Route your app's SMS to a mock inbox in the test environment. In the WebdriverIO spec, generate a unique phone number, note the time, submit the phone form, then poll the inbox's HTTP API from the test process and type the code with setValue. Because every test uses its own number, parallel instances never collide.
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"}WebdriverIO specs run in Node, so the request is made by the test process with the global fetch (Node 18+). The browser never talks to otpmock.
Step 3: wdio.conf.ts
A standard WebdriverIO v9 test runner config with the Mocha framework. Read the API key from an environment variable and keep it out of the repository. Raise the Mocha timeout a little so a test has room to wait for the code.
export const config: WebdriverIO.Config = {
runner: 'local',
specs: ['./test/specs/**/*.ts'],
maxInstances: 4,
capabilities: [{ browserName: 'chrome' }],
baseUrl: process.env.BASE_URL ?? 'https://staging.example.com',
framework: 'mocha',
reporters: ['spec'],
mochaOpts: { ui: 'bdd', timeout: 60_000 },
onPrepare() {
if (!process.env.OTPMOCK_API_KEY) {
throw new Error('OTPMOCK_API_KEY is not set');
}
},
};Worker processes inherit the launcher's environment, so process.env.OTPMOCK_API_KEY is available in every spec. Set it in CI as a secret.
Step 4: a small helper
Download the dependency-free helper from /sdk/otpmock.ts into your test folder, or write the polling loop yourself. Both are shown; pick one.
import { OtpMock } from './otpmock';
export const otp = new OtpMock({
baseUrl: process.env.OTPMOCK_URL ?? 'https://api.otpmock.com',
apiKey: process.env.OTPMOCK_API_KEY!,
});
// Without the helper: the same thing in a few lines.
export async function waitForCode(phone: string, since: number, timeout = 15_000): Promise<string> {
const url = `${process.env.OTPMOCK_URL ?? 'https://api.otpmock.com'}/v1/inbox/${encodeURIComponent(phone)}/code?since=${since}`;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(url, { headers: { authorization: `Bearer ${process.env.OTPMOCK_API_KEY}` } });
if (res.ok) return (await res.json()).code;
if (res.status !== 404) throw new Error(`otpmock ${res.status}: ${await res.text()}`);
await new Promise((r) => setTimeout(r, 300));
}
throw new Error(`no code for ${phone}`);
}Step 5: the spec
import { browser, $, expect } from '@wdio/globals';
import { otp } from '../support/otp';
describe('phone signup', () => {
it('verifies the number with an SMS code', async () => {
const phone = otp.randomPhone(); // e.g. +15550192834, unique per test
await browser.url('/signup');
await $('input[name="phone"]').setValue(phone);
const since = Date.now() - 5_000; // margin for clock skew between app server and runner
await $('button[type="submit"]').click();
const { code } = await otp.waitForCode(phone, { since, timeout: 20_000 });
await $('input[name="code"]').setValue(code);
await $('button[type="submit"]').click();
await expect($('h1')).toHaveText('Welcome');
});
});WebdriverIO v9 is async only, so every browser and element command is awaited. The wait happens in plain Node code between commands; no browser.pause is needed.
If you prefer WebdriverIO's own polling, wrap a single request in browser.waitUntil:
let code = '';
await browser.waitUntil(async () => {
const res = await fetch(`https://api.otpmock.com/v1/inbox/${encodeURIComponent(phone)}/code?since=${since}`, {
headers: { authorization: `Bearer ${process.env.OTPMOCK_API_KEY}` },
});
if (res.ok) code = (await res.json()).code;
return res.ok;
}, { timeout: 20_000, interval: 300, timeoutMsg: `no SMS code for ${phone}` });Parallel instances and unique numbers
maxInstances runs several spec files at once, each in its own worker, and you can multiply that across capabilities. A shared number would let one worker read another's code. Generating a fresh +1555 number inside each test avoids that without any coordination between workers. The since filter adds a second guard: a code from an earlier attempt on the same number is ignored.
If your app rejects +1555 numbers, pass a different prefix, for example otp.randomPhone('+4479'), as long as your app sends to that exact string.
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.
- Mocha timeout too short. The generated config sets
mochaOpts.timeoutto 60 seconds, but some projects lower it. Keep it above the code wait plus page loads. - Taking
sinceafter the click. Note the time before submitting, minus a few seconds, or a fast SMS can arrive "before" the timestamp. - Formatting differences. If your UI adds spaces or strips the
+, query the number in the form the app sent. otpmock stores numbers as+and digits. - Hardcoding the key in wdio.conf. Use an environment variable or CI secret.
FAQ
Does this work with WebdriverIO on Appium for mobile apps?
Yes. The code is read from the test process over HTTP, so it does not matter whether the session drives a browser or a native app. Only the element selectors change.
Can I use Jasmine or Cucumber instead of Mocha?
Yes. The helper is plain async code. Call it from a Jasmine spec or a Cucumber step definition the same way.
How many messages does a run use?
One per verification your tests trigger. The free plan includes 100 messages a month, and messages expire after 10 minutes.
The free plan includes 100 messages a month. No card required.
Get a free API key