Guides / TestCafe

How to test SMS OTP verification in TestCafe

Updated October 8, 2026
Short answer

Route your app's SMS to a mock inbox in the test environment. In the TestCafe test, generate a unique phone number, note the time, submit the phone form, then poll the inbox's HTTP API with t.request (or a plain fetch helper) and type the code with t.typeText. Test code runs in Node, so the API key never reaches the browser.

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"}

Option A: poll with t.request

TestCafe 3.x has a built-in HTTP client, t.request(url, options). It runs in the Node process, accepts headers, params and timeout, and resolves to an object with status, statusText, headers and body. A 404 does not throw, so you can read the status and retry. JSON responses come back parsed; the helper below handles a string body too, to be safe.

tests/otp.js
const API = process.env.OTPMOCK_URL || 'https://api.otpmock.com';

export function randomPhone(prefix = '+1555') {
  let rest = '';
  for (let i = 0; i < 7; i++) rest += Math.floor(Math.random() * 10);
  return prefix + rest;
}

export async function waitForCode(t, phone, since, timeout = 20000) {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const res = await t.request(`${API}/v1/inbox/${encodeURIComponent(phone)}/code`, {
      params: { since },
      headers: { authorization: `Bearer ${process.env.OTPMOCK_API_KEY}` },
    });
    if (res.status === 200) {
      const body = typeof res.body === 'string' ? JSON.parse(res.body) : res.body;
      return body.code;
    }
    if (res.status !== 404) throw new Error(`otpmock ${res.status}: ${JSON.stringify(res.body)}`);
    await t.wait(300);
  }
  throw new Error(`no code for ${phone}`);
}

randomPhone builds a 12-digit number starting with +1555. Pass a different prefix if your app rejects it.

The test

tests/signup.test.js
import { Selector } from 'testcafe';
import { randomPhone, waitForCode } from './otp.js';

fixture('Phone signup')
  .page(process.env.BASE_URL || 'https://staging.example.com/signup');

test('verifies the number with an SMS code', async (t) => {
  const phone = randomPhone(); // unique per test

  await t.typeText(Selector('input[name="phone"]'), phone);
  const since = Date.now() - 5000; // margin for clock skew
  await t.click(Selector('button[type="submit"]'));

  const code = await waitForCode(t, phone, since);

  await t
    .typeText(Selector('input[name="code"]'), code)
    .click(Selector('button[type="submit"]'))
    .expect(Selector('h1').innerText).eql('Welcome');
});

Run it with OTPMOCK_API_KEY=... npx testcafe chrome tests/, with the key supplied as a CI secret.

Option B: the otpmock helper

If you already use the dependency-free helper from /sdk/otpmock.mjs, call it directly. It uses the global fetch (Node 18+), which works in TestCafe test code because tests run in Node.

tests/signup-helper.test.js
import { Selector } from 'testcafe';
import { OtpMock } from './otpmock.mjs';

const otp = new OtpMock({
  baseUrl: process.env.OTPMOCK_URL || 'https://api.otpmock.com',
  apiKey: process.env.OTPMOCK_API_KEY,
});

fixture('Phone signup (helper)')
  .page(process.env.BASE_URL || 'https://staging.example.com/signup');

test('verifies the number with an SMS code', async (t) => {
  const phone = otp.randomPhone();
  await t.typeText(Selector('input[name="phone"]'), phone);
  const since = Date.now() - 5000;
  await t.click(Selector('button[type="submit"]'));

  const { code } = await otp.waitForCode(phone, { since, timeout: 20000 });
  await t.typeText(Selector('input[name="code"]'), code).click(Selector('button[type="submit"]'));
});

The difference: t.request keeps everything inside TestCafe's API, while the helper is portable across test tools.

Concurrency and multiple browsers

With -c 4 or several browsers in one run (testcafe chrome,firefox), the same test runs more than once at the same time. Since each run generates its own number, every instance reads only its own code. The since filter ignores any older code that might still be on a number.

Common pitfalls

FAQ

Which TestCafe version do I need?

t.request was added in TestCafe 1.20 and is part of 3.x. On older versions use the fetch-based helper, or any Node HTTP client.

Does this work with TypeScript tests?

Yes. TestCafe compiles TypeScript tests itself; import the .ts helper instead of the .mjs one.

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