DEV Community

Dishant Singh
Dishant Singh

Posted on

Automating OTP Verification in Playwright Without Writing a Single Regex

I used to have a function in my test suite called extractOtpFromEmail. It was 40 lines long, had six different regex patterns for different email formats, and still broke every time an upstream service changed their email template.

When I finally killed that function, I replaced it with one line:

const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
Enter fullscreen mode Exit fullscreen mode

This post is about how to get there — a clean, parallel-safe OTP automation setup for Playwright using the FreeCustom.Email API, which is the only disposable email service that ships a proper OTP extraction endpoint (and the only one with an official CLI).


The Core Problem

Playwright's async model and parallel worker architecture are genuinely excellent. But when your test needs to read an email, you hit a wall:

  • You can't share an inbox across workers — parallel tests receive each other's OTPs
  • Web-based temp mail sites require a second page context and fragile DOM scraping
  • Mocking email delivery means you are not testing the actual pipeline
  • Rolling your own IMAP reader is a project in itself

The answer is a fresh isolated inbox per test, with a purpose-built OTP extraction API that does the parsing for you.


Setup

Install the SDK and create a free account at freecustom.email:

npm install freecustom-email
Enter fullscreen mode Exit fullscreen mode

For the CLI (useful for local debugging):

curl -fsSL freecustom.email/install.sh | sh   # macOS / Linux
npm install -g fcemail@latest                  # All platforms
choco install fce                              # Windows
Enter fullscreen mode Exit fullscreen mode

All releases at github.com/DishIs/fce-cli/releases. Authenticate once:

fce login
# Browser opens → sign in → key saved to keychain automatically
Enter fullscreen mode Exit fullscreen mode

For CI, grab your API key from the dashboard and set FCE_API_KEY as a secret.


The Fixture — The Right Pattern for Playwright

The cleanest way to integrate FCE with Playwright is a custom fixture. Each test gets a fresh inbox and it is cleaned up automatically after:

// tests/fixtures/fce.ts
import { test as base } from '@playwright/test';
import { FreecustomEmailClient } from 'freecustom-email';

type FceFixture = {
  inbox: string;
  getOtp: (timeoutMs?: number) => Promise<string>;
};

const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });

export const test = base.extend<FceFixture>({
  inbox: async ({}, use) => {
    const addr = `pw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@ditmail.info`;
    await fce.inboxes.register(addr);
    await use(addr);
    await fce.inboxes.unregister(addr).catch(() => {});
  },

  getOtp: async ({ inbox }, use) => {
    await use((ms = 30_000) => fce.otp.waitFor(inbox, { timeout: ms }));
  },
});

export { expect } from '@playwright/test';
Enter fullscreen mode Exit fullscreen mode

Signup Test

// tests/signup.spec.ts
import { test, expect } from './fixtures/fce';

test('user signs up and verifies email', async ({ page, inbox, getOtp }) => {
  await page.goto('/signup');
  await page.fill('#email', inbox);
  await page.fill('#password', 'TestPass123!');
  await page.click('button[type="submit"]');

  // Wait for OTP screen
  await page.waitForSelector('#otp-input');

  // No polling loop. No regex. Just this:
  const otp = await getOtp();
  expect(otp).toMatch(/^\d+$/);

  await page.fill('#otp-input', otp);
  await page.click('button[type="submit"]');

  await page.waitForURL('**/dashboard');
  expect(page.url()).toContain('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Password Reset Test

Because getOtp() always returns the latest OTP in the inbox, multi-step flows work naturally:

test('user resets password via email', async ({ page, inbox, getOtp }) => {
  // First: create and verify the account
  await page.goto('/signup');
  await page.fill('#email', inbox);
  await page.fill('#password', 'Original1!');
  await page.click('button[type="submit"]');
  await page.waitForSelector('#otp-input');
  await page.fill('#otp-input', await getOtp());
  await page.click('button[type="submit"]');
  await page.waitForURL('**/dashboard');

  // Then: request a reset
  await page.goto('/forgot-password');
  await page.fill('#email', inbox);
  await page.click('button[type="submit"]');
  await page.waitForSelector('#reset-otp');

  // getOtp() picks up the new code, not the old one
  const resetOtp = await getOtp();
  await page.fill('#reset-otp', resetOtp);
  await page.fill('#new-password', 'NewPass456!');
  await page.click('button[type="submit"]');
  await page.waitForURL('**/login');
});
Enter fullscreen mode Exit fullscreen mode

Playwright Config — 4 Parallel Workers

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  workers: 4,
  retries: 1,
  timeout: 60_000,
  use: {
    baseURL: process.env.APP_URL,
    headless: true,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Four workers, four isolated inboxes, no interference. This is the biggest quality-of-life improvement over any shared inbox approach.


Python Version

If you are using Python Playwright:

pip install playwright pytest-playwright freecustom-email pytest-asyncio
playwright install chromium
Enter fullscreen mode Exit fullscreen mode
# conftest.py
import asyncio, os, time, pytest
from freecustom_email import FreeCustomEmail

fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])

@pytest.fixture
def inbox():
    addr = f"pw-{int(time.time())}@ditmail.info"
    asyncio.run(fce.inboxes.register(addr))
    yield addr
    asyncio.run(fce.inboxes.unregister(addr))

@pytest.fixture
def get_otp(inbox):
    return lambda timeout=30: asyncio.run(fce.otp.wait_for(inbox, timeout=timeout))
Enter fullscreen mode Exit fullscreen mode
# tests/test_signup.py
from playwright.sync_api import Page, expect
import re

def test_signup(page: Page, inbox, get_otp):
    page.goto("/signup")
    page.fill("#email", inbox)
    page.fill("#password", "TestPass123!")
    page.click("button[type='submit']")
    page.wait_for_selector("#otp-input")

    otp = get_otp()
    assert otp and otp.isdigit()

    page.fill("#otp-input", otp)
    page.click("button[type='submit']")
    page.wait_for_url("**/dashboard")
    expect(page).to_have_url(re.compile(r"/dashboard"))
Enter fullscreen mode Exit fullscreen mode

GitHub Actions

- name: Playwright E2E tests
  env:
    FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
    APP_URL: ${{ vars.STAGING_URL }}
  run: npx playwright test --workers=4

- name: Upload report
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/
Enter fullscreen mode Exit fullscreen mode

What About Apps That Block Disposable Domains?

Some signup forms validate the email domain and reject known temp mail addresses. If that is an issue, the Growth plan lets you register a custom domain and create inboxes like test-001@automation.yourcompany.com. Register the domain in the dashboard, verify DNS, and you are done.


What the OTP Command Looks Like (CLI debugging)

When I am debugging a failing test locally, I use the CLI directly to inspect what was received:

fce otp pw-1234567890@ditmail.info
Enter fullscreen mode Exit fullscreen mode
────────────────────────────────────────────────
  OTP
────────────────────────────────────────────────

  OTP   ·  212342
  From  ·  noreply@myapp.com
  Subj  ·  Your verification code
  Time  ·  20:19:54
Enter fullscreen mode Exit fullscreen mode

The CLI is separate from the SDK but uses the same underlying API. Useful for quick manual checks without spinning up a test runner.


Links


Are you using Playwright for E2E tests with email verification? I am curious what your current setup looks like — specifically whether anyone has a nice pattern for handling magic links in Playwright that they are happy with.

Top comments (0)