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
- One clock for mail. Give
waitForMessagea 45-second deadline and poll every 2 seconds, and let Playwright's ownexpecthandle the UI side. Two polling systems fighting each other is a classic flake source. - Retries need fresh addresses. Playwright retries re-run the whole test; the fixture generates a new inbox per attempt, so a retry can't be poisoned by the first attempt's mail.
- Rate limits. 240 reads/minute per client. Ten workers at 2-second polls is fine; a 64-worker fan-out should share one poller per process or back off on 429s.
When an email assertion fails at 2 a.m.
- Did the app send? Open the address in the Tempx app, if the mail is there, the test's matching failed; if not, it's the app.
- What arrived? The trace plus the inbox's message preview often settle it; for header-level questions use the header analyzer.
- Wrong content? The MIME decoder shows the exact text and HTML parts that were sent.
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