DEV Community

Cover image for Testing SMS OTP flows in Selenium with Python and Java
Kutay VURAL
Kutay VURAL

Posted on AI-assisted

Testing SMS OTP flows in Selenium with Python and Java

This series is about testing SMS OTP flows end to end without real text messages. After Playwright and Cypress (see the series list above), this one is for Selenium, which still runs a huge share of the world's E2E suites, often in Python or Java rather than JavaScript.

The good news: the pattern is the same, and the only tool-specific part is a tiny polling function.

The pattern in 30 seconds

  1. In the test environment, the app's SMS provider SDK points to a mock inbox instead of the real provider (one option on the client, only when OTPMOCK_URL is set). Nothing reaches a carrier.
  2. Each test uses its own fake number, so parallel sessions on a Grid never read each other's codes.
  3. Take a since timestamp right before triggering the SMS.
  4. Poll the inbox over HTTP for a code sent to that number after since, then type it in.

The inbox API is one request:

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}
404 {"error":"no_code_yet"}
Enter fullscreen mode Exit fullscreen mode

Python

# tests/otp.py
import os, random, time, urllib.parse
import requests

API = os.environ.get("OTPMOCK_URL", "https://api.otpmock.com")
KEY = os.environ["OTPMOCK_API_KEY"]

def random_phone():
    return "+1555" + "".join(random.choices("0123456789", k=7))

def wait_for_code(phone, since_ms, timeout=15):
    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}")
Enter fullscreen mode Exit fullscreen mode
# tests/test_signup.py
import time
from selenium.webdriver.common.by import By
from otp import random_phone, wait_for_code

def test_phone_signup(driver):
    phone = random_phone()
    driver.get("https://staging.example.com/signup")
    driver.find_element(By.NAME, "phone").send_keys(phone)
    since = int(time.time() * 1000) - 5000  # margin for clock skew
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

    code = wait_for_code(phone, since)
    driver.find_element(By.NAME, "code").send_keys(code)
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
Enter fullscreen mode Exit fullscreen mode

Java

Plain java.net.http, no extra dependencies:

// OtpMock.java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.regex.*;

public final class OtpMock {
  private static final HttpClient HTTP = HttpClient.newHttpClient();
  private static final String API = System.getenv().getOrDefault("OTPMOCK_URL", "https://api.otpmock.com");
  private static final String KEY = System.getenv("OTPMOCK_API_KEY");

  public static String waitForCode(String phone, long sinceMs) throws Exception {
    String url = API + "/v1/inbox/" + URLEncoder.encode(phone, StandardCharsets.UTF_8) + "/code?since=" + sinceMs;
    long deadline = System.currentTimeMillis() + 15_000;
    while (System.currentTimeMillis() < deadline) {
      HttpResponse<String> r = HTTP.send(
          HttpRequest.newBuilder(URI.create(url)).header("Authorization", "Bearer " + KEY).build(),
          HttpResponse.BodyHandlers.ofString());
      if (r.statusCode() == 200) {
        Matcher m = Pattern.compile("\"code\":\"([^\"]+)\"").matcher(r.body());
        if (m.find()) return m.group(1);
      } else if (r.statusCode() != 404) {
        throw new IllegalStateException("otpmock " + r.statusCode() + ": " + r.body());
      }
      Thread.sleep(300);
    }
    throw new IllegalStateException("no code for " + phone);
  }
}
Enter fullscreen mode Exit fullscreen mode

Swap the regex for Jackson or Gson if you already have one on the classpath.

Pointing the app at the mock (Python and Java backends too)

The app side isn't tied to JavaScript either. If your backend is in Python or Java, the provider SDK has a host option or accepts a custom HTTP client. A few examples:

# Python, AWS SNS with boto3
sns = boto3.client("sns", endpoint_url=os.environ.get("OTPMOCK_URL"))
Enter fullscreen mode Exit fullscreen mode
// Java, Vonage
VonageClient.Builder builder = VonageClient.builder().apiKey(apiKey).apiSecret(apiSecret);
if (System.getenv("OTPMOCK_URL") != null) builder.httpConfig(HttpConfig.builder().baseUri(System.getenv("OTPMOCK_URL")).build());
Enter fullscreen mode Exit fullscreen mode

I keep tested setups for 15 providers in Node, Python, Java, C#, PHP, Ruby and Go here.

Selenium Grid and parallel runs

Every test generates its own number, so you can scale Grid nodes without coordination. Only the test runner talks to the inbox API; the browser nodes never do, so they don't need network access to it.

Pitfalls

  • time.sleep(10) instead of polling. Codes usually arrive in well under a second. Polling is faster and less flaky.
  • Variables only on the runner. The app sends the SMS, so it needs OTPMOCK_URL too.
  • Number format. If the UI adds a country code or strips the +, read the code for the number the app actually sent to.

Wrapping up

I use otpmock for this, but any mock inbox you can query over HTTP works with the same code. Full guide: Selenium SMS OTP testing.

Next and last in the series: mobile apps with Appium and Maestro.

Top comments (0)