Developer & CI

Email testing with Playwright, end to end

Give every Playwright test its own real, receivable inbox: a fixture, a signup spec that waits for the verification code, assertions on the mail itself, and what to change for CI.

On this page
  1. Why the email step is where suites go flaky
  2. Get a key and write a small helper
  3. Make the inbox a Playwright fixture
  4. The signup test
  5. Assert on the email, not only on the app
  6. Two settings that decide whether CI stays green
  7. Running it in CI

Playwright email testing stops being hard the moment the mailbox is just an HTTP API. The test creates a throwaway inbox, types that address into your signup form, then blocks until the message lands and reads the code out of it. No SMTP server to run, no IMAP polling loop, and no shared team mailbox that two parallel workers fight over.

What follows is the whole setup: the helper, the fixture, the spec, the assertions worth adding, and the two settings that decide whether it survives CI.

Why the email step is where suites go flaky

Most teams take one of two bad routes. They stub the mail out and assert that a sendEmail function was called, which proves the code path ran and nothing about whether a user could ever receive the message. Or they point every test at one real mailbox and search it for the newest subject line, which works fine on a laptop and falls apart the moment two workers sign up in the same second and each grabs the other’s code.

The fix is one fresh address per test. If the address is unique, there is nothing to race over, and the test can say exactly which message it is waiting for. Every other decision in playwright email testing follows from that one.

Get a key and write a small helper

Any mailbox that speaks HTTP will do here, and if you are still weighing options, which email API a test suite can actually drive sets out what to compare. Create a free key on the Email Testing API reference page, then export it:

export INBOXDOME_KEY=idk_live_...

One thin wrapper is enough. Everything here is a plain REST call with a bearer token.

// tests/helpers/inboxdome.ts
const BASE = 'https://inboxdome.com';
const KEY = process.env.INBOXDOME_KEY!;

export async function api(path: string, init: RequestInit = {}) {
  const res = await fetch(BASE + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  });
  if (!res.ok) throw new Error(`InboxDome ${path} failed: ${res.status}`);
  return res.json();
}

export async function createInbox(ttlSeconds = 3600) {
  const { inbox } = await api('/v1/inboxes', {
    method: 'POST',
    body: JSON.stringify({ domain: 'smart', ttlSeconds }),
  });
  return inbox as { id: string; emailAddress: string };
}

export async function waitForOtp(id: string, timeoutSeconds = 30) {
  const { otp } = await api(`/v1/inboxes/${id}/wait?for=otp&timeoutSeconds=${timeoutSeconds}`);
  return otp.value as string;
}

ttlSeconds accepts 600 to 604800 and defaults to 86400, so an inbox created mid-run disappears on its own long after the suite has finished.

Make the inbox a Playwright fixture

A fixture is the right home for this. Playwright builds it before the test body and tears it down after, and every test gets its own instance.

// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { createInbox } from './helpers/inboxdome';

type Fixtures = { inbox: { id: string; emailAddress: string } };

export const test = base.extend<Fixtures>({
  inbox: async ({}, use) => {
    await use(await createInbox());
  },
});

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

Keep it test scoped rather than worker scoped. A worker-scoped inbox is reused by every test that worker runs, which reintroduces the exact cross-talk you are trying to design out.

The signup test

// tests/signup.spec.ts
import { test, expect } from './fixtures';
import { waitForOtp } from './helpers/inboxdome';

test('new user verifies by email', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.emailAddress);
  await page.getByLabel('Password').fill('S3cure!pass');
  await page.getByRole('button', { name: 'Create account' }).click();

  const code = await waitForOtp(inbox.id, 30);

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

Notice there is no waitForTimeout anywhere in it. The wait endpoint long-polls: it holds the connection open until a message arrives, up to a timeoutSeconds ceiling of 50, and answers 408 when nothing turned up. That 408 is a useful failure. It means your application did not send, which is a real bug rather than a slow test.

Assert on the email, not only on the app

The browser flow proves the code worked. It says nothing about whether the message was well formed or authenticated. A single call covers both, and waits for the mail while it is at it:

const run = await api(`/v1/inboxes/${inbox.id}/assert`, {
  method: 'POST',
  body: JSON.stringify({
    timeoutSeconds: 45,
    assertions: [
      { type: 'otpPresent' },
      { type: 'containsText', text: 'Confirm your email' },
      { type: 'auth', check: 'dkim', expect: 'pass' },
      { type: 'auth', check: 'spf', expect: 'pass' },
    ],
  }),
});

expect(run.passed, JSON.stringify(run.results, null, 2)).toBe(true);

The response carries passed, arrived, arrivalSeconds and a results array with a detail string per assertion, so a red build tells you which condition failed, not just that one did. There is also a linkResponds assertion that fetches a detected link server side. It is excellent for catching a dead route, but treat it carefully on flows with single-use tokens, for the reason set out in testing a password reset email end to end.

Two settings that decide whether CI stays green

The first is the test timeout. Playwright’s default is 30 seconds, and a 45 second mail wait nested inside it gets killed by the runner before the API ever answers. Raise the test, not the wait:

test.setTimeout(90_000);

The second is concurrency. The free plan allows 100 inbox tests a month with a 20 per day burst, 30 requests a minute, and 2 concurrent waits. A long-poll counts for as long as it is open, so eight Playwright workers all parked in a wait will hit that ceiling. Either cap workers in playwright.config.ts to your plan’s concurrent wait limit, or move the email specs into their own project and run that project with fewer workers than the rest of the suite.

Running it in CI

Playwright email testing needs almost nothing extra in a pipeline. Store INBOXDOME_KEY as a repository secret and nothing else changes: the runner reaches the same API your laptop does, so there is no mail service to stand up in the pipeline. Send an Idempotency-Key header when creating an inbox and a retried job returns the original inbox instead of spending a second one from the monthly allowance.

For a ready-made workflow, see the GitHub Actions guide. For the shorter copy-paste version of the test above, see the Playwright OTP guide.

Disclosure: InboxDome publishes this blog and makes the products it mentions. We describe what they do rather than promise results.

Key takeaways

  • Playwright email testing works once the mailbox is an HTTP API: create an inbox, long-poll for the message, read the code.
  • Give every test its own inbox through a fixture. A shared address is what makes parallel email tests flaky.
  • Long-polling replaces sleep loops. The wait endpoint blocks up to 50 seconds and returns 408 if nothing arrives.
  • Assert on the mail itself, not only on the app: OTP present, expected copy, and SPF, DKIM and DMARC results.
  • Set the Playwright test timeout higher than the mail timeout, or the runner kills the test before the wait resolves.

Sources

  1. Playwright test fixtures — How to extend the base test object with a per-test fixture.
  2. Playwright timeouts — Test timeout versus expect timeout, and how to raise either.
  3. Playwright parallelism and workers — Worker processes and the workers option used to cap concurrency.
  4. RFC 7208: Sender Policy Framework — The SPF specification behind the auth assertions.

Links last checked August 30, 2026.

Frequently asked questions

Can Playwright read email on its own?

No. Playwright drives a browser and has no mail client. You need a mailbox it can reach over HTTP, so the test creates an inbox through an API, fills the signup form with that address, then asks the API for the message.

Do I need a real SMTP server for this?

No. Your application keeps sending through whatever provider it already uses. The test only needs an address that genuinely receives mail, which is what a disposable test inbox gives you. Nothing extra runs on your machine or in CI.

How do I stop email tests from being flaky in parallel?

Create one inbox per test inside a fixture so no two workers share an address, and long-poll instead of sleeping. Then check the concurrent wait limit on your plan and cap Playwright workers to match it.

Should tests delete their inboxes afterwards?

Only if you want the mail gone immediately. Inboxes expire on their own, with a default time to live of 86400 seconds, and a cleanup job runs every 15 minutes. A DELETE call on the inbox purges the address and its mail right away.

InboxDome TeamEmail infrastructure builders

The InboxDome team runs the temporary-email service and email-testing API this blog belongs to. We write about disposable inboxes, email privacy and testing transactional email, based on operating the infrastructure ourselves.