Guides / Playwright

How to test SMS OTP verification in Playwright

Signup and login flows that send a one-time code by text message are the classic place where Playwright suites turn flaky. Here's a pattern that keeps them fast and deterministic, including when tests run in parallel.

Updated October 8, 20268 min read
Short answer

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

The pattern

  1. In test and staging only, route outgoing SMS to a mock inbox. Production keeps using the real provider.
  2. Create a fresh, fake phone number for each test.
  3. Take a since timestamp, then trigger the SMS from the UI.
  4. 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:

src/sms.js
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:

tests/fixtures.ts
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

tests/signup.spec.ts
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:

.github/workflows/e2e.yml
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_TOKEN

Common pitfalls

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.

Try it on your own suite

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

Related guides