playwright

How to Test OTP Flows in Playwright (Without Mocking or Flaky Tests)

8 min read

Testing OTP flows in Playwright is annoying. Let's just say it.

Your app fires off a 6-digit code to some email address. Your test needs to grab it and type it back into a form. Except… where's it coming from? Playwright doesn't have an inbox. Your CI runner definitely doesn't.

So you cheat. We've all written one of these:

  • The hardcode. Set OTP=123456 in test env, drop an if (env.TEST_MODE) accept("123456") in your API. Ship the whole mess to prod six months later as a Sev-1.
  • Gmail IMAP polling. Works for two weeks. Then Google flags the login as "suspicious activity" and CI goes red every Wednesday at 3am.
  • MailSlurp. Fine, but now you're writing 30 lines of inbox lifecycle code for one 6-digit number. And paying $49/mo for the privilege.

There's a way better option. It's basically one line.


What the "bad" versions actually look like

Let's start with the version we've all shipped at least once and pretended we hadn't:

// The bad-but-common approach: hardcode + backend bypass
test("signup with OTP", async ({ page }) => {
  await page.goto("/signup");
  await page.fill("[name=email]", "test@example.com");
  await page.click("button[type=submit]");

  // Backend accepts "000000" in test mode. Fingers crossed nobody
  // ships the bypass flag to prod.
  await page.fill("[name=otp]", "000000");
  await expect(page).toHaveURL("/dashboard");
});

This test passes. It also tests almost nothing — you're skipping OTP generation, delivery, and expiry logic entirely. The day someone changes how OTPs are derived, this happily stays green while prod burns.

Now the "grown-up" version with MailSlurp:

import { MailSlurp } from "mailslurp-client";

const mailslurp = new MailSlurp({ apiKey: process.env.MAILSLURP_API_KEY });

test("signup with OTP", async ({ page }) => {
  const inbox = await mailslurp.createInbox();

  await page.goto("/signup");
  await page.fill("[name=email]", inbox.emailAddress);
  await page.click("button[type=submit]");

  // Wait up to 30 seconds for the email
  const email = await mailslurp.waitForLatestEmail(inbox.id, 30000);

  // Custom regex. Pray your email template never changes
  const match = email.body.match(/\b\d{6}\b/);
  if (!match) throw new Error("OTP not found");
  const otp = match[0];

  await page.fill("[name=otp]", otp);
  await expect(page).toHaveURL("/dashboard");

  await mailslurp.deleteInbox(inbox.id); // cleanup or hit inbox limits
});

Better — this test is actually real. But look at what you're juggling now: inbox lifecycle, brittle regex, a 30-second polling budget, cleanup logic, and a $49/mo minimum for the privilege. All to read one code.


The one-line version

Here's the same test using otpmagiclink:

import { SandboxClient } from "@otpmagiclink/playwright";

const sandbox = new SandboxClient({
  apiKey: process.env.OTP_API_KEY!,
});

test("signup with OTP", async ({ page }) => {
  await page.goto("/signup");
  await page.fill("[name=email]", "test-user@yourapp.com");
  await page.click("button[type=submit]");

  const otp = await sandbox.waitForOtp("test-user@yourapp.com");

  await page.fill("[name=otp]", otp);
  await expect(page).toHaveURL("/dashboard");
});

That's it. No inbox to create. No regex to write. No cleanup. waitForOtp() polls the sandbox and returns the code the moment your app sends it — usually in the milliseconds.


How does it actually work?

Your app (or your auth library) already sends OTPs somehow — Resend, SendGrid, Postmark, SMTP, whatever. In your test environment, you point that send at the sandbox instead of a real inbox. From that point on, every OTP or magic link your app generates gets captured before it hits anyone's real email.

The SDK gives you three things:

  • waitForOtp(identifier) — poll until a 6-digit code arrives, return the code
  • waitForMagicLink(identifier) — poll until a magic link arrives, return the URL
  • getInbox(identifier) — full message history for one address, if you want to inspect it

And because every project has its own scoped sandbox (not a shared Mailinator-style pool), parallel tests never collide. Two workers using the same email address at the same second? Each one sees its own message stream.


Testing Better Auth

Better Auth has been eating NextAuth's lunch this year, and its email-OTP plugin exposes a sendVerificationOTP hook. That's the whole integration:

// auth.ts
import { betterAuth } from "better-auth";
import { emailOTP } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    emailOTP({
      async sendVerificationOTP({ email, otp }) {
        if (process.env.NODE_ENV === "test") {
          // Route to sandbox — the SDK will pick it up
          return sendToSandbox({ email, otp });
        }
        return resend.emails.send({
          from: "auth@yourapp.com",
          to: email,
          subject: "Your code",
          text: `Your code is ${otp}`,
        });
      },
    }),
  ],
});

Your Playwright test then looks exactly like the one above. No Better Auth-specific glue in the test file — just waitForOtp().


Testing Clerk

Clerk's a little different because you can't intercept their send. What you can do is use their built-in test emails (anything matching +clerk_test@) and point the delivery target at your sandbox:

test("clerk email OTP signup", async ({ page }) => {
  await page.goto("/sign-up");
  await page.fill("input[name=emailAddress]", "test+clerk_test@yourapp.com");
  await page.click("button[type=submit]");

  const otp = await sandbox.waitForOtp("test+clerk_test@yourapp.com");

  await page.fill("input[name=code]", otp);
  await expect(page).toHaveURL("/dashboard");
});

Real Clerk sign-up flow. Real webhook. Real code. Just with the delivery redirected at the sandbox instead of the internet. And you don't burn Clerk's paid test-mode API credits doing it.


Running in CI

The whole point of E2E tests is CI. Here's what you need — one job, no inbox provisioning, no cleanup step:

name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps

      - run: npx playwright test
        env:
          OTP_API_KEY: ${{ secrets.OTP_API_KEY }}

Put your API key in GitHub Secrets and you're done. That's the entire CI setup.


Parallel tests won't fight each other

Playwright's default is to run tests in parallel, which is where shared-inbox tools like Mailinator implode. test@mailinator.com in two workers = they both wait for the same code, one steals it, the other times out.

The sandbox scopes everything to your API key, so pick any identifier and each test gets its own message stream:

test.describe.parallel("parallel signup tests", () => {
  test("user 1", async ({ page }) => {
    const otp = await sandbox.waitForOtp("user-1@yourapp.com");
    // ...
  });

  test("user 2", async ({ page }) => {
    const otp = await sandbox.waitForOtp("user-2@yourapp.com");
    // ...
  });
});

Two workers. Two identifiers. Zero collisions.


Magic links are the same story

Instead of a 6-digit code, you get the URL. Same shape:

test("signup with magic link", async ({ page }) => {
  await page.goto("/signup");
  await page.fill("[name=email]", "test@yourapp.com");
  await page.click("button[type=submit]");

  const link = await sandbox.waitForMagicLink("test@yourapp.com");

  await page.goto(link);
  await expect(page).toHaveURL("/dashboard");
});

That's the whole magic-link test. No email parsing. No URL extraction. Just the link you asked for.


Try it

Free tier covers 1 project with unlimited test runs — enough for a solo project or an evaluation weekend. Pro is $19/mo for unlimited projects and CI/CD support. No SMS surcharges, no "you exceeded 5,000 tests this month" surprises.

Sign up free →

Evaluating against MailSlurp or Mailosaur? Read the comparison →

Ship OTP + magic-link tests today

@otpmagiclink/playwright is a one-line SDK for waiting on OTPs and magic links in Playwright, Cypress, or any Node test runner. Free tier included.