Point your backend's SMS provider at a mock inbox in the test environment. In the Appium test, enter a unique fake number, note the time, tap "send code", then poll the inbox's HTTP API from the test runner and type the code into the app. No SMS reaches the emulator, simulator or device, so the same test runs on Android and iOS, locally or on a device cloud.
Step 1: route SMS to the mock
The mobile app does not send the SMS; your backend does, through a provider such as Twilio or Vonage. In the test or staging backend, point that provider's SDK at otpmock with one option. Each provider's option is on its provider page. The app build you test simply talks to that backend.
Step 2: the inbox API your test calls
One request returns the newest code sent to a number after a timestamp, or 404 until one arrives:
GET https://api.otpmock.com/v1/inbox/{phone}/code?since={unix_ms}
Authorization: Bearer $OTPMOCK_API_KEY
200 {"code":"482913","messageSid":"SM…","body":"Your code is 482913","receivedAt":1791417652342}The call is made by the test runner (Node or Python on your machine or CI agent), not by the device. Only the runner needs outbound HTTPS to api.otpmock.com.
WebdriverIO (JavaScript or TypeScript)
Download the dependency-free helper otpmock.ts (or otpmock.mjs) into your test folder. It uses the global fetch of Node 18+.
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 ~ selector is the accessibility id: content-desc on Android, accessibilityIdentifier on iOS (React Native testID and Flutter Semantics labels map to it). Use one selector style for both platforms and the spec stays shared; your wdio.conf.ts chooses the capabilities (appium:automationName UiAutomator2 or XCUITest).
Python (Appium-Python-Client)
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): # driver = appium.webdriver.Remote(...) fixture
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()What about SMS autofill?
iOS security code autofill (the suggestion above the keyboard), Android's SMS Retriever API and the SMS User Consent API all rely on a real SMS reaching the device. With a mock inbox nothing arrives, so those prompts never appear and the test types the code itself. That is usually what you want: the test is deterministic and does not depend on a SIM or emulator SMS injection. If you need to test the autofill path itself, keep a small separate check on a real device with a real number.
Device clouds and parallel runs
On BrowserStack, Sauce Labs or a self-hosted Appium grid, the code is still fetched by the runner, so nothing changes on the device side. Each test generates its own number, so parallel sessions on different devices never read each other's codes.
Common pitfalls
- Configuring otpmock in the app instead of the backend. The SMS provider option belongs where the backend runs. The app build needs no change.
- Phone input masks. Some phone fields split the country code into a picker or reformat digits. Query the number in the E.164 form the backend actually sent, for example
+15551234567. - Typing into split code fields. If the OTP UI has six single-digit boxes, either target the first box and let the app advance focus, or send one digit per box.
- Fixed sleeps. Poll the inbox instead of
driver.pauseortime.sleep; codes usually arrive in under a second.
FAQ
Do I need a real SIM or emulator SMS injection?
No. The SMS never leaves your backend's provider client; the test reads it over HTTP and types it.
Does this work with Appium Java or C# clients?
Yes. The inbox call is a single GET request; port the polling loop above to your language.
Will this work on a production build?
Only if that build talks to a backend whose SMS provider points at otpmock. Use a staging backend for these tests.
The free plan includes 100 messages a month. No card required.
Get a free API key