Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

How I test SMS OTP flows in Playwright without sending real texts

Lately I've been working with OTP flows a lot. Every project seems to use a different SMS provider: Twilio here, Infobip there, AWS SNS somewhere else, and Netgsm or Verimor on the Turkish projects. Different APIs, diffe

Lately I've been working with OTP flows a lot. Every project seems to use a different SMS provider: Twilio here, Infobip there, AWS SNS somewhere else, and Netgsm or Verimor on the Turkish projects. Different APIs, different auth, different error formats.

But the part that annoyed me most wasn't the integration. It was the tests.

The end-to-end test runs fine until this screen:

Enter the code we sent to your phone.

And then it just sits there. The code went to a real phone, and the test has no way to read it.

The usual workarounds (and why I didn't like them)
Send a real SMS and check the phone. Slow, costs money on every run, and it isn't automation anymore if someone has to look at a phone.

Hard-code a backdoor. Something like "if the number is a test number, accept 000000". It works, but now you're not testing the real flow, and that if lives in your application code. One wrong environment variable and it's in production.

Skip the step. The most security-critical flow in the app ships untested.

None of these felt right, so I went looking for a cleaner pattern.

The pattern
In test and staging environments, the app still sends SMS through its usual provider SDK, but the SDK points to a mock inbox instead of the real provider.
Every test uses its own fake phone number, so parallel workers never read each other's codes.
Right before triggering the SMS, the test notes the time (since).
The test polls the inbox over HTTP for a code sent to that number after since, and types it in.
The app code doesn't change. Production keeps talking to the real provider. Nothing reaches a carrier in tests.

Step 1: point the SMS client at the mock (test env only)
With Twilio's Node SDK, you can pass a custom HTTP client that rewrites the host. This is the only change in the app, and it only kicks in when OTPMOCK_URL is set:

// 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,
});

OtpMockHttpClient is about ten lines: it extends twilio.RequestClient and keeps the path, swapping only the host.

// twilio-node-client.mjs
import twilio from "twilio";

export class OtpMockHttpClient extends twilio.RequestClient {
  constructor(baseUrl = "https://api.otpmock.com") {
    super();
    this.baseUrl = baseUrl.replace(/\/+$/, "");
  }

  request(opts) {
    const u = new URL(opts.uri);
    return super.request({ ...opts, uri: this.baseUrl + u.pathname + u.search });
  }
}

Other SDKs are usually even simpler, because they already have a host option: Vonage has restHost/apiHost, the AWS SDK has endpoint, Infobip has baseUrl, Sinch has smsHostname.

In the test environment you also set the provider credential (here TWILIO_AUTH_TOKEN) to the mock's API key, so no real credentials are involved.

Step 2: a Playwright fixture with a unique number per 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(); // +1555XXXXXXX, unique per test
    await use(phone);
    await otp.clear(phone);          // optional tidy-up
  },
});

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 between app and runner
  await page.getByRole("button", { name: "Send code" }).click();

  const { code } = await otp.waitForCode(phone, { since });
  await page.getByLabel("Code").fill(code);
  await page.getByRole("button", { name: "Verify" }).click();

  await expect(page.getByText("Welcome")).toBeVisible();
});

waitForCode polls GET /v1/inbox/{phone}/code?since=... every 300 ms and returns as soon as the code arrives. In practice that's well under a second after the app's send call.

What about Verify APIs?
This is where hard-coded backdoors really fall apart. With Twilio Verify (or Vonage Verify, Infobip 2FA, etc.) the provider generates the code, and your app only calls verifications.create and later verificationChecks.create.

A mock that understands those APIs handles both halves: it generates the code, drops it in the inbox for your test to read, and approves the check when your app submits the right code. Your app code stays exactly the same, and wrong-code and too-many-attempts errors behave like the real thing, so you can test those screens too.

Running it in CI
Set the variables wherever both the app under test and the test runner can read them. A mistake I made early on: setting them only for the test runner. The app is the one sending the SMS, so it needs OTPMOCK_URL too.

# .github/workflows/e2e.yml
env:
  OTPMOCK_URL: https://api.otpmock.com
  OTPMOCK_API_KEY: ${{ secrets.OTPMOCK_API_KEY }}
  TWILIO_AUTH_TOKEN: ${{ secrets.OTPMOCK_API_KEY }}   # provider credential = mock key in tests

A few pitfalls
No since. Without it, a retried test can pick up the code from its previous attempt.
Number formatting. If your UI adds a country code or strips the +, read the code for the number the app actually sent to.
Fixed sleeps. Poll instead of waitForTimeout(5000). It's both faster and less flaky.

Not using Playwright?
The pattern is the same everywhere: unique number per test, take since, poll the inbox over HTTP, type the code. Only the way you make the HTTP call changes. I wrote up the details for other tools:

  • Cypress (read the code from a cy.task)
  • Selenium (Python and Java)
  • WebdriverIO, Puppeteer, TestCafe
  • Mobile: Appium, Maestro, Detox for React Native
  • API and QA tools: Postman/Newman, Robot Framework, k6 load tests

The tool
I ended up turning my mock into a small hosted service called otpmock. It currently emulates 15 SMS providers (Twilio, Vonage, AWS SNS, Infobip, Sinch, Telnyx, Plivo, Netgsm and others) plus their Verify APIs, and the setup is tested with the official SDKs in Node, Python, Java, C#, PHP, Ruby and Go. There's a free tier if you want to try the pattern above without building the mock yourself.

But the pattern is the important part. Whether you use otpmock, WireMock or something you write yourself, the OTP step doesn't have to be the one part of your login flow that never gets tested.

How do you handle OTP steps in your E2E tests? I'm curious what other people do.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.