otpbox

OTPBox / Guides / Playwright

Playwright

Test email verification with Playwright

A signup flow that ends in "check your email for a code" is hard to test end-to-end, because the test needs an inbox that actually exists and actually receives mail. OTPBox gives Playwright a real disposable address and a way to wait for the code it receives — no mocking the email provider, no shared test account that other runs collide on.

The pattern

Every approach below follows the same four steps: create a real inbox, use its address in the signup form, block until the OTP or verification link arrives, then finish the flow and clean up. The difference is just how much boilerplate you write yourself.

Option 1: plain REST with fetch

No dependency beyond Playwright itself. Mint a free key once (200 requests/month, or use an organization key for CI — see Get a key), then call the API directly inside the test:

import { test, expect } from '@playwright/test';

test('sign up with a real OTP', async ({ page }) => {
  const key = process.env.OTPBOX_KEY!;

  const inboxRes = await fetch('https://otpbox.io/api/v1/inboxes', {
    method: 'POST',
    headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
    body: '{}',
  });
  const inbox = await inboxRes.json(); // { id, address, domain, expiresAt, token }

  await page.goto('https://your-app.example.com/signup');
  await page.fill('[name="email"]', inbox.address);
  await page.click('button[type="submit"]');

  // Poll for the code (a real production test should back off / cap retries)
  let code: string | null = null;
  for (let i = 0; i < 15 && !code; i++) {
    await new Promise((r) => setTimeout(r, 1500));
    const msgs = await fetch(`https://otpbox.io/api/v1/inboxes/${inbox.id}/messages`, {
      headers: { authorization: `Bearer ${key}` },
    }).then((r) => r.json());
    code = msgs.messages[0]?.code ?? null;
  }

  await page.fill('[name="otp"]', code!);
  await page.click('button[type="submit"]');
  await expect(page.locator('text=Welcome')).toBeVisible();

  await fetch(`https://otpbox.io/api/v1/inboxes/${inbox.id}`, {
    method: 'DELETE',
    headers: { authorization: `Bearer ${key}` },
  });
});

Option 2: otpbox-sdk (recommended)

otpbox-sdk is the first-party TypeScript client, published on npm, and removes the manual polling loop with waitForOtp():

npm install otpbox-sdk
import { test, expect } from '@playwright/test';
import { OTPBox } from 'otpbox-sdk';

test('sign up with a real OTP', async ({ page }) => {
  const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
  const inbox = await client.createInbox();

  try {
    await page.goto('https://your-app.example.com/signup');
    await page.fill('[name="email"]', inbox.address);
    await page.click('button[type="submit"]');

    const code = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
    await page.fill('[name="otp"]', code!);
    await page.click('button[type="submit"]');
    await expect(page.locator('text=Welcome')).toBeVisible();
  } finally {
    await client.deleteInbox(inbox.id);
  }
});

waitForOtp() polls getMessages() until a message received after the call started has an extracted code, or the timeout elapses (returns null on timeout — code! above is fine once you've asserted it in your own test, but check for null in real code). There's also waitForEmail() if you want the whole message, and getUsage() if a suite wants to assert it hasn't burned through its quota.

Option 3: otpbox-playwright fixture (in progress)

A dedicated Playwright package, otpbox-playwright, wraps otpbox-sdk in a custom test fixture so a spec doesn't even call createInbox()/deleteInbox() itself:

import { test, expect } from 'otpbox-playwright';

test('sign up with a real OTP', async ({ page, otpboxInbox }) => {
  await page.goto('https://your-app.example.com/signup');
  await page.fill('[name="email"]', otpboxInbox.address);
  await page.click('button[type="submit"]');

  const code = await otpboxInbox.waitForOtp();
  await page.fill('[name="otp"]', code);
  await page.click('button[type="submit"]');
  await expect(page.locator('text=Welcome')).toBeVisible();
  // no inbox.delete() to write - the fixture tears it down for you
});
Status: otpbox-playwright isn't published to npm yet. It's real, working code in the same monorepo as otpbox-sdk, but until it ships as a package you'd install it via npm link against a clone of the repo rather than npm install otpbox-playwright. Until then, Option 2 (otpbox-sdk directly) is the supported path and gives you the same waitForOtp()/waitForVerificationLink() behavior with a few extra lines of setup/teardown.

Verifying via a link instead of a code

Some signup flows email a confirmation link rather than a 6-digit code. The same inbox works — use waitForEmail() and read message.link (or, with otpbox-sdk, fetch the full message via getMessage(messageId) for link.url):

const message = await client.waitForEmail(inbox.id, { timeoutMs: 20_000 });
const full = await client.getMessage(message!.id);
await page.goto(full.link!.url);
await expect(page.locator('text=Verified')).toBeVisible();

CI notes

Next steps

Ready to try it against your own app? Create a free account

← Back to OTPBox