本页内容
为什么 OTP 会让自动化失效
测试可以点击、输入和断言,却无法读取手机。常见的变通办法各有代价:
- 在预发环境关闭双重验证,意味着用户真正走的登录路径从未被测试过。
- 固定的测试验证码只在你能控制的系统中有效,而且会掩盖投递问题。
- 让人拿着手机撑不起每晚的批量运行,而且凌晨 3 点从来没人在。
方案:真实号码,通过 API 读取
把你自己的 SIM 卡插进一台 Android 手机(或使用带快捷指令自动化的 iPhone),安装 BusySMS 并验证号码。用这个号码注册你的测试账户。在测试中触发短信,向 BusySMS API 请求下一条到达的验证码,然后输入。验证码是真实的,投递路径是真实的,也不需要有人盯着手机。
- 不会拿到过期验证码。等待中的请求只返回它开始之后到达的验证码,因此测试绝不会提交上一次运行的验证码。
- 一个请求搞定。
GET /api/v1/codes?timeoutMs=60000会长轮询,所以无需自己写轮询循环。 - 过滤参数。
phone、service和deviceId可以防止并行测试互相拿走对方的验证码。
一次性设置
- 1
注册一台手机
在插有测试 SIM 卡的手机上从 Google Play 安装 BusySMS,登录并验证号码。详情见 Android 短信网关指南。
- 2
用该号码注册测试账户
在被测产品中使用这个号码,方式与真实用户完全一样。
- 3
创建 API 密钥
控制台 → REST API → 创建密钥。请立即复制:它只显示一次。
- 4
存为 CI 机密
以
BUSYSMS_API_KEY的形式提供给测试运行器。切勿提交到代码仓库,也切勿发送到被测浏览器中。
一个小工具函数
下面每个示例都基于同一个思路:开始等待、触发短信、取得验证码。把等待逻辑放进一个工具函数中,可以用 TypeScript 或 Python 编写:
const API = "https://busysms.net/api/v1/codes";
export async function waitForSmsCode(
opts: { phone?: string; service?: string; timeoutMs?: number } = {},
): Promise<string> {
const params = new URLSearchParams({ timeoutMs: String(opts.timeoutMs ?? 60_000) });
if (opts.phone) params.set("phone", opts.phone);
if (opts.service) params.set("service", opts.service);
const res = await fetch(`${API}?${params}`, {
headers: { Authorization: `Bearer ${process.env.BUSYSMS_API_KEY}` },
});
if (!res.ok) throw new Error(`BusySMS ${res.status}: ${await res.text()}`);
const { code } = (await res.json()) as { code: string | null };
if (!code) throw new Error("No new SMS code arrived in time");
return code;
}import os
import requests
API = "https://busysms.net/api/v1/codes"
def wait_for_sms_code(phone=None, service=None, timeout_ms=60_000):
params = {"timeoutMs": timeout_ms}
if phone:
params["phone"] = phone
if service:
params["service"] = service
r = requests.get(
API,
params=params,
headers={"Authorization": f"Bearer {os.environ['BUSYSMS_API_KEY']}"},
timeout=timeout_ms / 1000 + 15,
)
r.raise_for_status()
code = r.json()["code"]
if not code:
raise TimeoutError("No new SMS code arrived in time")
return code在点击发送验证码之前就开始等待。这样可以避免短信来得太快、在你开始等待前就已到达的竞态问题。在请求前约一秒内到达的验证码仍被视为新的,更早的则不算。
Playwright
import { test, expect } from "@playwright/test";
import { waitForSmsCode } from "./busysms";
test("signs in with SMS two-factor", async ({ page }) => {
test.setTimeout(120_000); // the default 30 s is shorter than an SMS wait
await page.goto("https://staging.example.com/login");
await page.getByLabel("Phone number").fill("+44 7700 900123");
// Start waiting BEFORE the SMS is sent, then trigger it.
const code = waitForSmsCode({ phone: "447700900123" });
await page.getByRole("button", { name: "Send code" }).click();
await page.getByLabel("Verification code").fill(await code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/dashboard/);
});Selenium(Python)
Selenium 的调用是阻塞的,所以要在工作线程中执行等待,触发短信,然后再取结果:
from concurrent.futures import ThreadPoolExecutor
from selenium import webdriver
from selenium.webdriver.common.by import By
from busysms import wait_for_sms_code
driver = webdriver.Chrome()
driver.get("https://staging.example.com/login")
driver.find_element(By.NAME, "phone").send_keys("+44 7700 900123")
with ThreadPoolExecutor(max_workers=1) as pool:
# Start waiting first, then trigger the SMS.
pending = pool.submit(wait_for_sms_code, phone="447700900123")
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
code = pending.result()
driver.find_element(By.NAME, "otp").send_keys(code)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()Cypress
Cypress 命令运行在浏览器中,所以别让密钥进入浏览器:在 cypress.config.ts 中通过 Node task 调用 API,再在测试中使用结果。
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
setupNodeEvents(on) {
on("task", {
// Runs in Node, so the API key never reaches the browser.
async waitForSmsCode({ phone }: { phone: string }) {
const params = new URLSearchParams({ phone, timeoutMs: "60000" });
const res = await fetch(`https://busysms.net/api/v1/codes?${params}`, {
headers: { Authorization: `Bearer ${process.env.BUSYSMS_API_KEY}` },
});
if (!res.ok) throw new Error(`BusySMS ${res.status}`);
const { code } = await res.json();
return code; // null if nothing new arrived
},
});
},
},
});it("signs in with SMS two-factor", () => {
cy.visit("https://staging.example.com/login");
cy.get("input[name=phone]").type("+44 7700 900123");
cy.contains("button", "Send code").click();
cy.task("waitForSmsCode", { phone: "447700900123" }, { timeout: 90_000 }).then((code) => {
expect(code, "SMS code").to.be.a("string");
cy.get("input[name=otp]").type(String(code));
});
cy.contains("button", "Verify").click();
});Cypress 按顺序执行命令,所以这里的等待是在点击之后才开始的。短信很少会那么快送达,而且在请求前一秒内到达的验证码依然有效。
并行运行与不稳定的投递
- 最简单的规则是每个并行 worker 使用一个号码。如果多个 worker 必须共用一个号码,请按
service过滤,并让同一服务的登录依次执行。 - 单次等待上限为 90 秒。如果短信可能需要更久,请重试一次等待,而不是调高超时时间。
- 测试拿到
null时,先检查手机再怪产品:GET /api/v1/numbers会返回canReceive以及reason,例如gateway_off或phone_unreachable。 - 如果等待开始时没有任何手机在捕获,BusySMS 会向手机发送通知,请求重新开启网关。需要有人去轻点它。
- 捕获的短信最多保留 7 天,足够排查昨晚失败的运行。
普通脚本和 CI 任务
这里的做法与测试框架无关。CI 任务中的一个 shell 步骤用 curl 和 jq 也能做到:在后台开始等待,触发短信,然后读取结果。
# Start waiting in the background, then trigger the SMS
curl -s -H "Authorization: Bearer $BUSYSMS_API_KEY" \
"https://busysms.net/api/v1/codes?phone=447700900123&timeoutMs=60000" > code.json &
WAIT_PID=$!
./trigger-login.sh # whatever makes the service send the SMS
wait "$WAIT_PID"
CODE=$(jq -r .code code.json)
[ "$CODE" != "null" ] || { echo "No SMS code arrived" >&2; exit 1; }
echo "Got code $CODE"没有收到验证码时,jq 会输出 null,所以检查会让任务失败,而不是把 null 这个词填进表单。
只用于你自己的账户
这个方法之所以可行,是因为验证码发到了你自己的号码。请只用于你掌控的账户:你产品的测试用户、你自己的管理员登录、你的预发环境。如果驱动浏览器的是 AI 智能体而不是测试运行器,请看 MCP 指南。
BusySMS 读取发到你自己号码上的验证码。它不是绕过他人验证的手段,也不是为批量注册账户而设计的。
常见问题
常见问题解答
如何在 Selenium 中自动完成 OTP 验证?
在工作线程中发起 GET /api/v1/codes?timeoutMs=60000,触发短信,然后把返回的验证码输入到输入框中。上面的 Python 示例正是这样做的。
没有真实手机能自动化 OTP 吗?
用 BusySMS 不行。验证码来自你插在真实手机中的 SIM 卡;不支持用模拟器作为网关。
测试会拿到旧的验证码吗?
不会。当 timeoutMs 大于零时,API 只返回请求开始之后到达的验证码,如果没有,就返回 null。
在 CI 中运行要花多少钱?
7 天免费试用结束后,每个已验证号码每月 $5。按号码计费,而不是按测试运行次数或验证码条数计费。
支持邮箱验证码或身份验证器应用吗?
不支持。BusySMS 读取的是通过短信发到你自己号码上的验证码。邮箱验证码和身份验证器应用的验证码都不是短信,因此不在支持范围内。
整个团队可以共用一个测试号码吗?
可以。把号码共享给最多五位其他 BusySMS 用户;对方接受后,他们自己的 API 密钥也能收到该号码的新验证码。
博客
更多博客文章
全部文章继续阅读
相关指南
全部指南用你自己的号码试一试
安装应用、验证号码,今天就能读到第一条验证码。每个账户都从 7 天免费试用开始;之后每个已验证号码每月 $5,可随时取消。
BusySMS 的更多内容
