BusySMS

OTP 自动化

如何在测试和脚本中自动处理 OTP 与短信双重验证

短信一次性验证码是端到端测试最容易卡住的一步。本指南介绍如何在测试中保留真实的这一步:你自己手机里的真实 SIM 卡,加上一个等待验证码的 API 调用,并提供 Playwright、Selenium 和 Cypress 的可运行示例。

每个已验证号码每月 $5 · 7 天免费试用 · 在网页端通过 Stripe 计费

本页内容

为什么 OTP 会让自动化失效

测试可以点击、输入和断言,却无法读取手机。常见的变通办法各有代价:

  • 在预发环境关闭双重验证,意味着用户真正走的登录路径从未被测试过。
  • 固定的测试验证码只在你能控制的系统中有效,而且会掩盖投递问题。
  • 让人拿着手机撑不起每晚的批量运行,而且凌晨 3 点从来没人在。

方案:真实号码,通过 API 读取

把你自己的 SIM 卡插进一台 Android 手机(或使用带快捷指令自动化的 iPhone),安装 BusySMS 并验证号码。用这个号码注册你的测试账户。在测试中触发短信,向 BusySMS API 请求下一条到达的验证码,然后输入。验证码是真实的,投递路径是真实的,也不需要有人盯着手机。

  • 不会拿到过期验证码。等待中的请求只返回它开始之后到达的验证码,因此测试绝不会提交上一次运行的验证码。
  • 一个请求搞定。GET /api/v1/codes?timeoutMs=60000 会长轮询,所以无需自己写轮询循环。
  • 过滤参数。phone、service 和 deviceId 可以防止并行测试互相拿走对方的验证码。

一次性设置

  1. 1

    注册一台手机

    在插有测试 SIM 卡的手机上从 Google Play 安装 BusySMS,登录并验证号码。详情见 Android 短信网关指南。

  2. 2

    用该号码注册测试账户

    在被测产品中使用这个号码,方式与真实用户完全一样。

  3. 3

    创建 API 密钥

    控制台 → REST API → 创建密钥。请立即复制:它只显示一次。

  4. 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;
}

在点击发送验证码之前就开始等待。这样可以避免短信来得太快、在你开始等待前就已到达的竞态问题。在请求前约一秒内到达的验证码仍被视为新的,更早的则不算。

Playwright

login.spec.ts
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 的调用是阻塞的,所以要在工作线程中执行等待,触发短信,然后再取结果:

test_login.py
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
        },
      });
    },
  },
});

Cypress 按顺序执行命令,所以这里的等待是在点击之后才开始的。短信很少会那么快送达,而且在请求前一秒内到达的验证码依然有效。

并行运行与不稳定的投递

  • 最简单的规则是每个并行 worker 使用一个号码。如果多个 worker 必须共用一个号码,请按 service 过滤,并让同一服务的登录依次执行。
  • 单次等待上限为 90 秒。如果短信可能需要更久,请重试一次等待,而不是调高超时时间。
  • 测试拿到 null 时,先检查手机再怪产品:GET /api/v1/numbers 会返回 canReceive 以及 reason,例如 gateway_off 或 phone_unreachable。
  • 如果等待开始时没有任何手机在捕获,BusySMS 会向手机发送通知,请求重新开启网关。需要有人去轻点它。
  • 捕获的短信最多保留 7 天,足够排查昨晚失败的运行。

普通脚本和 CI 任务

这里的做法与测试框架无关。CI 任务中的一个 shell 步骤用 curl 和 jq 也能做到:在后台开始等待,触发短信,然后读取结果。

bash
# 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,可随时取消。