Most guides to OTP testing in Playwright include a function that looks something like this:
function extractOtp(emailBody: string): string {
const patterns = [
/\b(\d{6})\b/,
/code[:\s]+(\d{4,8})/i,
/verification[:\s]+(\d{4,8})/i,
/OTP[:\s]+(\d{4,8})/i,
];
for (const pattern of patterns) {
const match = emailBody.match(pattern);
if (match) return match[1];
}
throw new Error('OTP not found in email body');
}
This function is fragile. It breaks when the email template changes. It returns false positives when the email body contains order IDs or timestamps. It requires you to maintain regex patterns for every email provider your app might use.
There is a better way.
The Problem with Regex OTP Extraction
When your app sends a verification email, the OTP is buried somewhere in the HTML body. To extract it you need to:
- Fetch the raw email body
- Parse HTML or plain text
- Apply regex patterns that match your specific email format
- Handle edge cases — 4-digit vs 6-digit codes, codes in tables, codes in buttons
Every time your email provider changes their template, your regex breaks. Every time you add a new auth provider, you write new patterns. It is maintenance overhead that compounds forever.
The right place to extract the OTP is at the infrastructure layer — before the email even reaches your test suite.
How ZeroDrop Extracts OTPs at the Edge
ZeroDrop catches emails at Cloudflare's edge before storing them. When an email arrives, the worker runs OTP detection on the body and stores the result as a structured field alongside the raw email.
By the time your test calls waitForLatest(), the OTP is already extracted and sitting in email.otp. No regex. No HTML parsing. No maintenance.
const email = await mail.waitForLatest(inbox);
email.otp // "847291" — already extracted
Setup
npm install zerodrop-client
No API key. No signup. No environment variables.
Basic OTP Test
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
const mail = new ZeroDrop();
test('OTP email verification', async ({ page }) => {
const inbox = mail.generateInbox();
// Trigger the OTP email
await page.goto('/signup');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// Wait for the email — OTP auto-extracted, no regex needed
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
expect(email.otp).toBeTruthy();
expect(email.otp).toMatch(/^\d{4,8}$/);
// Enter the OTP
await page.fill('[name="otp"]', email.otp!);
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
That is the complete test. No regex. No helper function. No HTML parsing.
Testing Digit-by-Digit OTP Inputs
Some auth providers — Clerk, Auth0, Supabase — render OTP inputs as individual digit fields. email.otp is a plain string, so splitting it is trivial:
test('digit-by-digit OTP input', async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto('/signup');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
// Split OTP into individual digits
const digits = email.otp!.split('');
for (let i = 0; i < digits.length; i++) {
await page.fill(`[name="otp-${i}"]`, digits[i]);
}
await expect(page).toHaveURL('/dashboard');
});
Clerk-Specific Pattern
Clerk renders a single input that accepts all digits at once but advances focus automatically:
test('Clerk OTP verification', async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto('/sign-up');
await page.fill('input[name="identifier"]', inbox);
await page.click('button:has-text("Continue")');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
// Clerk: fill the first digit input, it auto-advances
await page.locator('input[name="code-0"]').waitFor();
await page.fill('input[name="code-0"]', email.otp!);
await expect(page).toHaveURL('/dashboard');
});
Filtering by Sender When Multiple Emails Land
If your signup flow sends multiple emails — welcome email, verification email — use filter to target the right one:
test('OTP from specific sender', async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto('/signup');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// Only catch the verification email, not the welcome email
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: {
from: 'noreply@yourapp.com',
hasOtp: true,
}
});
await page.fill('[name="otp"]', email.otp!);
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
Password Reset OTP Flow
test('password reset OTP', 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 });
// Navigate to reset page with OTP
await page.fill('[name="reset-code"]', email.otp!);
await page.fill('[name="new-password"]', 'NewPassword123!');
await page.click('[type="submit"]');
await expect(page.locator('.success-message')).toBeVisible();
});
Parallel Test Runs — No Collisions
generateInbox() runs locally with no network request. Each parallel worker gets a unique inbox automatically:
test.describe.configure({ mode: 'parallel' });
test('user A OTP', async ({ page }) => {
const inbox = mail.generateInbox(); // unique per worker
// ...
});
test('user B OTP', async ({ page }) => {
const inbox = mail.generateInbox(); // different inbox, zero collision
// ...
});
50 parallel workers. 50 isolated inboxes. Zero race conditions. No shared inbox collisions.
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
- run: npx playwright test
No Docker. No SMTP service. No API keys in CI secrets. ZeroDrop works out of the box with no configuration.
What Gets Auto-Extracted
ZeroDrop detects OTP codes near common labels in the email body:
- Labels:
code,otp,pin,verification,passcode,token - Format: 4-8 digit numeric sequences
- Scope: plain-text body (more reliable than HTML parsing)
email.otp // "847291" — 4-8 digit code, or null if not detected
email.magicLink // "https://app.com/verify?token=..." — or null
email.subject // "Your verification code"
email.body // Full plain-text body — available if you need it
email.otp is null if no OTP pattern is detected. Always check before using:
expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
Conclusion
OTP extraction belongs at the infrastructure layer, not in your test suite. When the extraction happens at the edge — before the email is stored — your tests never need to touch the raw email body.
email.otp is just there. No regex. No helper functions. No maintenance.
Free to use. No signup required. Works in CI out of the box.
→ zerodrop.dev · npm · GitHub Action
Top comments (0)