Use Case

Email Verification Testing in Playwright

TL;DR
Verdict A Playwright fixture that hands each test its own address turns email verification from the reason your signup test is skipped into three lines of setup.

Why signup tests get skipped

The flow is easy right up to the point where the application sends mail. Then the test needs an inbox, and every option is awkward. A shared Gmail account leaks state between runs and between engineers. Plus-addressing gets filtered by some providers and collides under parallel runs. Reading the code out of your own database works, but at that point the test no longer covers the thing that breaks in production, which is delivery.

A disposable inbox per test sidesteps all three. The address is fresh, nothing else writes to it, and the assertion happens against a real delivered message.

A fixture that hands each test its own inbox

Playwright fixtures are the natural home for this. The inbox is created once per test and the address is injected wherever the test needs it.

// fixtures.ts
import { test as base } from '@playwright/test';

const API = 'https://emptyinbox.me/api';
const KEY = process.env.EMPTYINBOX_API_KEY!;
const auth = { Authorization: `Bearer ${KEY}` };

type Inbox = {
  address: string;
  messages: () => Promise<any[]>;
};

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    const res = await fetch(`${API}/inbox`, { method: 'POST', headers: auth });
    const address = (await res.text()).trim();

    await use({
      address,
      messages: async () => {
        const r = await fetch(`${API}/messages`, { headers: auth });
        const all = await r.json();
        return all.filter((m: any) => m.inbox === address);
      },
    });
  },
});

export { expect } from '@playwright/test';

The API returns the new address as plain text, not JSON, which is why the fixture reads the body with text().

Waiting for the message

Do not reach for a fixed sleep. expect.poll retries until the message lands or the timeout expires, so a fast delivery does not pay for the slowest case.

import { test, expect } from './fixtures';

test('new user can verify their email', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct horse battery staple');
  await page.getByRole('button', { name: 'Create account' }).click();

  // Wait for delivery rather than guessing at a sleep duration.
  let message: any;
  await expect
    .poll(
      async () => {
        message = (await inbox.messages())[0];
        return message !== undefined;
      },
      {
        message: 'verification email never arrived',
        timeout: 30_000,
        intervals: [1_000, 2_000, 5_000],
      },
    )
    .toBe(true);

  expect(message.subject).toContain('Verify');

  const code = message.text_body.match(/\b\d{6}\b/)?.[0];
  expect(code, 'no 6-digit code in the email body').toBeDefined();

  await page.getByLabel('Verification code').fill(code!);
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();
});

If the email carries a link rather than a code, pull the URL out of text_body and navigate straight to it with page.goto. The html_body field is there when the link only appears in the HTML part.

Running tests in parallel

Because the fixture creates an inbox per test, parallel workers never share an address and there is nothing to reset between runs. Messages delete themselves after 7 days, so no teardown step is needed either.

One thing to watch: each inbox creation consumes one credit. A suite with 40 signup tests running daily uses about 1,200 credits a month, which is roughly $4 at bulk rates. If that matters, scope the fixture to test.describe rather than to each test, so a group of related assertions shares one address.

Keeping the key out of the repository

Register once, then store the key as a CI secret and read it from the environment, as the fixture above does.

curl -X POST https://emptyinbox.me/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "ci-playwright"}'

# {"api_key": "ei...", "inbox_quota": 5}

Frequently asked questions

Does this work with Playwright's parallel workers?

Yes. Each test gets its own inbox from the fixture, so workers never contend for the same address and no cleanup is required between runs.

How long does a verification email usually take to arrive?

Delivery is normally a few seconds, but it depends on the sending service rather than on EmptyInbox. Poll with a 30 second ceiling and short early intervals so fast deliveries finish fast.

Can I filter messages to a single inbox?

Yes. The messages endpoint returns every message on the account, each carrying an inbox field, so filter on that field as the fixture does.

How many credits does a test suite consume?

One per inbox created. Scoping the fixture to a describe block instead of a single test is the simplest way to cut that count when several assertions can share an address.

Put a real inbox in your test suite

Free tier includes 5 inboxes. No credit card required.

Get API Key