Route your app's SMS to a mock inbox in the test environment. In the Puppeteer test, generate a unique phone number, note the time, submit the phone form, then poll the inbox's HTTP API with fetch from Node and type the code with page.type. The same code runs under Jest or the built-in node:test runner.
Step 1: route SMS to the mock
Your application, not the test, sends the SMS. In the test or staging environment, point your provider's SDK at otpmock with one option. Each provider's option is on its provider page; otpmock emulates fifteen, including Twilio, Vonage, AWS SNS, Infobip, Sinch and Plivo.
Step 2: the inbox API your test calls
One request returns the newest code sent to a number after a timestamp, or 404 if none has arrived yet:
GET https://api.otpmock.com/v1/inbox/{phone}/code?since={unix_ms}
Authorization: Bearer $OTPMOCK_API_KEY
200 {"code":"482913","messageSid":"SM…","body":"Your code is 482913","receivedAt":1791417652342}
404 {"error":"no_code_yet"}Puppeteer scripts run in Node, so call the API from the test itself, not from inside page.evaluate. That keeps the API key out of the page and avoids CORS entirely.
Step 3: a small helper
Download the dependency-free helper from /sdk/otpmock.mjs (or .ts) next to your tests. It uses the global fetch, available in Node 18 and later, which Puppeteer v23 already requires.
import { OtpMock } from './otpmock.mjs';
export const otp = new OtpMock({
baseUrl: process.env.OTPMOCK_URL ?? 'https://api.otpmock.com',
apiKey: process.env.OTPMOCK_API_KEY,
});It exposes randomPhone(), waitForCode(phone, { since, timeout }), clear(phone) and send(to, body). If you would rather not copy a file, the polling loop is about ten lines: GET the URL above, return code on 200, retry every 300 ms on 404, throw on anything else.
With Jest
import puppeteer from 'puppeteer';
import { otp } from './otp.mjs';
const BASE_URL = process.env.BASE_URL ?? 'https://staging.example.com';
let browser, page;
beforeAll(async () => { browser = await puppeteer.launch(); });
afterAll(async () => { await browser.close(); });
beforeEach(async () => { page = await browser.newPage(); });
afterEach(async () => { await page.close(); });
test('phone signup with SMS code', async () => {
const phone = otp.randomPhone(); // unique per test, e.g. +15550192834
await page.goto(`${BASE_URL}/signup`);
await page.locator('input[name="phone"]').fill(phone);
const since = Date.now() - 5_000; // margin for clock skew
await Promise.all([
page.waitForNavigation(),
page.locator('button[type="submit"]').click(),
]);
const { code } = await otp.waitForCode(phone, { since, timeout: 20_000 });
await page.locator('input[name="code"]').fill(code);
await page.locator('button[type="submit"]').click();
await page.locator('text/Welcome').wait();
}, 60_000);Jest's default test timeout is 5 seconds, which is too short for a page flow plus an SMS wait; the third argument to test raises it. Native ESM in Jest still needs node --experimental-vm-modules; if your project uses Babel or CommonJS, switch the imports to require. If your form updates in place instead of navigating, drop waitForNavigation and let the locator for the code field wait for it.
With node:test
No extra dependencies: the built-in runner handles ESM and async tests directly.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { otp } from './otp.mjs';
const BASE_URL = process.env.BASE_URL ?? 'https://staging.example.com';
let browser;
before(async () => { browser = await puppeteer.launch(); });
after(async () => { await browser.close(); });
test('phone signup with SMS code', { timeout: 60_000 }, async () => {
const page = await browser.newPage();
const phone = otp.randomPhone();
await page.goto(`${BASE_URL}/signup`);
await page.locator('input[name="phone"]').fill(phone);
const since = Date.now() - 5_000;
await page.locator('button[type="submit"]').click();
const { code } = await otp.waitForCode(phone, { since, timeout: 20_000 });
await page.locator('input[name="code"]').fill(code);
await page.locator('button[type="submit"]').click();
const heading = await page.locator('h1').map((el) => el.textContent).wait();
assert.equal(heading, 'Welcome');
await page.close();
});Run it with node --test test/. Pass the key as an environment variable, for example from a CI secret, never in the file.
Parallel runs
Jest runs test files in parallel workers, and node --test runs files in separate processes. Because each test calls randomPhone(), no two tests share an inbox, and the since filter ignores any older code on the same number. Nothing has to be coordinated between workers. If your app rejects +1555 numbers, pass another prefix such as otp.randomPhone('+4479').
Common pitfalls
- Setting the variable only on the test runner. The app server sends the SMS, so the otpmock option must be set where the app runs.
- Calling the API from
page.evaluate. That exposes your key to the page and is subject to CORS. Call it from Node. - Waiting with a fixed timeout. Poll the inbox instead of sleeping; codes usually arrive in well under a second.
- Taking
sinceafter the click. Note the time before submitting, minus a few seconds for clock skew. - Formatting differences. Query the number in the exact form the app sent. otpmock stores numbers as
+and digits.
FAQ
Does this work with puppeteer-core and a remote browser?
Yes. Only the test process talks to otpmock, so it makes no difference where Chrome runs or whether you use puppeteer.connect.
Can I test the WebOTP API autofill?
No. WebOTP needs a real SMS on a real Android device. Use otpmock to test your verification flow and server logic, and type the code as a user would.
Do I need to change application code?
Only where the SMS client is created, behind an environment variable that is set in test environments.
The free plan includes 100 messages a month. No card required.
Get a free API key