SMS verification is everywhere in mobile apps, and it's also where E2E tests most often give up on it. This last post in the series covers Appium and Maestro. (Earlier parts covered Playwright, Cypress and Selenium; see the series list above.)
First, a misconception
The mobile app doesn't send the SMS. Your backend does: the app calls something like POST /auth/send-code, and the backend calls Twilio, Infobip, AWS or whichever provider you use.
That's good news for testing. You don't need to touch the device or the app binary at all. In the test or staging environment, point the backend's SMS SDK at a mock inbox (one option, only when OTPMOCK_URL is set). Nothing reaches a carrier, and the test reads the code over HTTP.
It also means SMS autofill (iOS one-time code suggestions, Android SMS Retriever) won't kick in during these tests, because no SMS reaches the device. Your test types the code, which is exactly what a user without autofill does. If you specifically want to test autofill, that needs a real SIM and a separate, small check.
Appium (WebdriverIO, TypeScript)
Use the small dependency-free helper (curl -O https://otpmock.com/sdk/otpmock.ts):
// test/specs/signup.e2e.ts
import { OtpMock } from "../support/otpmock";
const otp = new OtpMock({
baseUrl: process.env.OTPMOCK_URL ?? "https://api.otpmock.com",
apiKey: process.env.OTPMOCK_API_KEY!,
});
describe("phone verification", () => {
it("signs up with an SMS code", async () => {
const phone = otp.randomPhone(); // +1555XXXXXXX, unique per test
await $("~phone-input").setValue(phone);
const since = Date.now() - 5_000; // margin for clock skew
await $("~send-code-button").click();
const { code } = await otp.waitForCode(phone, { since, timeout: 20_000 });
await $("~code-input").setValue(code);
await $("~verify-button").click();
await expect($("~welcome-screen")).toBeDisplayed();
});
});
The ~ selectors are accessibility IDs, which work on both Android and iOS.
Appium (Python)
import os, random, time, urllib.parse
import requests
from appium.webdriver.common.appiumby import AppiumBy
API = os.environ.get("OTPMOCK_URL", "https://api.otpmock.com")
KEY = os.environ["OTPMOCK_API_KEY"]
def wait_for_code(phone, since_ms, timeout=20):
url = f"{API}/v1/inbox/{urllib.parse.quote(phone)}/code"
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(url, params={"since": since_ms}, headers={"Authorization": f"Bearer {KEY}"})
if r.status_code == 200:
return r.json()["code"]
if r.status_code != 404:
r.raise_for_status()
time.sleep(0.3)
raise TimeoutError(f"no code for {phone}")
def test_phone_signup(driver):
phone = "+1555" + "".join(random.choices("0123456789", k=7))
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "phone-input").send_keys(phone)
since = int(time.time() * 1000) - 5000
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "send-code-button").click()
code = wait_for_code(phone, since)
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "code-input").send_keys(code)
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "verify-button").click()
Maestro
Maestro flows are YAML, but runScript lets you run JavaScript with a built-in http client, which is all we need.
One script creates a unique number:
// scripts/new-phone.js
var digits = '';
for (var i = 0; i < 7; i++) digits += Math.floor(Math.random() * 10);
output.phone = '+1555' + digits;
output.code = '';
Another makes one attempt to read the code:
// scripts/fetch-code.js
var url = OTPMOCK_URL + '/v1/inbox/' + encodeURIComponent(PHONE) + '/code?since=' + SINCE;
var response = http.get(url, { headers: { 'Authorization': 'Bearer ' + OTPMOCK_API_KEY } });
if (response.status === 200) {
output.code = json(response.body).code;
} else if (response.status === 404) {
// Not there yet. Maestro scripts have no sleep, so wait briefly before the next attempt.
var until = Date.now() + 500;
while (Date.now() < until) {}
} else {
throw new Error('otpmock ' + response.status + ': ' + response.body);
}
And the flow repeats that attempt until the code is there:
appId: ${APP_ID}
env:
OTPMOCK_URL: https://api.otpmock.com
---
- launchApp:
clearState: true
- runScript: ../scripts/new-phone.js
- tapOn:
id: "phone-input"
- inputText: ${output.phone}
- evalScript: ${output.since = Date.now() - 5000}
- tapOn:
id: "send-code-button"
- repeat:
times: 30
while:
true: ${output.code == ''}
commands:
- runScript:
file: ../scripts/fetch-code.js
env:
OTPMOCK_URL: ${OTPMOCK_URL}
OTPMOCK_API_KEY: ${OTPMOCK_API_KEY}
PHONE: ${output.phone}
SINCE: ${output.since}
- assertTrue: ${output.code != ''}
- tapOn:
id: "code-input"
- inputText: ${output.code}
- tapOn:
id: "verify-button"
- assertVisible:
id: "welcome-screen"
Run it with the key passed as an env var:
maestro test -e APP_ID=com.example.app -e OTPMOCK_API_KEY="$OTPMOCK_API_KEY" flows/signup-otp.yaml
React Native and Detox
Same story: the RN app calls your backend, the backend's SDK points to the mock in test environments, and the Detox (Jest) test uses the same helper as the Appium example. I wrote it up here. One exception: if you use Firebase Phone Auth, Firebase sends the SMS itself, so use Firebase's own test numbers or emulator instead.
Device farms and parallel runs
BrowserStack, Sauce Labs, AWS Device Farm: none of them need access to the mock. Only the test runner calls the inbox API, and each test has its own number, so you can run as many devices in parallel as you like.
Wrapping up the series
Across Playwright, Cypress, Selenium and mobile, the idea never changed: unique number, since, poll, type. The only thing that changes is how each tool makes an HTTP call.
I use otpmock for this, which I built after running into the problem one too many times. It emulates 15 SMS providers and their Verify APIs, with setups tested in seven languages. But whatever you use, the OTP step really doesn't have to be the untested part of your login flow.
Top comments (0)