guides
Test SMS OTP signup with Cypress
Test an SMS signup in Cypress with a dedicated MailSlurp number, a fresh OTP, Node-side API calls, and an assertion for the signed-in account.
Use with your tools

An SMS signup test needs to follow the code out of the browser and back again. Your application sends a text, the user types its code, and only then should the expected account become signed in. Clicking the send button proves only the first part.
Repeating that journey by hand means keeping a phone nearby and copying a new code for every run. MailSlurp provides real phone numbers with an SMS API, so Cypress can receive the message and continue the same test. Keep the API calls in Cypress's Node process, and let the browser handle the signup form.
The example below is for an application you own. Adapt its selectors and message text to your staging app; it expects a phone signup form, an SMS containing Your code: 123456, and a signed-in page that displays the account's phone number. Use the actual delivered code, including any leading zero.
Setup and install
Install Cypress, TypeScript, and the MailSlurp client in your test project:
npm install --save-dev cypress typescript @types/node mailslurp-client
Assign a MailSlurp phone number to this test worker and keep it exclusive for the whole signup. Supply MAILSLURP_API_KEY and MAILSLURP_PHONE_NUMBER_ID through your shell or CI secret settings. The latter is the phone-number entity ID from MailSlurp, not the telephone digits.
Start your staging app and prepare an unused account for that number. If signup cannot reuse a registered phone, reset the application's test account before each attempt and clean it up even after a failure. Keep the reserved MailSlurp number for later runs. For concurrent tests, phone pools and leases can assign a separate number to each active flow.
Save this as cypress.config.ts at the project root. Change baseUrl to your staging app. The Cypress task API runs the MailSlurp client in Node, keeping the API key out of the browser's environment.
import { defineConfig } from "cypress";
import { MailSlurp } from "mailslurp-client";
import { createSmsTasks } from "./sms-tasks";
export default defineConfig({
video: false,
screenshotOnRunFailure: false,
e2e: {
baseUrl: "http://localhost:3000",
testIsolation: true,
setupNodeEvents(on) {
const apiKey = process.env.MAILSLURP_API_KEY;
const phoneId = process.env.MAILSLURP_PHONE_NUMBER_ID;
if (!apiKey || !phoneId) {
throw new Error("Set MAILSLURP_API_KEY and MAILSLURP_PHONE_NUMBER_ID");
}
// Keep the API key in Node; do not copy it into Cypress browser env.
on("task", createSmsTasks(new MailSlurp({ apiKey }), phoneId));
},
},
});
Create sms-tasks.ts beside the config using the helper below. It fetches the assigned number and waits for an SMS received after the current attempt began. Its message parser deliberately accepts only the application's expected six-digit format.
Add the SMS tasks
import { MailSlurp } from "mailslurp-client";
export type AssignedPhone = { id: string; phoneNumber: string };
export type SmsAttempt = { since: string };
// The assigned number must remain exclusive to this worker until the test ends.
export function createSmsTasks(
mailslurp: MailSlurp,
assignedPhoneId: string,
) {
if (!assignedPhoneId) throw new Error("Set MAILSLURP_PHONE_NUMBER_ID");
return {
async assignedSmsPhone(): Promise<AssignedPhone> {
try {
const phone = await mailslurp.phoneController.getPhoneNumber(
{ phoneNumberId: assignedPhoneId },
{ signal: AbortSignal.timeout(15_000) },
);
if (phone.id !== assignedPhoneId || !/^\+[1-9][0-9]{6,14}$/.test(phone.phoneNumber)) {
throw new Error("Invalid assigned phone");
}
return { id: phone.id, phoneNumber: phone.phoneNumber };
} catch {
throw new Error("Could not fetch the assigned SMS test number");
}
},
async currentSignupCode({ since }: SmsAttempt): Promise<string> {
const started = new Date(since);
if (!Number.isFinite(started.getTime()) || started.getTime() > Date.now()) {
throw new Error("Supply the timestamp captured before requesting the SMS");
}
let sms;
try {
sms = await mailslurp.waitController.waitForLatestSms(
{
waitForSingleSmsOptions: {
phoneNumberId: assignedPhoneId,
since: started,
timeout: 60_000,
unreadOnly: false,
},
},
{ signal: AbortSignal.timeout(65_000) },
);
} catch {
throw new Error("The SMS wait did not complete");
}
if (sms.phoneNumber !== assignedPhoneId ||
!Number.isFinite(sms.createdAt.getTime()) ||
sms.createdAt.getTime() < started.getTime()) {
throw new Error("The SMS does not belong to this number and attempt");
}
// Match your application's exact message template; keep leading zeros.
const match = /^Your code: ([0-9]{6})$/.exec(sms.body ?? "");
if (!match) throw new Error("The SMS did not match the expected code template");
return match[1];
},
};
}
The SMS API wait is 60 seconds, its transport deadline is 65 seconds, and the Cypress task below allows 75 seconds. This gives the API time to return its own failure instead of having Cypress end the task first. unreadOnly: false allows the current message to be found even if someone has already opened it in the dashboard.
Write a test
Keep the whole signup in one it block. The phone entry, SMS request, code submission, and account assertion depend on the same browser session. With testIsolation: true, the next test starts independently instead of inheriting a half-finished signup.
Save this as cypress/e2e/sms-signup.cy.ts. Replace the five data-testid values with your application's controls. The final element must show the authenticated account's phone number in the same format returned by MailSlurp; if your UI formats it differently, compare a stable account ID or a protected account response instead.
import type {} from "cypress";
import type { AssignedPhone } from "../../sms-tasks";
export function assertSignedInPhone(actual: string, expected: string) {
if (actual !== expected) throw new Error("The expected phone account is not signed in");
}
describe("SMS signup", () => {
it("verifies the account belonging to the assigned number", () => {
cy.task<AssignedPhone>("assignedSmsPhone", null, { log: false }).then((phone) => {
// These selectors are the contract of your owned staging application.
cy.visit("/signup");
cy.get('[data-testid="signup-phone"]').type(phone.phoneNumber, { log: false });
cy.then(() => {
const since = new Date().toISOString();
cy.get('[data-testid="request-sms"]').click();
cy.get('[data-testid="sms-code"]').should("be.visible");
return cy.task<string>(
"currentSignupCode",
{ since },
{ timeout: 75_000, log: false },
);
}).then((code) => {
cy.get('[data-testid="sms-code"]').type(code, { log: false });
cy.get('[data-testid="verify-sms"]').click();
cy.get('[data-testid="signed-in-phone"]')
.should("be.visible")
.should(($element) => assertSignedInPhone($element.text(), phone.phoneNumber));
});
});
});
});
Run that spec while its assigned number is reserved:
npx cypress run --spec cypress/e2e/sms-signup.cy.ts
The timestamp is captured immediately before clicking the control that requests the text. The helper rejects earlier messages and a message for the wrong phone-number entity. A timestamp cannot distinguish another request still in flight to the same number, which is why the number and staging account must remain exclusive until the attempt ends.
The last assertion matters as much as the OTP step. A disappearing code field or a visible account menu can belong to the wrong session. Comparing the signed-in identity makes the expected signup outcome explicit.
Diagnose a failed SMS signup
If the wait expires, first check whether the application actually requested an SMS and used the assigned telephone number. Inspect MailSlurp's message history for that number, then compare the message time with the attempt. Increasing every Cypress timeout will not repair a missing send or the wrong destination.
If a text arrives but the parser rejects it, compare the application's real template with ^Your code: ([0-9]{6})$. Change the pattern to match the intended format, keeping the code as a string. Do not accept the first arbitrary number in a message that also contains dates, reference numbers, or support phone numbers.
If code submission succeeds but the account assertion fails, inspect the application's session and account creation behavior. Keep the identity assertion in place while fixing the fixture or redirect; a successful OTP entry alone does not establish which account is active.
The configuration disables automatic videos and failure screenshots for this flow, and the commands suppress phone and OTP values in the command log. If your suite enables additional traces, screenshots, or logs, review their contents before sharing them. For combined email and SMS journeys, the Cypress MailSlurp integration and SMS test diagnostics cover the surrounding setup and failure checks.