DEV Community

Cover image for How to Test Better Auth Email Verification with Playwright
zerodrop
zerodrop

Posted on

How to Test Better Auth Email Verification with Playwright

Better Auth has become the go-to TypeScript authentication framework — email verification, magic links, OTP, password reset, all built in. But testing those email flows in CI is a problem nobody talks about.

Your app sends a verification email. Your Playwright test needs to read it, click the link, and assert the user is verified. Without a way to catch that email, you're either mocking it or skipping it entirely.

This guide shows how to test all four Better Auth email flows end to end with Playwright and ZeroDrop — no Docker, no shared inboxes, no regex.

The four flows we're testing

Better Auth ships these email flows out of the box:

  1. Email verification — link sent on signup via emailVerification
  2. Magic link — passwordless login via the magicLink plugin
  3. Email OTP — code-based login/verification via the emailOTP plugin
  4. Password reset — reset link via sendResetPassword

Every one of them sends a real email. Every one of them needs a real inbox to test properly.

Setup

Install the dependencies:

npm install zerodrop-client @playwright/test
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode

No API key needed for ZeroDrop. No signup. No environment variables.

Better Auth config

Here's the full Better Auth setup with all four email flows:

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

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    sendResetPassword: async ({ user, url }) => {
      // Send via your email provider (Resend, Postmark, etc.)
      await sendEmail({ to: user.email, subject: "Reset your password", text: url });
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({ to: user.email, subject: "Verify your email", text: url });
    },
  },

  plugins: [
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendEmail({ to: email, subject: "Your magic link", text: url });
      },
    }),

    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        await sendEmail({ to: email, subject: "Your verification code", text: `Your code: ${otp}` });
      },
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Testing email verification on signup

Better Auth sends a verification link when a user signs up. ZeroDrop catches it, email.magicLink is auto-extracted:

import { test, expect } from "@playwright/test";
import { ZeroDrop } from "zerodrop-client";

const mail = new ZeroDrop();

test("email verification on signup", async ({ page }) => {
  const inbox = mail.generateInbox();

  // Sign up with a ZeroDrop inbox
  await page.goto("/signup");
  await page.fill('[name="email"]', inbox);
  await page.fill('[name="password"]', "TestPassword123!");
  await page.click('[type="submit"]');

  // Better Auth shows "check your email" page
  await expect(page.locator("text=Check your email")).toBeVisible();

  // Wait for the verification email — arrives in <1s
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { subject: "Verify" },
  });

  // magicLink auto-extracted — no regex needed
  expect(email.magicLink).toBeTruthy();

  // Navigate to the verification link
  await page.goto(email.magicLink!);

  // Better Auth verifies the user and redirects
  await expect(page).toHaveURL("/dashboard");
});
Enter fullscreen mode Exit fullscreen mode

Testing magic link login

test("magic link login", async ({ page }) => {
  const inbox = mail.generateInbox();

  // Request a magic link
  await page.goto("/login");
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send magic link")');

  await expect(page.locator("text=Magic link sent")).toBeVisible();

  // Catch the email
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { hasMagicLink: true },
  });

  expect(email.magicLink).toBeTruthy();

  // Navigate to the magic link — Better Auth signs the user in
  await page.goto(email.magicLink!);
  await expect(page).toHaveURL("/dashboard");
});

test("magic link is single-use", async ({ page, context }) => {
  const inbox = mail.generateInbox();

  await page.goto("/login");
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send magic link")');

  const email = await mail.waitForLatest(inbox, { timeout: 15000 });
  const magicLink = email.magicLink!;

  // First use — succeeds
  await page.goto(magicLink);
  await expect(page).toHaveURL("/dashboard");

  // Second use — Better Auth rejects the consumed token
  const page2 = await context.newPage();
  await page2.goto(magicLink);
  await expect(page2.locator(".error-message")).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Testing email OTP

Better Auth's emailOTP plugin sends a 6-digit code. email.otp is auto-extracted — no regex:

test("email OTP sign-in", async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto("/login");
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send code")');

  await expect(page.locator("text=Code sent")).toBeVisible();

  // OTP auto-extracted at Cloudflare's edge
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { hasOtp: true },
  });

  expect(email.otp).toBeTruthy();
  expect(email.otp).toMatch(/^\d{6}$/);

  // Enter the code
  await page.fill('[name="otp"]', email.otp!);
  await page.click('[type="submit"]');

  await expect(page).toHaveURL("/dashboard");
});
Enter fullscreen mode Exit fullscreen mode

Testing password reset

test("password reset via email link", async ({ page }) => {
  const inbox = mail.generateInbox();

  // Request a password reset
  await page.goto("/forgot-password");
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  // Catch the reset email
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { subject: "Reset" },
  });

  expect(email.magicLink).toBeTruthy();

  // Navigate to the reset link
  await page.goto(email.magicLink!);

  // Set new password
  await page.fill('[name="password"]', "NewPassword123!");
  await page.fill('[name="confirmPassword"]', "NewPassword123!");
  await page.click('[type="submit"]');

  await expect(page.locator("text=Password updated")).toBeVisible();
});

test("reset link is single-use", async ({ page, context }) => {
  const inbox = mail.generateInbox();

  await page.goto("/forgot-password");
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  const email = await mail.waitForLatest(inbox, { timeout: 15000 });
  const resetLink = email.magicLink!;

  // First use — succeeds
  await page.goto(resetLink);
  await page.fill('[name="password"]', "NewPassword123!");
  await page.click('[type="submit"]');
  await expect(page.locator("text=Password updated")).toBeVisible();

  // Second use — Better Auth rejects the consumed token
  const page2 = await context.newPage();
  await page2.goto(resetLink);
  await expect(page2.locator(".error-message")).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Parallel test runs — no collisions

generateInbox() runs locally with no network request. Each parallel worker gets a unique inbox automatically:

test.describe.configure({ mode: "parallel" });

test("user A signup", async ({ page }) => {
  const inbox = mail.generateInbox(); // unique per worker
  // ...
});

test("user B signup", async ({ page }) => {
  const inbox = mail.generateInbox(); // different inbox, no collision
  // ...
});
Enter fullscreen mode Exit fullscreen mode

10 parallel workers. 10 isolated inboxes. Zero race conditions.

GitHub Actions CI

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 chromium
      - run: npx playwright test
    env:
      BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
      RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

No Docker. No SMTP service. ZeroDrop works out of the box.

How email.otp and email.magicLink work

ZeroDrop catches emails at Cloudflare's edge before storing them. When a Better Auth email arrives, the worker extracts:

  • email.otp — 4-8 digit numeric code found near labels like code, otp, verification
  • email.magicLink — URL containing verify, confirm, reset, token, or auth

Both fields are null if not detected. Always assert before using:

expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
Enter fullscreen mode Exit fullscreen mode

Working example repo

All four tests are in a working example repo with the full Better Auth + Next.js app:

github.com/zerodrop-dev/better-auth-playwright-zerodrop

Clone it, add your .env, and run pnpm test.

Conclusion

Better Auth ships complete email flows out of the box. Testing them end to end doesn't require Docker, shared inboxes, or regex.

email.otp and email.magicLink — auto-extracted at the edge. Your test just reads them.

Free to use. No signup required. Works in CI out of the box.

zerodrop.dev · npm · GitHub

Top comments (0)