DEV Community

Cover image for How to Test Clerk Email Verification in Playwright Without Hitting the 100 Email Limit
zerodrop
zerodrop

Posted on

How to Test Clerk Email Verification in Playwright Without Hitting the 100 Email Limit

Clerk's development mode limits you to 100 emails per calendar month. Once you hit that limit, OTP emails stop being delivered and your tests break.

The official workaround is to use Clerk's reserved test email addresses — fixed emails with hardcoded OTP codes like +clerk_test suffixes. They work, but they mean you're not testing the real email flow. You're testing a bypass.

If you want to test the actual email your users receive — real OTP codes, real magic links, real delivery — you need a different approach.

The problem with Clerk's test mode

Clerk's testing documentation recommends two approaches:

  1. Testing Tokens — bypass email verification entirely using @clerk/testing/playwright
  2. Reserved test emails — use fake addresses with hardcoded OTP codes

Both approaches skip the email delivery. Your Playwright test never sees a real email, never reads a real OTP, never clicks a real magic link.

That means:

  • If your email template breaks, your tests won't catch it
  • If Clerk's email delivery goes down, your tests still pass
  • If the OTP extraction logic changes, you won't know

The only way to catch email delivery problems is to test real email delivery.

The setup: Clerk + ZeroDrop

ZeroDrop gives you disposable email inboxes caught at Cloudflare's edge. Send a real Clerk email to a ZeroDrop inbox. Read email.otp or email.magicLink — auto-extracted, no regex.

No fake email addresses. No hardcoded codes. Real emails, real delivery, every test.

npm install zerodrop-client @clerk/nextjs
Enter fullscreen mode Exit fullscreen mode

Testing Clerk email verification on signup

Clerk's signup flow sends a verification code to the user's email. Here's how to test it end to end:

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

const mail = new ZeroDrop();

test("Clerk email verification on signup", async ({ page }) => {
  // Generate a unique inbox — no network request
  const inbox = mail.generateInbox();

  // Sign up with the ZeroDrop inbox
  await page.goto("/sign-up");
  await page.fill('input[name="emailAddress"]', inbox);
  await page.fill('input[name="password"]', "TestPassword123!");
  await page.click('button[type="submit"]');

  // Clerk sends the verification email
  // ZeroDrop catches it at Cloudflare's edge in <1s
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { hasOtp: true },
  });

  // email.otp is auto-extracted — no regex needed
  expect(email.otp).toBeTruthy();
  expect(email.otp).toMatch(/^\d{6}$/);

  // Clerk renders individual digit inputs
  // Fill the first input — Clerk auto-advances through the rest
  await page.locator('input[name="code-0"]').waitFor();
  await page.fill('input[name="code-0"]', email.otp!);

  // Assert the user is verified and signed in
  await expect(page).toHaveURL("/dashboard");
});
Enter fullscreen mode Exit fullscreen mode

The Clerk OTP input pattern

Clerk renders OTP inputs as individual digit fields — code-0 through code-5. Filling code-0 with all 6 digits triggers auto-advance through the rest:

// Clerk's digit input pattern
await page.locator('input[name="code-0"]').waitFor();
await page.fill('input[name="code-0"]', email.otp!);
// Clerk auto-advances through code-1, code-2, code-3, code-4, code-5
Enter fullscreen mode Exit fullscreen mode

No need to fill each input individually.

Testing Clerk magic link sign-in

If you're using Clerk's passwordless magic link flow:

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

  await page.goto("/sign-in");

  // Switch to email link strategy
  await page.click('button:has-text("Email link")');
  await page.fill('input[name="emailAddress"]', inbox);
  await page.click('button[type="submit"]');

  // Wait for Clerk's magic link email
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: { hasMagicLink: true },
  });

  // email.magicLink auto-extracted
  expect(email.magicLink).toBeTruthy();

  // Navigate to the magic link — Clerk signs the user in
  await page.goto(email.magicLink!);

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

Testing Clerk password reset

test("Clerk password reset flow", async ({ page }) => {
  const inbox = mail.generateInbox();

  // First create the user
  // (or use an existing test account)

  // Request password reset
  await page.goto("/sign-in");
  await page.click('button:has-text("Forgot password")');
  await page.fill('input[name="emailAddress"]', inbox);
  await page.click('button[type="submit"]');

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

  expect(email.otp).toBeTruthy();

  // Enter the reset code
  await page.locator('input[name="code-0"]').waitFor();
  await page.fill('input[name="code-0"]', email.otp!);

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

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

Why not use Clerk's Testing Tokens?

Clerk's @clerk/testing/playwright package and Testing Tokens are great for testing authenticated flows — pages that require a signed-in user. They bypass the entire auth flow so you can start tests with a pre-authenticated state.

But they don't test the email flow itself. If you want to verify that:

  • The verification email is actually sent
  • The OTP code is correct and extractable
  • The magic link works end to end
  • The email template renders correctly

You need to test with real emails. ZeroDrop and Clerk's Testing Tokens are complementary — use Testing Tokens for authenticated page tests, use ZeroDrop for email flow tests.

Avoiding the 100 email limit

Clerk's 100 email/month limit applies to your development instance. ZeroDrop helps you stay under it by:

  1. Running email flow tests separately — not on every commit, but on a dedicated schedule or before releases
  2. Using Clerk's Testing Tokens for authenticated page tests — no email sent, doesn't count toward the limit
  3. Switching to a paid Clerk plan — removes the limit entirely for production testing

For most teams, the pattern is: Testing Tokens for 95% of tests, ZeroDrop for the email flow tests that actually need to verify delivery.

Parallel test runs

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

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

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:
      NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }}
      CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
Enter fullscreen mode Exit fullscreen mode

ZeroDrop needs no environment variables. No API key, no signup.

What gets auto-extracted

ZeroDrop detects OTP codes near labels like code, verification, otp, pin in the email body. For Clerk emails, this includes:

  • Sign-up verification codes
  • Sign-in email codes
  • Password reset codes

email.otp is a plain string — ready to paste into page.fill().

const email = await mail.waitForLatest(inbox);
email.otp // "847291" — 6-digit Clerk verification code
Enter fullscreen mode Exit fullscreen mode

Conclusion

Clerk's test mode is useful, but it trades real email testing for convenience. Testing Tokens skip email delivery entirely. Reserved test emails use hardcoded codes that never change.

If your Clerk app sends verification emails to real users, test with real emails. ZeroDrop catches them, extracts the OTP, and your test reads email.otp — no regex, no fake addresses, no 100-email-limit anxiety.

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

zerodrop.dev · npm · docs

Top comments (0)