Testing email verification flows with Playwright

End-to-end test signup links, one-time codes, and password resets in Playwright with a fresh, real inbox per test. No mail server, mocks, or API keys.

On this page
  1. The approach
  2. Setup
  3. An inbox fixture
  4. Verification link
  5. One-time code
  6. Password reset
  7. Keeping it reliable
  8. Running in CI

Email is where signup tests stop being honest. The verification step gets mocked, or skipped with a database flag, or checked once by hand before a release. The tests pass, and a broken template, a link to the wrong host, or a misconfigured mail provider ships anyway.

The fix is to let the test receive the real email. This guide sets up Playwright so every test gets its own real inbox, with no mail server to run and no API key to manage, and then uses it for verification links, one-time codes, and password resets.

The approach

Agent Temp Mail addresses are derived from a keypair, so a new inbox is a local function call. generateIdentity() returns a key, and that key’s address can receive mail straight away. Each test gets isolation for free: no shared inbox, no cleanup between runs, no cross-talk between parallel workers.

Every test follows the same shape:

  1. Create an inbox.
  2. Note the time, then drive the UI to trigger the email.
  3. Wait for the message and pull out the link or code.
  4. Finish the flow in the browser and assert the result.

Setup

npm install --save-dev @playwright/test agent-temp-mail

The SDK needs Node.js 22.12 or later.

A fixture that gives every test an inbox

Playwright fixtures are the right home for this. The one below hands each test a fresh address and a receive() helper that takes the timestamp before running the action that sends the email, so a test can’t accidentally read an older message.

// tests/fixtures.ts
import { test as base } from "@playwright/test";
import { generateIdentity, MailClient, type Message } from "agent-temp-mail";

type Inbox = {
  address: string;
  /** Runs `trigger`, then waits for the email it causes. */
  receive: (trigger: () => Promise<unknown>, sender: string) => Promise<Message>;
};

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    const mail = new MailClient(generateIdentity());

    await use({
      address: mail.address,
      async receive(trigger, sender) {
        const since = new Date().toISOString();
        await trigger();
        // Most mail lands within a few seconds; a short pause lets the
        // first check find it instead of waiting a full polling interval.
        await new Promise((resolve) => setTimeout(resolve, 3_000));
        const { messages } = await mail.wait({ since, sender, timeoutSeconds: 90 });
        const latest = messages[messages.length - 1];
        if (!latest) throw new Error(`No email from ${sender} within 90 seconds`);
        return mail.get(latest.id);
      },
    });
  },
});

export { expect } from "@playwright/test";
// tests/signup.spec.ts
import { test, expect } from "./fixtures";

test("a new user can verify their email", async ({ page, inbox }) => {
  test.setTimeout(120_000);

  await page.goto("/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByLabel("Password").fill("a-long-test-password-123");

  const email = await inbox.receive(
    () => page.getByRole("button", { name: "Create account" }).click(),
    "hello@yourapp.example",
  );
  expect(email.subject).toContain("Confirm your email");

  const link = email.candidates.links.find((l) => l.likely_verification);
  expect(link, "verification link").toBeDefined();
  expect(new URL(link!.url).origin).toBe(new URL(page.url()).origin);

  await page.goto(link!.url);
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});

The origin assertion is the one people skip and later wish they hadn’t. It catches staging emails that link to production, and emails from every environment that link to localhost.

Test: enter a one-time code

test("passwordless sign-in with an emailed code", async ({ page, inbox }) => {
  test.setTimeout(120_000);

  await page.goto("/login");
  await page.getByLabel("Email").fill(inbox.address);

  const email = await inbox.receive(
    () => page.getByRole("button", { name: "Email me a code" }).click(),
    "hello@yourapp.example",
  );

  const code = email.candidates.otp_codes.find((c) => /^\d{6}$/.test(c.value));
  expect(code, `no 6-digit code in: ${email.text.slice(0, 200)}`).toBeDefined();

  await page.getByLabel("Code").fill(code!.value);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Matching the shape you expect, here exactly six digits, beats taking the first candidate. Templates often put other numbers near the word “code,” such as a support reference. Including the start of the message in the failure text makes a broken template obvious from the test report alone.

Test: password reset

test("password reset works end to end", async ({ page, inbox }) => {
  test.setTimeout(180_000);
  await signUpAndVerify(page, inbox); // a helper built from the first test

  await page.goto("/forgot-password");
  await page.getByLabel("Email").fill(inbox.address);

  const email = await inbox.receive(
    () => page.getByRole("button", { name: "Send reset link" }).click(),
    "hello@yourapp.example",
  );
  const reset = email.candidates.links.find(
    (l) => new URL(l.url).pathname.startsWith("/reset-password"),
  );
  expect(reset, "reset link").toBeDefined();

  await page.goto(reset!.url);
  await page.getByLabel("New password").fill("another-long-password-456");
  await page.getByRole("button", { name: "Save password" }).click();
  await expect(page.getByText("Password updated")).toBeVisible();
});

This test receives two emails at the same address. Because receive() takes its own timestamp each time, the signup email already in the inbox can’t be mistaken for the reset email.

Keeping it reliable

  • Take the timestamp before the click. receive() does it for you. Keep that rule in any helper you write.
  • Prefer fresh inboxes to cleanup. Playwright retries a failed test in a new worker, which gets a new fixture and therefore a new address, so a stale message can’t make a retry pass.
  • Raise the test timeout. Playwright’s default is 30 seconds per test, and waiting for email can take longer. Set it per test, as above, or in your config.
  • Filter by sender. The filter is an exact match on the From address. If staging sends from a different address than production, keep it in config.
  • Assert on content, not just arrival. Check the subject, the recipient’s name, and where the links point. An email that arrives with the wrong link is still a bug.
  • Budget time per email. After the first check, wait() follows the service’s suggested interval, currently 15 seconds. With the short pause in the fixture, most email steps finish in a few seconds; a slow sender can add fifteen or thirty.

Running it in CI

Because inboxes come from keys generated inside the test, there’s nothing to configure in CI: no mail server container, no secrets, no API key to rotate. A few habits keep a suite fast and dependable:

  • Test the flows that matter. Sign-up, sign-in, password reset, and email change cover most real-world breakage. Unit-test template details separately.
  • Run in parallel freely. Every test has its own address, so workers never read each other’s mail.
  • Assert on what users see. Messages arrive as clean text with links and codes extracted, which makes content and link assertions short and readable.
  • Keep secrets out entirely. Keys are generated per test and discarded, so there’s nothing sensitive to store in your CI configuration.

Looking at other options? What to check in a temporary email API lists the questions worth asking.