DEV Community

Cover image for OTP Verification in Playwright Without Regex
zerodrop
zerodrop

Posted on

OTP Verification in Playwright Without Regex

Every developer who has written a Playwright test for OTP verification has written this line:

const otp = email.body.match(/\b\d{6}\b/)?.[0];
Enter fullscreen mode Exit fullscreen mode

It works. Until it doesn't.

The email body changes format. The OTP appears inside an HTML table. The sending service wraps it in a <span>. Your regex matches a phone number instead of the code. The test fails intermittently and you spend an hour debugging something that has nothing to do with the feature you're testing.


The regex problem

OTP extraction via regex is brittle by nature. You're pattern-matching against a string that your email sending service controls — not you. Any time the template changes, your tests break.

Here's what a typical OTP test looks like today:

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

const mail = new ZeroDrop();

test('user can verify OTP', async ({ page }) => {
  const inbox = mail.generateInbox();

  // 1. Trigger OTP send
  await page.goto('/login');
  await page.fill('[data-testid="email"]', inbox);
  await page.click('[data-testid="submit"]');

  // 2. Wait for email
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // 3. Extract OTP — the fragile part
  const otp = email.body.match(/\b\d{6}\b/)?.[0];
  if (!otp) throw new Error('OTP not found in email body');

  // 4. Enter OTP
  await page.fill('[data-testid="otp"]', otp);
  await page.click('[data-testid="verify"]');

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

The test works — but line 14 is carrying all the risk. Change the email template and the test breaks. Add a phone number to the footer and the regex matches the wrong number. Send a 4-digit OTP instead of 6 and you need to update the pattern.


OTP extraction at the edge

ZeroDrop extracts OTPs before they reach your test. The Cloudflare Worker that catches incoming emails runs a pattern match on the plain-text body and stores the result alongside the raw email in Redis.

When your test calls waitForLatest, the extracted OTP is already there as a first-class field:

const email = await mail.waitForLatest(inbox, { timeout: 15000 });

email.otp        // "123456" — already extracted
email.magicLink  // "https://..." — verification links too
email.body       // raw body still available if you need it
Enter fullscreen mode Exit fullscreen mode

Both fields are null if not detected. No regex needed in your test code.


The same test, without regex

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

const mail = new ZeroDrop();

test('user can verify OTP', async ({ page }) => {
  const inbox = mail.generateInbox();

  // 1. Trigger OTP send
  await page.goto('/login');
  await page.fill('[data-testid="email"]', inbox);
  await page.click('[data-testid="submit"]');

  // 2. Wait for email — OTP already extracted
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });
  expect(email.otp).not.toBeNull();

  // 3. Enter OTP
  await page.fill('[data-testid="otp"]', email.otp!);
  await page.click('[data-testid="verify"]');

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

The fragile regex line is gone. The test asserts that the OTP exists and uses it directly. If the email template changes, the extraction logic at the edge updates independently of your test code.


What gets extracted

The edge worker extracts:

OTP codes — standalone 4-8 digit numeric codes. Detected when they appear near common labels like code, otp, pin, verification, or as isolated numbers on their own line.

Magic links — verification or reset URLs containing path segments like verify, confirm, reset, token, activate, or auth. The first matching URL is extracted.

Both are stored with the email payload in Redis and expire after 30 minutes along with the rest of the inbox.


In GitHub Actions

The same fields are available when using the GitHub Action:

- name: Generate test inbox
  id: inbox
  uses: zerodrop-dev/create-inbox@v1

- name: Run OTP tests
  run: npx playwright test
  env:
    TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
Enter fullscreen mode Exit fullscreen mode
// In your test
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
const email = await mail.waitForLatest(inbox, { timeout: 15000 });

// OTP ready to use
await page.fill('[data-testid="otp"]', email.otp!);
Enter fullscreen mode Exit fullscreen mode

Parallel OTP tests

Because every inbox is isolated and OTPs are extracted per-inbox, parallel test runs work without coordination. 10 workers testing OTP flows simultaneously get 10 isolated inboxes with 10 independently extracted codes.

// Safe to run in parallel — each inbox is isolated
const inboxes = Array.from({ length: 10 }, () => mail.generateInbox());
Enter fullscreen mode Exit fullscreen mode

No race conditions, no shared state, no cleanup between runs.


Install

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

No signup. No Docker. No SMTP config. Free tier includes OTP extraction, magic link detection, and SSE-based sub-second email delivery in CI.

zerodrop.dev

Top comments (0)