ZERODROP
← INTEGRATIONSDOCS
PlaywrightBetter AuthGitHub Actions

Testing Better Auth Email Flows with Playwright

Better Auth ships email verification, magic links, OTP and password reset out of the box. This guide shows how to test all four flows end to end in Playwright CI — no Docker, no shared inboxes, no regex.

Install

npm install zerodrop-client better-auth

Email verification on signup

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();

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

  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { subject: "Verify" },
  });

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

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

Magic link sign-in

test("magic link 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 magic link")');

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

  await page.goto(email.magicLink!);
  await expect(page).toHaveURL("/dashboard");
});

Email OTP sign-in

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")');

  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { hasOtp: true },
  });

  // email.otp auto-extracted — no regex needed
  expect(email.otp).toBeTruthy();
  await page.fill('[name="otp"]', email.otp!);
  await page.click('[type="submit"]');

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

Password reset

test("password reset", async ({ page }) => {
  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,
    filter: { subject: "Reset" },
  });

  await page.goto(email.magicLink!);
  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();
});

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 }}

What you're actually testing

Email verification tokens are generated and delivered correctly
Magic links contain valid Better Auth callback URLs
OTP codes are extracted and accepted by Better Auth
Password reset links are single-use and expire correctly
Real email delivery via your provider (Resend, Postmark, etc.)

Working example repo

Full Next.js app with Better Auth configured, all four email flows, ready to clone and run.

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

Ready to test your Better Auth flows?

Free tier. No signup. No Docker. Works in CI in 5 minutes.

OPEN INBOX →

RELATED GUIDES

Playwright + NextAuthPlaywright + ClerkPlaywright + SupabaseExample repoFull article on dev.to