Testing SMS OTP flows, end to end.
Somewhere in your company there is a phone. It belongs to someone, it holds the number your staging 2FA texts go to, and your "automated" test plan ends with a human reading a code off its lock screen. Email OTP testing got fixed years ago; the SMS version of the same flow is still tested by hand. Here's how teams actually do it, why each way flakes, and the pattern that replaces all three.
How SMS 2FA gets tested today
1. Someone's personal phone. It can't be scripted, so the test is manual forever. It's single-threaded — one number, one code at a time, so parallel runs are out. It doesn't exist in CI at all. And it's attached to a person: the day they're on a plane, the release train waits, because the 2FA phone is somewhere over the Atlantic. A test whose critical dependency takes vacations is not a test.
2. A shared temp-number site. Those free "receive SMS online" numbers are on every fraud blocklist that matters — and the serious OTP senders, the exact services you're testing against, check those lists and quietly refuse to deliver. When delivery does happen, the messages are on a public web page: your codes, everyone's codes, anyone watching. And the numbers recycle — sometimes mid-run — so the code your assertion matches may belong to a stranger's test.
3. Stubbing the SMS provider in test config. Point the app at a fake sender and assert the fake was called. Tidy, fast, and it proves precisely nothing about delivery: the template rendering, the provider config, the country routing, the phone-number normalization — the exact places 2FA bugs live — never execute. You've tested that your code calls a function. It does.
The anatomy of the fix
Same anatomy as the email version, transposed: a dedicated receive-only number your suite provisions by API, a long-poll the server holds open instead of a sleep, and the OTP returned as a typed field instead of a regex over message text.
The dedicated part carries the same weight a custom domain does for email: the number is yours alone, its reputation is whatever your traffic makes it, and no blocklist has a reason to touch it — blocklists exist to catch shared numbers any stranger can read.
let number; test.beforeAll(async () => { number = await mail.createPhoneNumber(); }); // dedicated US local number test.afterAll(async () => { await number.release(); }); test('login verifies by text', async ({ page }) => { await page.goto('/login'); await page.fill('#phone', number.phoneNumber); await page.click('text=Send code'); const otp = await number.waitForOtp({ timeout: 30_000 }); // resolves the moment the text lands await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); });
@pytest.fixture(scope="session") def number(mf): number = mf.create_phone_number() # provision once per suite, not per test yield number number.release() def test_login_verifies_by_text(page, number): page.goto("/login") page.fill("#phone", number.phone_number) page.click("text=Send code") otp = number.wait_for_otp(timeout=30) page.fill("#otp", otp)
The wait is the same server-side long-poll (wait=) the email inboxes use — the request returns the instant the text arrives, and the timeout is a ceiling, not a duration. Extraction is the same engine too: GET /v1/sms/:id/otp returns { best, candidates }, every 4–8 digit run scored by context, so a text that also mentions a year or an order number doesn't fool your assertion the way /\d{6}/ would.
Agents get the identical loop over MCP: create_phone_number, then wait_for_sms_otp — "trigger the 2FA flow, read the code" is one tool call each.
The honest limits
SMS is not email, and the differences are worth knowing before CI finds them for you:
- Paid plans only. Phone numbers cost real monthly money at the carrier, so there's no free-tier version to hand out. Solo includes 1 number and 100 texts a month, Team 3 and 500, Scale 10 and 2,000.
- The monthly SMS quota is a hard stop — on every plan. No overage billing; texts beyond the quota are dropped. Email bills soft overage on paid plans; SMS does not. Size your quota to your suite, not the other way around.
- US local numbers only, for now. No short codes, no MMS — and, like everything here, receive-only: we never send a text.
- Numbers are plan-capped, so provision per suite, not per test. An inbox per test is free; a number per test would exhaust your cap by the third worker. Create the number in
beforeAllor a session fixture, release it in teardown. On Solo, the single number is shared state across your suite — serialize the tests that use it, exactly the way you'd serialize any other singleton. - Retention follows your plan — 14 days on Solo, 30 on Team and Scale. Releasing a number deletes its messages with it.
One boundary worth stating
Read plainly, "receive SMS programmatically" is also the job description of SMS-verification farming — mass-registering accounts on services the registrant doesn't operate. So the line our terms draw for email applies here verbatim: registering accounts on services you don't control is prohibited; testing your own product's signup and 2FA flows is the point, and explicitly carved out. The design backs the words: numbers exist only through authenticated API calls on paid accounts, so every number, every received text, and every abuse report maps to a named, paying account with a full audit trail. If what you need is verification codes for someone else's service, this is the wrong tool — on purpose.
Where to go next
The SMS quickstart gets you from a key to a green test in a few minutes, in JS, Python, or bare curl. If your second factor arrives by email instead — or as well — the email OTP guide covers that side's flake anatomy. And if the thing filling in your signup form is an agent rather than a test runner, the MCP quickstart wires the same loop into it.