In the test environment, send your app's SMS to a mock inbox instead of a real provider. Give each test its own phone number, record a timestamp just before you trigger the SMS, then poll the inbox over HTTP for a code received after that timestamp and type it in. No real texts, no fixed codes in your app.
Why OTP steps break Playwright tests
- Real SMS is slow and uneven. Delivery can take anything from a second to a minute, so a fixed timeout either wastes time or fails at random.
- Reading the text back is hard. A real message lands on a real phone. Automating that needs a physical device or a paid receiving-number service.
- Parallel workers collide. If every test uses the same test number, two workers can read each other's codes.
- Stale codes. A code left over from the previous run looks valid to a naive "get latest code" helper.
- Backdoors leak. The common workaround, a hard-coded code like
000000for a test number, lives in application code and can reach production.
The pattern
- In test and staging only, route outgoing SMS to a mock inbox. Production keeps using the real provider.
- Create a fresh, fake phone number for each test.
- Take a
sincetimestamp, then trigger the SMS from the UI. - Poll the inbox for a code sent to that number after
since, and type it in.
The rest of this guide uses otpmock as the mock inbox. The pattern works with any inbox that you can query over HTTP.
Step 1: route SMS to the mock in the test environment
Point your SMS provider's SDK at otpmock when OTPMOCK_URL is set. otpmock emulates Twilio, Vonage, Telnyx, AWS SNS, AWS End User Messaging, Infobip, Sinch, Bird, Plivo, Telesign, Bandwidth, Netgsm, İleti Merkezi, Verimor and Mutlucell; each needs one option, shown on its provider page. Your send and verify calls stay unchanged. Here is the Twilio version:
import twilio from "twilio";
import { OtpMockHttpClient } from "./twilio-node-client.mjs";
export const sms = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN, {
httpClient: process.env.OTPMOCK_URL ? new OtpMockHttpClient(process.env.OTPMOCK_URL) : undefined,
});For any other provider, add a test-only branch that posts the message to POST /v1/messages/send instead. See the API reference.
Step 2: a fixture that gives each test its own number
Download the dependency-free helper with curl -O https://otpmock.com/sdk/otpmock.ts, then extend Playwright's test:
import { test as base } from "@playwright/test";
import { OtpMock } from "./otpmock";
export const test = base.extend<{ otp: OtpMock; phone: string }>({
otp: async ({}, use) => {
await use(new OtpMock({ baseUrl: process.env.OTPMOCK_URL!, apiKey: process.env.OTPMOCK_API_KEY! }));
},
phone: async ({ otp }, use) => {
const phone = otp.randomPhone(); // e.g. +15550173829, never shared between tests
await use(phone);
await otp.clear(phone);
},
});
export { expect } from "@playwright/test";Step 3: the test
import { test, expect } from "./fixtures";
test("sign up with a phone number", async ({ page, otp, phone }) => {
await page.goto("/signup");
await page.getByLabel("Phone").fill(phone);
const since = Date.now() - 5_000; // small margin for clock skew
await page.getByRole("button", { name: "Send code" }).click();
const { code } = await otp.waitForCode(phone, { since, timeout: 15_000 });
await page.getByLabel("Verification code").fill(code);
await expect(page.getByText("Welcome")).toBeVisible();
});waitForCode polls every 300 ms and returns as soon as the code arrives, which is usually well under a second after your app's send call returns.
Running in parallel
Because each test gets its own number, you can raise workers without changing anything else. The clear call in the fixture's teardown is optional, since messages expire after 10 minutes anyway, but it keeps the live inbox tidy when people are watching it.
Running in CI
Set the two variables as secrets wherever both the app under test and the test runner can read them. In GitHub Actions:
env:
OTPMOCK_URL: https://api.otpmock.com
OTPMOCK_API_KEY: ${{ secrets.OTPMOCK_API_KEY }}
# plus your provider credential set to the otpmock key, e.g. TWILIO_AUTH_TOKENCommon pitfalls
- The variable is set on the test runner but not on the app server. The app is the one sending SMS, so
OTPMOCK_URLmust be set where it runs, for example your staging deployment. - The number format differs. otpmock normalizes numbers to
+and digits, but if your UI adds a country code, read the code for the number the app actually sent to. - No
since. Without it, a retried test can pick up the code from its previous attempt.
FAQ
Can I test the real SMS delivery path this way?
No. A mock inbox tests your application's behaviour, not the carrier network. If you need to verify that real texts arrive on real phones, run a small separate check with a receiving-number service, and keep your main suite on the mock.
Does this work with Verify APIs?
Yes. otpmock emulates Twilio Verify v2 and Vonage Verify v2: it generates the code, puts it in the inbox, and approves the verification check when your app submits the right code.
Do I need to change application code?
Only where the SMS client is created, and only behind an environment variable that is set in test environments. Production behaviour is unchanged.
The free plan includes 100 messages a month. Using an AI coding assistant? Point it at otpmock.com/llms-full.txt.
Get a free API key