DEV Community

Cover image for How I test SMS OTP flows in Cypress (the cy.task trick)
Kutay VURAL
Kutay VURAL

Posted on AI-assisted

How I test SMS OTP flows in Cypress (the cy.task trick)

In the previous post I showed how I test SMS OTP flows in Playwright without sending real texts. A few people asked about Cypress, and Cypress needs one extra trick, so here it is.

Quick recap of the idea: in the test environment, the app still sends SMS through its normal provider SDK, but the SDK points to a mock inbox instead of the real provider. Nothing reaches a carrier. The test reads the code over HTTP and types it in. No 000000 backdoor in the app, and the real flow gets tested.

Why Cypress is a bit different

In Playwright you can just await a fetch from the test. In Cypress, test code runs in the browser and is built around the command queue. Polling an external API from a spec is possible with cy.request and recursion, but it gets ugly fast, and CORS can get in the way.

The cleaner way: do the polling in Node, inside a cy.task. Tasks run in the Cypress Node process, so you can use a normal async helper with fetch, and the spec just waits for the result.

Step 1: point the app's SMS client at the mock

This part is the same as in any framework and lives in the app, not in Cypress. One option on the client you already create, only when OTPMOCK_URL is set. For example with the Vonage SDK:

import { Vonage } from "@vonage/server-sdk";

export const vonage = new Vonage(
  { apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }, // secret = mock key in tests
  process.env.OTPMOCK_URL ? { restHost: process.env.OTPMOCK_URL, apiHost: process.env.OTPMOCK_URL } : {},
);
Enter fullscreen mode Exit fullscreen mode

Twilio, AWS SNS, Infobip, Sinch and the others each have a similar one-liner (base URL, endpoint or host option).

Step 2: the task

Download the small helper (no dependencies) next to your config:

curl -O https://otpmock.com/sdk/otpmock.mjs
Enter fullscreen mode Exit fullscreen mode

Then register a task that waits for the code:

// cypress.config.js
import { defineConfig } from "cypress";
import { OtpMock } from "./otpmock.mjs";

const otp = new OtpMock({ baseUrl: process.env.OTPMOCK_URL, apiKey: process.env.OTPMOCK_API_KEY });

export default defineConfig({
  e2e: {
    taskTimeout: 30000,
    setupNodeEvents(on) {
      on("task", {
        waitForCode: ({ phone, since }) => otp.waitForCode(phone, { since }).then((r) => r.code),
      });
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

waitForCode polls GET /v1/inbox/{phone}/code?since=... every 300 ms and resolves as soon as a code for that number shows up. A task must resolve to something serializable, so we return just the code string.

Step 3: the spec

// cypress/e2e/signup.cy.js
it("signs up with a phone number", () => {
  const phone = "+1555" + Cypress._.random(1000000, 9999999); // unique per test
  cy.visit("/signup");
  cy.get("[name=phone]").type(phone);

  const since = Date.now() - 5000; // margin for clock skew
  cy.contains("Send code").click();

  cy.task("waitForCode", { phone, since }).then((code) => {
    cy.get("[name=code]").type(code);
  });
  cy.contains("Verify").click();
  cy.contains("Welcome").should("be.visible");
});
Enter fullscreen mode Exit fullscreen mode

Things that tripped me up

  • taskTimeout. The default is 60 s, which is fine, but if you lower it below your SMS wait time, the task fails with a confusing timeout. Keep it comfortably above the helper's own timeout (15 s by default).
  • Env vars in the wrong process. process.env in cypress.config.js is the Node side, so OTPMOCK_API_KEY must be in the environment that runs cypress run, not in Cypress.env().
  • Same number across retries. With retries enabled, a retried test reuses nothing from the failed attempt as long as the number is generated inside the test, and since makes sure an old code isn't picked up.
  • The app needs the variables too. The app under test sends the SMS, so OTPMOCK_URL has to be set where the app runs (staging, a preview deploy, a local server in CI), not just for Cypress.

Verify APIs work the same way

If your app uses Twilio Verify, Vonage Verify, Infobip 2FA or similar, the provider generates the code. The mock handles that too: it generates the code, puts it in the inbox for the task to read, and approves the check when the app submits it. The spec above doesn't change at all.

Wrapping up

I use otpmock for this (I built it after getting tired of the problem), but the cy.task pattern works with any mock inbox you can query over HTTP. The full Cypress guide, including CI setup, is here.

Next in the series: Selenium, with Python and Java.

Top comments (0)