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.