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
SmsSenderinterface. - 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=...(andDELETEto reset that number's outbox and rate-limit counters). Mount it only whenNODE_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 tofill. 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/smsto 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.
Comments
Join the discussionNo comments yet. Be the first to comment.