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:
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.
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
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.
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
- 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.
- Reading the code with a ClientFunction. That runs in the browser, exposes your key and hits CORS. Use
t.requestor Node code. - A fixed
t.wait(5000). Poll instead; codes usually arrive in well under a second. - Taking
sinceafter the click. Note the time before submitting, minus a few seconds. - Formatting differences. Query the number in the exact form the app sent. otpmock stores numbers as
+and digits.
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.
The free plan includes 100 messages a month. No card required.
Get a free API key