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

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

```ts
// 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

```ts
// 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:

```ts
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](https://playwright.dev/docs/clock) 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:

```ts
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:

```ts
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.

```ts
// 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](https://tempsmss.com/) 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:**

- [How to build a secure SMS OTP verification endpoint in Node.js](https://dev.to/tempsmss/how-to-build-a-secure-sms-otp-verification-endpoint-in-nodejs-3elo)
- [How to test SMS OTP verification without a real phone number](https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285)

