Guides

Test magic links and email OTPs with Playwright

Capture and test verification links, magic links, password resets, and email OTPs with Playwright.

To test an email link or OTP with Playwright, give each test a unique InboxTap recipient, trigger the real browser flow, await the matching value from the captured email, and continue in the browser. The same pattern covers sign-up verification, magic-link sign-in, password resets, email OTPs, and invitations without a shared test inbox.

We tested this exact pattern in InboxTap's runnable Better Auth and Next.js example, including verification links, magic links, OTP sign-in, resend behavior, and parallel Playwright workers.

Start the services

Start InboxTap alongside the application in Playwright's webServer configuration or in your CI service setup. One option is to run both commands before the test process:

npx inboxtap &
npm run dev &
npx playwright test

Configure the application launched by the test to use SMTP localhost:1025. Keep InboxTap on the loopback interface even in CI.

import { test, expect } from "@playwright/test";
import { InboxTapClient } from "inboxtap/client";

const inboxTap = new InboxTapClient();

test("verifies a new account", async ({ page }) => {
  const inbox = await inboxTap.createInbox({ alias: "signup" });

  await page.goto("/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Create account" }).click();

  const verificationUrl = await inbox.waitForLink({
    subject: /verify your email/i,
    contains: "/verify",
    timeoutMs: 20_000,
  });

  await page.goto(verificationUrl);
  await expect(page.getByText("Email verified")).toBeVisible();
});

For a magic link or password reset, keep the test structure and change the subject and contains filters to match the email contract, such as /sign in/i with /auth/callback or /reset password/i with /reset-password.

Test an email OTP

Use waitForCode() when the application asks the user to copy a code rather than follow a link:

test("signs in with an email OTP", async ({ page }) => {
  const inbox = await inboxTap.createInbox({ alias: "signin" });

  await page.goto("/sign-in");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Send code" }).click();

  const otp = await inbox.waitForCode({
    subject: /sign-in code/i,
    timeoutMs: 20_000,
  });

  await page.getByLabel("One-time code").fill(otp);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page.getByText("Signed in")).toBeVisible();
});

Choose the right helper

Email flowInboxTap helperUseful filters
Verification linkwaitForLink()subject, contains
Magic-link sign-inwaitForLink()subject, callback path
Password resetwaitForLink()subject, reset path
Numeric email OTPwaitForCode()subject, pattern
Invitation or custom tokenwaitForMatch()subject, regular expression

Parallel workers

Create the inbox inside the test or fixture, never once at module scope for the whole suite. Each call generates a new recipient address, so Playwright workers can share one InboxTap server without reading one another's messages.

Call await inbox.clear() after a test when immediate cleanup helps local debugging. The server also evicts old messages when its bounded store reaches maxMessages.

Frequently asked questions

Can Playwright read an email directly?

Playwright's browser page does not need inbox credentials or a hosted email account. The Node-side test uses InboxTapClient to await the captured email, then passes the extracted link or code back into browser actions.

How do parallel Playwright workers avoid reading the same email?

Create an inbox inside each test or fixture. Every createInbox() call returns a unique recipient, so all workers can share one local InboxTap server while filtering messages by their own address.

Do Playwright email tests need Docker?

No. Start InboxTap with npx inboxtap, bunx inboxtap, or as an in-process server, then point the application's SMTP configuration at localhost:1025.