Skip to main content

Testing SMS OTP with Playwright: Stubs, Fake Timers and Rate Limits

Updated 6 min readMarkdown

Testing SMS OTP with Playwright works best when your app never talks to a real SMS provider during the run. Swap the provider for an outbox your tests can read, fetch the code with Playwright's request fixture, drive the resend countdown with page.clock, and assert rate limits at both the API and the UI level. Below is each piece in TypeScript, plus the part teams forget: keeping codes and credentials out of traces and CI logs.

Where the stub belongs

Playwright drives the browser, but your backend sends the SMS. page.route() only intercepts requests the browser makes, so it can't see the server-to-provider call. The stub goes on the server:

  • Put the provider behind a small SmsSender interface.
  • In the test environment, bind it to an in-memory outbox that records { to, body }.
  • Add a test-only endpoint, e.g. GET /__test/sms?to=... (and DELETE to reset that number's outbox and rate-limit counters). Mount it only when NODE_ENV === 'test' and require a token.

Skip the fixed 000000 code: reading the real generated code exercises your generation and expiry logic.

Helpers: a safe number and a polled code

// tests/helpers/otp.ts
import { expect, type APIRequestContext, type TestInfo } from '@playwright/test';

const headers = { 'x-test-token': process.env.TEST_TOKEN ?? '' };

// 555-0100..0199 is reserved for fiction, so a stub mistake never texts a real person.
export function testPhone(testInfo: TestInfo): string {
  return `+1202555${String(100 + testInfo.parallelIndex).padStart(4, '0')}`;
}

export async function resetPhone(request: APIRequestContext, to: string): Promise<void> {
  await request.delete('/__test/sms', { params: { to }, headers });
}

export async function latestCode(request: APIRequestContext, to: string): Promise<string> {
  let code = '';
  await expect.poll(async () => {
    const res = await request.get('/__test/sms', { params: { to }, headers });
    if (!res.ok()) return '';
    const { messages } = (await res.json()) as { messages: { body: string }[] };
    code = messages.at(-1)?.body.match(/\b(\d{6})\b/)?.[1] ?? '';
    return code;
  }, { timeout: 10_000 }).not.toBe('');
  return code;
}

One number per parallel worker plus a reset in beforeEach keeps tests from tripping each other's limits.

The happy path

// tests/otp.spec.ts
import { test, expect } from '@playwright/test';
import { latestCode, resetPhone, testPhone } from './helpers/otp';

test.beforeEach(async ({ request }, testInfo) => {
  await resetPhone(request, testPhone(testInfo));
});

test('signs up with the code from the outbox', async ({ page, request }, testInfo) => {
  const phone = testPhone(testInfo);
  await page.goto('/signup');
  await page.fill('#phone', phone);
  await page.getByRole('button', { name: 'Send code' }).click();
  await expect(page.getByText('We sent a code')).toBeVisible();

  await page.fill('#otp', await latestCode(request, phone));
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByText('Your account is ready')).toBeVisible();
});

Fake timers for the resend countdown

Don't wait 30 real seconds for a "Resend" button. Install the fake clock before goto, then advance it:

test('resend unlocks after the cooldown', async ({ page }, testInfo) => {
  await page.clock.install();
  await page.goto('/signup');
  await page.fill('#phone', testPhone(testInfo));
  await page.getByRole('button', { name: 'Send code' }).click();

  const resend = page.getByRole('button', { name: /resend/i });
  await expect(resend).toBeDisabled();
  await page.clock.runFor(30_000);
  await expect(resend).toBeEnabled();
});

runFor fires every timer due in that window, which suits a countdown built on repeated setTimeout calls. The Clock API docs cover fastForward, pauseAt and the rest.

One limit: page.clock only changes time in the browser. Server-side code expiry follows the server's clock, so inject a clock into your backend or add a test-only way to age a code.

Asserting rate limits

Test the policy at the API level, where it's enforced:

test('code requests are rate-limited per number', async ({ request }, testInfo) => {
  const phone = testPhone(testInfo);
  const statuses: number[] = [];
  for (let i = 0; i < 4; i++) {
    const res = await request.post('/api/otp/send', { data: { phone } });
    statuses.push(res.status());
  }
  expect(statuses).toEqual([200, 200, 200, 429]); // our policy: 3 per window
});

Then test the UI's reaction. Here page.route() is the right tool, because the browser makes this request:

test('explains the limit when the API returns 429', async ({ page }) => {
  await page.route('**/api/otp/send', (route) =>
    route.fulfill({ status: 429, json: { error: 'too_many_requests' } }),
  );
  await page.goto('/signup');
  await page.fill('#phone', '+12025550123');
  await page.getByRole('button', { name: 'Send code' }).click();
  await expect(page.getByText(/too many attempts/i)).toBeVisible();
});

Keep codes and secrets out of logs

  • No real provider key in CI. With the outbox bound, there's nothing to leak.
  • Treat traces as sensitive. trace: 'retain-on-failure' is a good default, but a trace holds DOM snapshots, network bodies and the action log, including values passed to fill. Keep artifact retention short and never publish them from a public repository.
  • Don't log codes or tokens. Reporters capture stdout.
  • Mask screenshots you take yourself: await page.screenshot({ path: 'otp.png', mask: [page.locator('#otp')] }).
  • Prove the test endpoint is absent in production with a smoke test that expects /__test/sms to return 404.
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'retain-on-failure' },
  webServer: { command: 'npm run start:test', url: 'http://localhost:3000' }, // app with the outbox sender
});

What stubs can't tell you

All of this proves your logic, not that a carrier delivers your message: sender rules, encoding and filtering differ by country. Before launch, do a short manual pass on staging with real numbers. Free public numbers on Temp SMS show incoming texts on a web page with no account, which makes that check quick. Anyone can read those messages, so use them only for disposable test accounts.

FAQ

Can Playwright intercept the SMS my backend sends? No. page.route() only sees browser requests. Stub the provider on the server and read the code through a test-only endpoint.

Does page.clock make my OTP expire sooner? Only in the browser. Server-side expiry needs a controllable server clock or a test hook.

Should I use a fixed test code like 000000? It skips your real generation and verification path, and it's risky if the flag ever reaches production.


More on SMS verification for developers:

About the author

  • Temp SMS (https://tempsmss.com) lists free temporary phone numbers in the US, Canada, UK and Europe. Here we write practical guides on SMS verification, OTP testing for developers and keeping your phone number private.

No comments yet. Be the first to comment.