rification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
await deliverMessage({
to: user.email,
subject: "Account Verification",
content: url,
});
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await deliverMessage({
to: email,
subject: "Sign In Link",
content: url,
});
},
}),
emailOTP({
sendVerificationOTP: async ({ email, otp, type }) => {
await deliverMessage({
to: email,
subject: "Your Verification Code",
content: Code: ${otp},
});
},
}),
],
});
}
#### 3. Test Harness Abstraction
To avoid code duplication and ensure consistent error handling, wrap the email client in a test utility. This abstraction manages inbox lifecycle and provides typed access to extracted tokens.
```typescript
// tests/helpers/email-capture.ts
import { ZeroDrop } from "zerodrop-client";
export interface CaptureCriteria {
subjectKeyword?: string;
expectOtp?: boolean;
expectLink?: boolean;
timeoutMs?: number;
}
export class EmailCaptureService {
private client: ZeroDrop;
constructor() {
this.client = new ZeroDrop();
}
provisionInbox(): string {
return this.client.generateInbox();
}
async captureMessage(
targetInbox: string,
criteria: CaptureCriteria
): Promise<{ otp?: string; magicLink?: string }> {
const filter: Record<string, unknown> = {};
if (criteria.subjectKeyword) filter.subject = criteria.subjectKeyword;
if (criteria.expectOtp) filter.hasOtp = true;
if (criteria.expectLink) filter.hasMagicLink = true;
const result = await this.client.waitForLatest(targetInbox, {
timeout: criteria.timeoutMs || 15000,
filter,
});
return {
otp: result.otp,
magicLink: result.magicLink,
};
}
}
4. Implementation Examples
The following tests demonstrate how to validate each flow. Note the use of data-testid selectors and the structured assertions on extracted tokens.
Email Verification Flow
import { test, expect } from "@playwright/test";
import { EmailCaptureService } from "../helpers/email-capture";
test.describe("Account Registration", () => {
let emailService: EmailCaptureService;
let testInbox: string;
test.beforeEach(() => {
emailService = new EmailCaptureService();
testInbox = emailService.provisionInbox();
});
test("verifies user via email link", async ({ page }) => {
await page.goto("/register");
await page.fill('input[data-testid="email-input"]', testInbox);
await page.fill('input[data-testid="password-input"]', "SecurePass99!");
await page.click('button[data-testid="submit-registration"]');
await expect(page.locator('text=Check your inbox')).toBeVisible();
const captured = await emailService.captureMessage(testInbox, {
subjectKeyword: "Verification",
expectLink: true,
});
expect(captured.magicLink).toBeDefined();
expect(captured.magicLink).toContain("/api/auth/verify-email");
await page.goto(captured.magicLink!);
await expect(page).toHaveURL("/dashboard");
await expect(page.locator('text=Welcome')).toBeVisible();
});
});
Magic Link Authentication
test.describe("Passwordless Login", () => {
let emailService: EmailCaptureService;
let testInbox: string;
test.beforeEach(() => {
emailService = new EmailCaptureService();
testInbox = emailService.provisionInbox();
});
test("authenticates via magic link", async ({ page }) => {
await page.goto("/login");
await page.fill('input[data-testid="email-input"]', testInbox);
await page.click('button[data-testid="send-magic-link"]');
await expect(page.locator('text=Link sent')).toBeVisible();
const captured = await emailService.captureMessage(testInbox, {
expectLink: true,
});
expect(captured.magicLink).toMatch(/token=/);
await page.goto(captured.magicLink!);
await expect(page).toHaveURL("/dashboard");
});
test("rejects consumed magic link", async ({ page, context }) => {
await page.goto("/login");
await page.fill('input[data-testid="email-input"]', testInbox);
await page.click('button[data-testid="send-magic-link"]');
const captured = await emailService.captureMessage(testInbox, {
expectLink: true,
});
// First consumption succeeds
await page.goto(captured.magicLink!);
await expect(page).toHaveURL("/dashboard");
// Second consumption fails
const secondPage = await context.newPage();
await secondPage.goto(captured.magicLink!);
await expect(secondPage.locator('text=Invalid or expired token')).toBeVisible();
});
});
Email OTP Verification
test.describe("OTP Authentication", () => {
let emailService: EmailCaptureService;
let testInbox: string;
test.beforeEach(() => {
emailService = new EmailCaptureService();
testInbox = emailService.provisionInbox();
});
test("completes login with OTP", async ({ page }) => {
await page.goto("/login");
await page.fill('input[data-testid="email-input"]', testInbox);
await page.click('button[data-testid="request-otp"]');
await expect(page.locator('text=Code requested')).toBeVisible();
const captured = await emailService.captureMessage(testInbox, {
expectOtp: true,
});
expect(captured.otp).toMatch(/^\d{4,8}$/);
await page.fill('input[data-testid="otp-input"]', captured.otp!);
await page.click('button[data-testid="verify-otp"]');
await expect(page).toHaveURL("/dashboard");
});
});
Password Reset Flow
test.describe("Credential Recovery", () => {
let emailService: EmailCaptureService;
let testInbox: string;
test.beforeEach(() => {
emailService = new EmailCaptureService();
testInbox = emailService.provisionInbox();
});
test("resets password via email link", async ({ page }) => {
await page.goto("/forgot-password");
await page.fill('input[data-testid="email-input"]', testInbox);
await page.click('button[data-testid="submit-reset"]');
const captured = await emailService.captureMessage(testInbox, {
subjectKeyword: "Reset",
expectLink: true,
});
await page.goto(captured.magicLink!);
await expect(page.locator('text=Set New Password')).toBeVisible();
await page.fill('input[data-testid="new-password"]', "NewSecurePass1!");
await page.fill('input[data-testid="confirm-password"]', "NewSecurePass1!");
await page.click('button[data-testid="submit-new-password"]');
await expect(page.locator('text=Password updated')).toBeVisible();
});
});
Pitfall Guide
-
Static Inbox Collisions
- Issue: Using a hardcoded email address across tests causes race conditions when running in parallel.
- Fix: Always call
generateInbox() within the test setup or beforeEach hook to ensure unique identities per worker.
-
Regex Fragility
- Issue: Parsing email HTML with regular expressions to find links or codes breaks when email templates change.
- Fix: Rely on the edge-extraction properties (
magicLink, otp) provided by the email client. These are parsed structurally and are resilient to template variations.
-
Ignoring Token Expiration
- Issue: Tests may pass locally but fail in CI if the email delivery latency exceeds the token's TTL.
- Fix: Ensure
waitForLatest has a sufficient timeout (e.g., 15s) and monitor CI network latency. Better Auth tokens typically have generous windows, but delays can occur.
-
Missing Null Checks
- Issue: Accessing
email.magicLink or email.otp without verification can cause runtime errors if extraction fails.
- Fix: Always assert that the extracted value is defined before using it.
expect(captured.magicLink).toBeDefined();.
-
Mocking the Delivery Callback
- Issue: Mocking the
sendEmail function in Better Auth config prevents the email from being sent to the disposable inbox.
- Fix: The disposable inbox catches emails at the network level. Your
sendEmail implementation must actually transmit the message to the target address; do not mock the transport layer in integration tests.
-
Parallel Worker State Leakage
- Issue: Global variables in test files can cause state leakage between parallel workers.
- Fix: Instantiate the
EmailCaptureService and inbox variables within the test scope or use Playwright's fixture system to inject isolated instances.
-
Single-Use Token Reuse
- Issue: Failing to test that tokens are consumed after use.
- Fix: Include negative test cases that attempt to reuse a magic link or OTP to verify that Better Auth correctly invalidates the token.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Local Development | Disposable Edge Inboxes | Fast feedback loop; no SMTP setup required. | Free tier usage. |
| CI/CD Pipeline | Disposable Edge Inboxes | Isolated parallel runs; deterministic results. | Scales with test volume; no infrastructure cost. |
| Staging Validation | Real Email Provider | Validates end-to-end delivery to real inboxes. | Email provider costs; slower execution. |
| Unit Testing | Mocking | Fast isolation of logic; no network calls. | Zero cost; does not validate integration. |
Configuration Template
Playwright Configuration (playwright.config.ts)
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
GitHub Actions Workflow (.github/workflows/e2e.yml)
name: E2E Authentication Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
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 }}
EMAIL_PROVIDER_API_KEY: ${{ secrets.EMAIL_PROVIDER_API_KEY }}
Quick Start Guide
- Initialize Project: Run
npm install zerodrop-client @playwright/test and npx playwright install chromium.
- Create Helper: Add the
EmailCaptureService class to your test utilities directory.
- Write Test: Create a test file using the
EmailCaptureService to provision an inbox and capture messages for your auth flow.
- Run Locally: Execute
npx playwright test to verify the flow against your local dev server.
- Commit: Push changes to trigger CI validation with parallel execution.