How to Test Email Sending with Playwright

By Denis · Published 2026-09-16.

The basic recipe (sign up, poll an inbox, extract a code) is one page long. This is the version that survives contact with a real suite: fixtures, parallel workers, CI, retries and debugging.

Start from the fixture, not the spec

A fixture gives every test a fresh inbox without repeating yourself. It wraps the polling helper so specs read as intent:

// fixtures.mjs
import { test as base } from "@playwright/test";
import { waitForMessage } from "./wait-for-email.mjs";

export const test = base.extend({
 inbox: async ({}, use) => {
 const address = "pw-" + Math.random().toString(36).slice(2, 8) + "@tempx.uk";
 await use({
 address,
 waitFor: (opts) => waitForMessage(address, opts),
 });
 },
});
export { expect } from "@playwright/test";

A spec becomes:

import { test, expect } from "./fixtures.mjs";

test("welcome mail arrives after signup", async ({ page, inbox }) => {
 await page.goto("/signup");
 await page.getByLabel("Email").fill(inbox.address);
 await page.getByRole("button", { name: "Create account" }).click();

 const msg = await inbox.waitFor({
 timeoutMs: 45000,
 match: (m) => /welcome/i.test(m.subject),
 });
 expect(msg.from).toContain("noreply@example.com");
});

Parallel workers are free, if addresses are unique

Playwright runs workers in parallel, and each test in the fixture gets its own random address. Because Tempx inboxes exist implicitly (mail sent to any name creates the mailbox), there is no setup API to race and no teardown. Two rules keep it hermetic: never reuse an address across tests, and never assert on all inbox contents, match the message, don't count it.

CI: no secrets, one env var

Named-inbox reads need no credentials, so the CI config is boring, which is the point:

#.github/workflows/e2e.yml (excerpt)
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
 env:
 TEST_RUN_ID: ${{ github.run_id }} # unique per run
 BASE_URL: https://staging.example.com

Timeouts and retries without flakes

When an email assertion fails at 2 a.m.

The copy-paste starter spec lives in the Playwright integration; Cypress and Selenium have their own.

Retries, traces, and evidence that survives

An email spec that flakes turns on evidence: the difference between a ten-minute fix and a shrugged rerun. Two habits produce it. First, fold the address into the test title or annotations so a red run names its own mailbox: test.info().annotations.push({ type: "inbox", description: address }) puts the address into the HTML report, where a human can open the inbox in the web app seconds later. Second, keep the wait inside the fixture so Playwright's trace viewer shows the polling timeline, the trace makes "never sent" visibly different from "matched wrong" from "arrived at 44.9 s of a 45 s timeout".

Retry discipline stays the same as any slow spec: retries: 1 at most for scheduled lanes, zero on the every-commit lane, and never a retry that reuses the address, the fixture mints a fresh one per attempt, which is what keeps a rerun honest. If a specific mail step flakes repeatedly, the fix is a wider match (subject over preview, sender over subject) rather than a bigger hammer on timeout.

The reset spec, in Playwright form

Password reset is the flow that benefits most from the fixture, because its assertions span two sessions. The shape:

test("reset link is single-use", async ({ page, inbox }) => {
 await page.goto("/forgot-password");
 await page.getByLabel("Email").fill(inbox.address);
 await page.getByRole("button", { name: "Send link" }).click();
 const msg = await inbox.waitFor({ match: (m) => /reset/i.test(m.subject) });
 const link = msg.preview.match(/https:\/\/example\.com\/reset\?t=[\w-]+/)?.[0];

 await page.goto(link); // first use: form opens
 await page.getByLabel("New password").fill("n3w-pass-!");
 await page.getByRole("button", { name: "Save" }).click();

 const second = await page.context().browserPage(); // fresh context
 await second.goto(link); // second use: rejected
 await expect(second.getByText(/invalid|expired/i)).toBeVisible();
});

The same skeleton carries the enumeration check (registered versus unregistered address behave identically) and the expiry check with a staging-short TTL. Each lives in its own test with its own inbox, reset specs are cheap to parallelize because the mailbox is per-test.

Sharding without tripping the read limit

Playwright shards across machines happily; the inbox side has one shared number to respect, 240 reads per minute per client. The math is friendly: a two-second poll is thirty reads per minute per waiting test, so the ceiling is roughly eight concurrently-waiting tests per CI machine. Below that, nothing to do; above it, stretch the poll interval under load or batch the waiting (one helper that polls a set of addresses and resolves waiters). The failure mode of exceeding it is a clean 429, not corruption, but a flaky suite is a flaky suite, and the config is cheaper than the debugging.

One last CI nicety: keep the scheduled lane's failures attached to the inbox address and the matched subject regex. The person debugging at 9 a.m. was not watching at 3 a.m.; give them the two strings they need to reproduce the state by hand.

Organizing specs by flow, not by page

The suite shape that ages well mirrors the product's flows, not its URL map: one spec file per mail-touching flow (signup, verify, reset, notify), each self-contained with its own fixture-minted inbox and its own assertions about links, codes and timing. Page-shaped organization ("login.spec contains everything touching the login page") couples unrelated assertions to one address and one session, which is the shared-state trap the fixture exists to prevent.

Within a flow spec, keep the email helper's match vocabulary tight and named: a constant for the sender you expect, a constant for the subject fragment, the extraction regex beside them. Marketing changes a subject line and one file changes; the fixture mints a new address per test and nothing else in the spec ever knows.

Two fixtures patterns beyond the basic

Once the per-test inbox is routine, two extensions earn their keep. Worker-scoped inboxes for sweep specs: a fixture at the worker level gives every test in a file one shared address, wrong for assertions, right for "send ten notifications and confirm the inbox holds ten distinct ids", the counting tests that per-test inboxes make awkward. A paired page-and-inbox fixture for two-session flows: reset and change-email specs want a logged-in session plus a fresh inbox in one grant, and destructuring both from a single fixture keeps the spec's first line honest about what the scenario touches.

Both follow the original fixture's rule: the spec should read as the scenario, and the fixture should silently own the uniqueness. The day a spec manually concatenates a timestamp into an address is the day the abstraction leaked, fix the fixture, not the spec.

Create a temporary email address

Free, instant, no sign-up, messages auto-delete.

Go to your inbox