Guides / Puppeteer

How to test SMS OTP verification in Puppeteer

Updated October 8, 2026
Short answer

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:

HTTP
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.

test/otp.mjs
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

test/signup.test.mjs
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.

test/signup.test.mjs
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

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.

Try it on your own suite

The free plan includes 100 messages a month. No card required.

Get a free API key

Related guides