Most end-to-end suites skip the one step that breaks most often in production: the SMS code. They mock the SMS provider, so a wrong sender ID, a template change or a country your provider no longer delivers to only shows up when real users complain.
Here is a small Playwright test (Node.js, built-in fetch) that signs up with a real phone number and types the real code. It uses the Receive SMS HTTP API to get a number and read the SMS. Disclosure: I run that service, but the structure works with any provider that can buy a number and return its messages.
Helper: get a number, wait for the code
// sms.js const API = 'https://receive-smss.net/api/v1'; const headers = { Authorization: `Bearer ${process.env.SMS_API_KEY}` }; async function call(path, params) { const url = `${API}/${path}?${new URLSearchParams(params)}`; const res = await fetch(url, { headers }); return { status: res.status, body: await res.json() }; } export async function getNumber(app, country, maxPrice) { const { body } = await call('get_number', { app, country, max_price: maxPrice }); if (!body.success) throw new Error(`get_number failed: ${body.error}`); return body.data; // { id, number, deduction, expires_at } } export async function waitForCode(id, timeoutMs = 5 * 60_000) { const end = Date.now() + timeoutMs; while (Date.now() < end) { const { body } = await call('get_sms', { n_id: id }); if (body.success) return body.code; await new Promise((r) => setTimeout(r, 5000)); } await call('reject_code', { n_id: id }); // nothing came: cancel and get refunded throw new Error('No SMS received'); } export const finish = (id) => call('finish', { n_id: id }); get_sms returns HTTP 404 until the first message arrives, so the loop simply polls every 5 seconds.
The test
// signup.spec.js import { test, expect } from '@playwright/test'; import { getNumber, waitForCode, finish } from './sms.js'; test('sign up with a UK number', async ({ page }) => { const order = await getNumber('yourapp', 'GB', 0.5); await page.goto('https://staging.example.com/signup'); await page.getByLabel('Phone number').fill(order.number); await page.getByRole('button', { name: 'Send code' }).click(); const code = await waitForCode(order.id); await page.getByLabel('Verification code').fill(code); await page.getByRole('button', { name: 'Verify' }).click(); await expect(page.getByText('Welcome')).toBeVisible(); await finish(order.id); }); Set a generous timeout for this test (test.setTimeout(6 * 60_000)), because SMS delivery can take a minute or two.
Practical notes
- Run it against staging and on a schedule, not on every commit. Each run buys a number, so a nightly job per country is usually enough to catch delivery problems.
-
max_pricemakesget_numberfail instead of paying more than you expect. HTTP 503 means no numbers are free right now and you are not charged. - Use the service code your provider uses for your app.
get_serviceslists them; for your own product you'd typically use a generic "any other" service. - Keep the key in CI secrets and send it in the
Authorizationheader, not in the URL, so it doesn't end up in logs. - Test the negative case too. If your app is supposed to reject virtual numbers, this setup is an easy way to check that it does.
API reference: receive-smss.net/api-docs. Questions welcome in the comments.
