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:
-
Testing Tokens — bypass email verification entirely using
@clerk/testing/playwright - 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
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");
});
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
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");
});
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");
});
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:
- Running email flow tests separately — not on every commit, but on a dedicated schedule or before releases
- Using Clerk's Testing Tokens for authenticated page tests — no email sent, doesn't count toward the limit
- 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
// ...
});
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 }}
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
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)