Disclosure: I work with HeroSMS, the virtual number provider used in the examples below. Written with AI tools; code and endpoints checked against the HeroSMS API reference.
If you've tried to handle OTP in Selenium WebDriver, you know this test. It types a phone number, clicks "Send code", then sits in Thread.sleep(30000) while you read the code off your phone and type it in. That works on your own machine. In the nightly run nobody types anything, so the next explicit wait fails:
org.openqa.selenium.TimeoutException: Expected condition failed: waiting for url to contain "/welcome". Current url: "https://staging.example.com/signup/verify"
(tried for 15 seconds with 500 milliseconds interval)
The CI runner has no SIM card. This tutorial replaces the human with an API. The test rents a real number, polls for the SMS code from Java with a deadline, types the code after an explicit wait and releases the number in finally. GitHub Actions runs the whole thing on a schedule. Stack: Java 17+, Maven, Selenium 4.52 and JUnit 5.
How to automate OTP in Selenium WebDriver
Selenium can't read SMS because it drives a browser, not a phone. To automate OTP in Selenium:
- Rent a phone number through an SMS API and type it into your form.
- Poll the API from Java until the code arrives.
- Wait for the OTP field with
WebDriverWaitand type the code into it.
Your test code does the polling, and Selenium only does the typing. The same flow works for sign-up, login and SMS 2FA.
Mocks, test numbers, 2FA off for test users, or a real number
Start with the free options, since real numbers cost money. Selenium's own documentation advises against automating 2FA at all. It suggests these workarounds instead:
- disable 2FA for test users or in the test environment
- accept a test-only token
- skip 2FA for requests from your test machines' IPs
| Option | What it proves | Cost | When to run |
|---|---|---|---|
| Mocked SMS sender | UI and app logic | Free | Every PR |
| Provider test credentials or fictional numbers | Your call to the SMS provider; UI with a fixed code | Free | Every PR |
| 2FA off for test users, or a test-only token | Everything after the second factor | Free | Every PR |
| TOTP secret in the test | Your authenticator-app step | Free | Every PR |
| Real rented number | A real SMS reaches a real number and your app accepts the code | Paid per number | Nightly, pre-release |
Twilio's test credentials don't connect to real phone numbers. Firebase's fictional test numbers (up to 10, each with a fixed code) send no SMS. If your second factor is an authenticator app, you don't need a phone: generate the TOTP code from the shared secret.
None of these options proves that an SMS actually reaches a phone in your launch country. A real-number test fills that gap. I built the same split (mocks on every PR, real SMS on a schedule) three times before: in Python, for Playwright with a webhook and for Postman and Newman. This post is the Selenium Java version.
What you need
- Java 17 or later (the CI job below uses 21), Maven 3.6.3 or later, and Chrome. Selenium Manager has been built in since 4.6 and downloads and caches the matching driver. Since 4.11 it can also fetch Chrome for Testing if no browser is installed.
- A HeroSMS API key and some balance. What you buy is an activation: one number reserved for one service in one country for about 20 minutes. The exact end time is in
expiredAt. Numbers cover 180+ countries, so you can usually test in the country you launch in. - These environment variables:
export HEROSMS_API_KEY="<your API key>"
export OTP_SERVICE="<service code>" # 2-4 letters
export OTP_COUNTRY="<country ID>" # an integer
export APP_URL="https://staging.example.com"
# optional: export HEROSMS_MAX_PRICE="<price cap per number>"
Service codes and country IDs come from the getServicesList and getCountries endpoints in the HeroSMS API reference. GET /activations/offers/sms without filters returns them too: service codes are the keys of data, and country IDs are the keys inside each service.
The project has four classes under src/test and a workflow:
selenium-otp/
├── pom.xml
├── .github/workflows/otp-nightly.yml
└── src/test/java/com/example/otp/
├── HeroSmsClient.java
├── SmsCodePoller.java
├── ActivationCleanup.java
└── OtpSignupTest.java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>selenium-otp</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.14.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.52.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
</plugins>
</build>
</project>
To use JUnit 6 (which needs Java 17+), change the BOM version to 6.1.3. The test compiles unchanged because it only uses the Jupiter API. HTTP calls go through the JDK's own HttpClient, so Jackson is the only extra dependency.
Step 1: a small SMS API client in Java
// src/test/java/com/example/otp/HeroSmsClient.java
package com.example.otp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/** Minimal HeroSMS v1 client: rent a number, read the last OTP, finish or cancel. */
public final class HeroSmsClient {
private static final String API = "https://hero-sms.com/api/v1";
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http = HttpClient.newHttpClient(); // immutable, reuse it
private final String apiKey;
public HeroSmsClient(String apiKey) {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("HEROSMS_API_KEY is not set");
}
this.apiKey = apiKey;
}
/** One rented number. phone is digits without the '+'. */
public record Activation(String id, String phone, String countryPhoneCode, String expiredAt) {}
/** Error body from the API: branch on title, not on the HTTP status. */
public static final class HeroSmsException extends IOException {
public final int status;
public final String title;
public final String details;
public final transient JsonNode info;
public final long retryAfter; // seconds, 0 if the header is absent
HeroSmsException(int status, String title, String details, JsonNode info, long retryAfter) {
super("HTTP " + status + " " + title + ": " + details);
this.status = status;
this.title = title;
this.details = details;
this.info = info;
this.retryAfter = retryAfter;
}
}
public Activation buy(String service, int country, Double maxPrice)
throws IOException, InterruptedException {
Map<String, Object> body = new LinkedHashMap<>();
body.put("service", service);
body.put("country", country);
body.put("amount", 1);
body.put("verificationType", "sms");
if (maxPrice != null) body.put("maxPrice", maxPrice);
JsonNode a = call("POST", "/activations", body).path("data").path(0);
if (a.isMissingNode()) throw new IOException("POST /activations returned no activation");
return new Activation(a.path("id").asText(), a.path("phone").asText(),
a.path("countryPhoneCode").asText(), a.path("expiredAt").asText());
}
/** data of GET /activations/{id}/otp/last: smsCode and smsText, either may be null. */
public JsonNode lastOtp(String id) throws IOException, InterruptedException {
return call("GET", "/activations/" + id + "/otp/last", null).path("data");
}
/** Closes the activation after the code was used. No refund. */
public void finish(String id) throws IOException, InterruptedException {
call("POST", "/activations/" + id + "/finish", null);
}
/** Cancels an activation that got no SMS. The price is refunded. */
public void cancel(String id) throws IOException, InterruptedException {
call("DELETE", "/activations/" + id, null);
}
private JsonNode call(String method, String path, Object body)
throws IOException, InterruptedException {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(15)) // without it, send() can block forever
.header("Authorization", "ApiKey " + apiKey)
.header("Accept", "application/json");
if (body != null) {
req.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
} else {
req.method(method, HttpRequest.BodyPublishers.noBody());
}
HttpResponse<String> res = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
String text = res.body() == null ? "" : res.body();
if (res.statusCode() >= 400) {
JsonNode err = parse(text);
long retryAfter = res.headers().firstValue("Retry-After")
.map(HeroSmsClient::seconds).orElse(0L);
throw new HeroSmsException(res.statusCode(), err.path("title").asText(""),
err.path("details").asText(""), err.path("info"), retryAfter);
}
return parse(text); // 204 No Content -> missing node
}
private static JsonNode parse(String text) {
if (text.isBlank()) return JSON.missingNode();
try {
return JSON.readTree(text);
} catch (IOException e) {
return JSON.missingNode(); // not JSON
}
}
private static long seconds(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return 0; // an HTTP date: use the caller's fallback
}
}
}
- Auth is one header,
Authorization: ApiKey <key>. The key is read from the environment and never touches the browser. - Errors come back as JSON with
titleanddetails, and sometimesinfo.HeroSmsExceptionkeeps all three plus theRetry-Afterheader, so callers can branch ontitle. - Always set
HttpRequest.timeout. Without it,send()can wait forever, and one stuck request hangs the whole job. With it, a slow request throws anHttpTimeoutException. -
phonecomes back as an integer without the+.asText()keeps every digit.
Step 2: poll for the code with a deadline, not Thread.sleep
A fixed Thread.sleep(30000) is the classic way to wait for an OTP, and it fails in both directions. It's too short when a carrier is slow, and it wastes 28 seconds when the SMS lands in two. Poll instead, and stop at a deadline:
// src/test/java/com/example/otp/SmsCodePoller.java
package com.example.otp;
import com.example.otp.HeroSmsClient.HeroSmsException;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.time.Duration;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Polls GET /activations/{id}/otp/last until a code arrives or the deadline passes. */
public final class SmsCodePoller {
private static final Set<String> CLOSED =
Set.of("ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED");
private static final Pattern CODE = Pattern.compile("\\b\\d{4,8}\\b"); // fit it to your template
private SmsCodePoller() {}
public static String waitForCode(HeroSmsClient sms, String activationId, Duration timeout)
throws IOException, InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
long delayMs = 2_000; // backs off to 10 s
IOException lastError = null; // becomes the cause if time runs out
while (System.nanoTime() < deadline) {
long sleepMs = delayMs;
try {
JsonNode otp = sms.lastOtp(activationId);
lastError = null; // the API answered
String code = extractCode(otp);
if (code != null) return code;
if (!text(otp.path("smsText")).isEmpty()) {
throw new IllegalStateException("SMS arrived without a 4-8 digit code: adjust the regex");
}
} catch (HeroSmsException e) {
if (CLOSED.contains(e.title)) {
throw e; // closed or expired: stop polling
} else if (e.status == 429) {
sleepMs = (e.retryAfter > 0 ? e.retryAfter : 5) * 1_000; // documented for offers only
} else if (e.status >= 500) {
lastError = e; // server error: retry until the deadline
} else if (e.status == 400 || e.status == 404) {
lastError = null; // open activation, no SMS yet
} else {
throw e; // any other error: stop polling
}
} catch (IOException e) {
lastError = e; // timed-out or dropped request: retry until the deadline
}
long leftMs = Duration.ofNanos(deadline - System.nanoTime()).toMillis();
if (leftMs <= 0) break;
Thread.sleep(Math.min(sleepMs, leftMs));
delayMs = Math.min(delayMs * 3 / 2, 10_000);
}
throw new IllegalStateException(
"No SMS for activation " + activationId + " within " + timeout.toSeconds() + " s", lastError);
}
static String extractCode(JsonNode otp) {
String code = text(otp.path("smsCode")).trim();
if (!code.isEmpty()) return code;
Matcher m = CODE.matcher(text(otp.path("smsText")));
return m.find() ? m.group() : null;
}
private static String text(JsonNode node) {
return node.isMissingNode() || node.isNull() ? "" : node.asText(); // NullNode.asText() is "null"
}
}
-
GET /activations/{activationId}/otp/lastreturnssmsCode(the code) andsmsText(the whole message). Either one can be null. WhensmsCodeis empty, the regex takes the first 4-8 digit number fromsmsText, so adjust it to your SMS template. - The API reference doesn't document the response before the first SMS. The poller therefore treats 400, 404 and an empty
dataas "not yet", unlesstitlesays the activation is closed. One side effect: a 404 caused by a wrong ID looks like a timeout. - A 5xx response, a timed-out request or a dropped connection is retried until the deadline. If polling runs out of time, the last such error becomes the exception's cause, so a proxy or DNS problem doesn't look like a missing SMS.
- On 429 the loop honors
Retry-After. The reference documents that header only for the offers endpoint, so the loop falls back to 5 seconds when it's absent. - The deadline uses
System.nanoTime(), which doesn't jump when the runner's clock is adjusted.
Why not use Selenium's own FluentWait? It's generic, so new FluentWait<>(activationId) can poll any object, not just a driver. But Function.apply can't throw checked exceptions, and the polling interval is fixed. The 429 and closed-activation rules would end up wrapped and hidden. A plain loop is easier to read.
Step 3: the Selenium OTP test with JUnit 5 and explicit waits
// src/test/java/com/example/otp/OtpSignupTest.java
package com.example.otp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.openqa.selenium.By;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import java.util.List;
@Tag("real-sms")
@EnabledIfEnvironmentVariable(named = "HEROSMS_API_KEY", matches = ".+")
class OtpSignupTest {
private static final By PHONE = By.cssSelector("[data-test=phone]");
private static final By SEND_CODE = By.cssSelector("[data-test=send-code]");
private static final By OTP = By.cssSelector("[data-test=otp]"); // the input, or a wrapper around one box per digit
private static final By OTP_BOXES = By.cssSelector("[data-test=otp] input");
private static final By VERIFY = By.cssSelector("[data-test=verify]");
private static final By OTP_ERROR = By.cssSelector("[data-test=otp-error]");
private WebDriver driver;
private WebDriverWait wait;
@BeforeEach
void startBrowser() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--window-size=1366,900");
driver = new ChromeDriver(options); // Selenium Manager finds or downloads the driver
wait = new WebDriverWait(driver, Duration.ofSeconds(15)); // polls every 500 ms
wait.ignoring(StaleElementReferenceException.class); // OTP boxes may re-render
}
@AfterEach
void quitBrowser() {
if (driver != null) driver.quit();
}
@Test
void signsUpWithARealSmsCode() throws Exception {
// Check the config before paying for a number
String service = env("OTP_SERVICE", null);
int country = Integer.parseInt(env("OTP_COUNTRY", null));
String cap = env("HEROSMS_MAX_PRICE", "");
Double maxPrice = cap.isEmpty() ? null : Double.valueOf(cap);
String appUrl = env("APP_URL", null);
HeroSmsClient sms = new HeroSmsClient(System.getenv("HEROSMS_API_KEY"));
HeroSmsClient.Activation number = sms.buy(service, country, maxPrice);
long boughtAt = System.nanoTime();
boolean gotCode = false;
try {
driver.get(appUrl + "/signup");
wait.until(ExpectedConditions.visibilityOfElementLocated(PHONE))
.sendKeys("+" + number.phone());
wait.until(ExpectedConditions.elementToBeClickable(SEND_CODE)).click();
String code = SmsCodePoller.waitForCode(sms, number.id(), Duration.ofSeconds(120));
gotCode = true;
typeOtp(wrongCode(code)); // a wrong code must be rejected first
wait.until(ExpectedConditions.elementToBeClickable(VERIFY)).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(OTP_ERROR));
typeOtp(code);
wait.until(ExpectedConditions.elementToBeClickable(VERIFY)).click();
wait.until(ExpectedConditions.urlContains("/welcome")); // TimeoutException = test fails
} finally {
ActivationCleanup.release(sms, number.id(), gotCode, boughtAt);
}
}
/** One input, or one box per digit: types by index, so it doesn't rely on auto-focus. */
private void typeOtp(String code) {
WebElement otp = wait.until(ExpectedConditions.visibilityOfElementLocated(OTP));
if ("input".equalsIgnoreCase(otp.getTagName())) {
otp.clear();
otp.sendKeys(code);
return;
}
wait.until(ExpectedConditions.numberOfElementsToBe(OTP_BOXES, code.length()));
for (int i = 0; i < code.length(); i++) {
int index = i;
String digit = code.substring(i, i + 1);
wait.until(d -> {
List<WebElement> boxes = d.findElements(OTP_BOXES); // fresh lookup on every try
if (boxes.size() <= index) return null; // mid re-render: keep waiting
WebElement box = boxes.get(index);
box.clear();
box.sendKeys(digit);
return true;
});
}
}
private static String wrongCode(String code) {
return (code.startsWith("1") ? "2" : "1") + code.substring(1);
}
/** Blank counts as unset: GitHub passes an empty string for a missing variable. */
private static String env(String name, String fallback) {
String value = System.getenv(name);
if (value != null && !value.isBlank()) return value.trim();
if (fallback != null) return fallback;
throw new IllegalStateException(name + " is not set");
}
}
To run it locally, export the variables above and run mvn test -Dgroups=real-sms. If Surefire reports Tests run: 1, ... Skipped: 1, the key wasn't exported in that shell. Once the key is exported, a plain mvn test runs this test too and buys a number. For everyday runs, use mvn test -DexcludedGroups=real-sms.
-
@Tag("real-sms")lets Maven select or exclude the test.@EnabledIfEnvironmentVariabledisables it when the key isn't set, so a PR build without the secret never buys a number. - The test reads all of its config before
buy, andAPP_URLhas no fallback. A missing or malformed variable therefore fails the test before it pays for a number. - Every interaction goes through an explicit wait.
WebDriverWaittakes aDurationand polls every 500 ms. It ignoresNotFoundExceptionby default and throwsTimeoutExceptionwhen time runs out. - Use
visibilityOfElementLocatedfor fields,elementToBeClickablefor buttons, andnumberOfElementsToBeto wait until every OTP box exists. - Don't set an implicit wait as well. The Selenium waits page warns against mixing implicit and explicit waits, because the resulting timeouts are unpredictable.
- Split OTP inputs get one digit per box, by index, with a fresh lookup each time. If a re-render briefly leaves fewer boxes, the lambda returns
nulland the wait polls again. The wait also ignoresStaleElementReferenceException, so a box that re-renders mid-typing is retried instead of failing the test. - The wrong-code check proves your app rejects bad input. If your app burns the code after one wrong attempt, drop those three lines. If it submits on the last digit, drop the Verify clicks.
- For a login or SMS 2FA test, open your login page instead and type the code at the second-factor prompt. If your form has a separate country picker, strip
countryPhoneCode(also in the purchase response) from the start of the number. - The Selenium Chrome docs use
--headless=new. Chrome 132 removed the old headless mode, so plain--headlessnow does the same thing.
Step 4: finish or cancel the number in finally
// src/test/java/com/example/otp/ActivationCleanup.java
package com.example.otp;
import com.example.otp.HeroSmsClient.HeroSmsException;
import java.time.Duration;
final class ActivationCleanup {
private ActivationCleanup() {}
/** Finish if the code was used, cancel otherwise. Never throws: it must not hide the test failure. */
static void release(HeroSmsClient sms, String id, boolean gotCode, long boughtAtNanos) {
try {
if (gotCode) {
sms.finish(id); // the SMS arrived: close it, no refund
return;
}
try {
sms.cancel(id); // no SMS: cancel, the price goes back to your balance
} catch (HeroSmsException e) {
if ("OTP_RECEIVED".equals(e.title)) {
sms.finish(id); // the SMS came late
} else if ("EARLY_CANCEL_DENIED".equals(e.title)) {
long minSec = e.info.path("minActivationTime").asLong(120);
long waitNanos = boughtAtNanos + Duration.ofSeconds(minSec + 5).toNanos() - System.nanoTime();
if (waitNanos > 0) Thread.sleep(Duration.ofNanos(waitNanos).toMillis());
try {
sms.cancel(id); // one more try, no more waiting
} catch (HeroSmsException again) {
if (!"OTP_RECEIVED".equals(again.title)) throw again;
sms.finish(id);
}
} else {
throw e;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("[otp] cleanup of " + id + " interrupted; check GET /activations");
} catch (Exception e) {
System.err.println("[otp] cleanup of " + id + " failed: " + e.getMessage());
}
}
}
The release call sits in the test's own finally, right after the purchase, so it runs whether the assertions pass or throw. @AfterEach only quits the browser.
- If the code arrived,
finishcloses the activation, with no refund. If it didn't,DELETEcancels the activation and refunds the price. - The API refuses a cancel during a number's first minutes. It returns
EARLY_CANCEL_DENIEDwithinfo.minActivationTime, which is about 2 minutes in the reference's examples. A test that fails within seconds therefore waits out that window plus 5 seconds, then tries once more. - If the SMS arrives late, the cancel fails with
OTP_RECEIVEDand the code callsfinishinstead. Cleanup branches ontitle, not on the HTTP status. - Cleanup errors are only logged. Throwing from
finallywould replace the real failure (say, aTimeoutExceptionon the OTP field) with a less useful one. - If the runner itself dies, no Java code runs at all. Unused numbers are refunded to your balance, and
GET /activationslists anything still open.
Step 5: run the Selenium Java test nightly in GitHub Actions
Real numbers cost money, so run this test on a schedule rather than on every push:
# .github/workflows/otp-nightly.yml
name: otp-nightly
on:
schedule:
- cron: '17 4 * * 1-5' # 04:17 UTC on weekdays; every run buys a number
workflow_dispatch:
concurrency:
group: otp-nightly
cancel-in-progress: false # a canceled run may never reach the finally block
jobs:
real-sms:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Fail if the API key is missing
env:
HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
run: test -n "$HEROSMS_API_KEY" || { echo "::error::HEROSMS_API_KEY is not set"; exit 1; }
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Real-SMS Selenium test
env:
HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
OTP_SERVICE: ${{ vars.OTP_SERVICE }}
OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
APP_URL: ${{ vars.APP_URL }}
run: mvn -B test -Dgroups=real-sms
- uses: actions/upload-artifact@v7
if: failure()
with:
name: surefire-reports
path: target/surefire-reports
retention-days: 7
- The first step fails the job if the secret is missing or misnamed. Without that step, GitHub passes an empty string,
@EnabledIfEnvironmentVariableskips the test, and the nightly run goes green withSkipped: 1. - The key comes in through the step's
env:, not the command line, as GitHub's secrets guide recommends. Surefire's forked test JVM inherits Maven's environment, soSystem.getenvsees the key. - Your PR workflow should run
mvn -B test -DexcludedGroups=real-smsand not pass the key at all. GitHub doesn't pass secrets other thanGITHUB_TOKENto workflows triggered from forks, so a fork PR can't spend your balance. Don't move this job topull_request_target, because that trigger does get secrets. - The
ubuntu-latestimage ships with Chrome and ChromeDriver, and Selenium Manager finds the driver itself, so the workflow needs no browser setup step. -
Scheduled workflows have a few quirks:
- They run on the latest commit of the default branch.
- Times are in UTC unless you set a timezone.
- Under load they can start late or be dropped, especially at the top of the hour. That's why the cron uses minute 17.
- In a public repository, they are disabled after 60 days without activity.
- Set
HEROSMS_MAX_PRICEas a repository variable, so a price change can't quietly raise the cost of every run. - If you add failure screenshots, treat them as sensitive: they show the phone number and the code. Keep artifact retention short.
Troubleshooting
-
The code never arrives.
- Check that your app accepted the number with the
+. - Check that
OTP_SERVICEmatches the service that sends your SMS, since an activation is reserved for one service. - If your app rejects certain prefixes,
phoneExceptionin the purchase body excludes them up front. -
POST /activations/{activationId}/replacegives you a free replacement number. If you use it, pass the activation it returns toActivationCleanup.release, not the original ID.
- Check that your app accepted the number with the
-
TimeoutExceptionon the OTP field. One of three things is usually wrong:- The locator is wrong.
- The field is inside an iframe. Call
driver.switchTo().frame(...)first. - Your app shows the field only after the send-code request returns. Raise the wait for that one step instead of adding an implicit wait.
-
StaleElementReferenceExceptionon split boxes. Your component re-renders after each digit. Re-query each box by index, astypeOtpdoes, and keepignoring(StaleElementReferenceException.class)on the wait. -
Skipped: 1instead of a run.HEROSMS_API_KEYisn't set in that shell, so@EnabledIfEnvironmentVariabledisabled the test. In CI, the guard step fails the job instead. -
429 Too Many Requests. The poller honors
Retry-Afterand backs off to 10 seconds. Theconcurrencygroup stops two scheduled runs from polling at the same time. -
EARLY_CANCEL_DENIEDin the log. This is expected when a test fails fast. Cleanup waits and retries once. If the retry is refused too, the number stays open until it expires. -
Expired activation. Polling stops on
ACTIVATION_NOT_ACTIVE,CANCELED,FINISHEDorREFUNDED. A 2-minute poll sits well inside the ~20-minute activation. If you wait longer, compare againstexpiredAt. -
SessionNotCreatedExceptionabout the ChromeDriver version. The usual cause is an old driver pinned withwebdriver.chrome.driver, or a leftover WebDriverManager setup. Remove it and let Selenium Manager pick the matching driver. -
Passes locally, fails headless. Headless Chrome may lay the page out differently. Set an explicit
--window-size, as above, and take a screenshot on failure to see what the browser saw.
FAQ
Can I automate Selenium 2FA in Python instead? Yes, and the API calls are the same. My Python OTP post has the client and the polling loop with requests, so you can call them from a pytest Selenium test.
How do I automate email OTP in Selenium Java? It's the same loop with a different inbox. Create a test inbox with an email-testing API and poll it for the message with the same deadline loop. Then pull the code out with the same regex and type it with the same typeOtp. Only the client class changes.
Can I just skip OTP or CAPTCHA in tests? For most runs, yes. Selenium's docs recommend a test-only token, or 2FA disabled for test users. Keep one real-SMS run to check delivery. Never try to solve CAPTCHAs in automation; disable them in your test environment instead.
What about TOTP and Google Authenticator in Selenium? You don't need a phone for those. Store the shared secret as a CI secret and generate the current code at runtime with any RFC 6238 TOTP library.
How do I automate mobile OTP in Selenium Java? "Mobile OTP" usually means an SMS code sent to a phone number, which is exactly what this test handles. If the form lives in a native app rather than a web page, the same HeroSmsClient and poller work from an Appium test.
How much does a run cost? Each run pays for one number. The price depends on the service and country, and HEROSMS_MAX_PRICE caps it. A run that gets no SMS is canceled and refunded. If the cancel retry is refused too, the number stays open until it expires. A run that receives an SMS is paid even if your app then fails. A weekday schedule comes to about 22 numbers a month.
Use it on your own app only
Test only apps you own or are authorized to test. A rented number is yours only for the activation window, so don't tie it to an account you'll need later, and delete test users afterwards.
Wrap-up
Keep mocks and test tokens on every PR, and add one scheduled Selenium run against a real number. The PR runs prove the page works. The nightly run proves that a real user gets a code they can type into that page, in the country you launch in, with the SMS template you actually ship. If you'd rather have codes pushed to you than polled, the HeroSMS API reference documents an sms-incoming webhook. The Playwright post above shows that setup.
How do you handle OTP in Selenium today: a test token, a shared phone, or something else?
Top comments (0)