测试 OTP 与短信验证:开发者手册
目录
- 点击“发送验证码”到短信送达之间,究竟发生了什么?
- 如何为手机号验证设计测试矩阵?
- 如何为单次测试拿到一个真实的手机号?
- 如何把租用的号码接入自动化测试?
- Python、Node.js、Go 和 PHP 分别需要哪些代码示例?
- 不同服务的测试差异在哪里?
- 国家和运营商的差异如何改变测试方案?
- 每套 OTP 测试都应该覆盖哪些失败场景?
- 短信一直收不到,该怎么排查?
- 如何在不烧掉预算的前提下对 OTP 流程做压力测试?
- 如何在生产环境中监控 OTP 送达情况?
- 如何让 OTP 测试在 CI 中保持稳定?
- 团队该如何共享号码、权限和预算?
- 验证测试涉及哪些隐私与法律边界?
- 关于 OTP 与短信验证测试的常见问题
- 上线短信验证前,先走一遍发布前检查清单
手机号验证的端到端测试,指的是一次完整跑通的流程:从注册表单开始,到拿到会话令牌结束,中间的短信环节走的是真实手机的接收路径。其他做法(模拟的服务商、写死的 000000、打桩的 webhook)只能测你自己的代码,测不到线上真正会出问题的那条投递链路。
六步速览
- 只锁定一个目标,范围收窄。测一个服务、一个国家、一条运营商路由。想测”短信整体情况”,得到的结果没法落地。
- 搞到一个号码。可以是你自己持有的固定测试 SIM 卡、服务商的沙盒号码,或者为单次接码租用的号码。
- 提交表单之前先开启接码。发送方触发时号码必须已经上线并处于监听状态,晚五秒都不行。
- 在你的应用里提交手机号,并记录该请求的时间戳。
- 轮询查收短信,直到验证码到达或接码窗口关闭。记录时间差,精确到秒。
- 把验证码回填到你的验证接口,对会话做断言,然后关闭接码,释放号码。
团队最常跳过的就是第 3 步和第 6 步。跳过第 3 步,测试会变得不稳定:本地通过,CI 里挂掉。跳过第 6 步,接码会一直挂着,既花钱,又占着号码让下一轮跑不了。
用于冒烟测试的最便宜市场
| 国家 | 起价 | 服务数量 | 库存号码数 |
|---|---|---|---|
| USA | $0.01 | 200 | 79,399,375 |
| Germany | $0.01 | 144 | 101,118,633 |
| United Kingdom | $0.02 | 291 | 389,379,749 |
| France | $0.02 | 153 | 131,996,906 |
| Portugal | $0.02 | 143 | 140,112,396 |
| Uzbekistan | $0.02 | 38 | 19,318,261 |
| Italy | $0.03 | 149 | 412,533,735 |
| Austria | $0.04 | 126 | 183,770,041 |
三种测试号码来源对比
| 来源 | 单次成本 | 准备耗时 | 真实运营商路径 | 适用场景 |
|---|---|---|---|---|
| 抽屉里的实体 SIM 卡 | 套餐费用,外加一个人去看手机 | 每个号码几小时到几天 | 是 | 手动冒烟测试,一两个国家 |
| 服务商沙盒号或魔法号码 | 免费 | 几分钟 | 否,短信根本不会发出 | 单元测试,自有逻辑的 CI 跑批 |
| 租用号码,单次接码 | $0.04 起,随服务和国家浮动 | 几分钟,通过 API 或 App 完成 | 是 | 投递验证,开拓新国家,上线前检查 |
你的测试套件里有 90% 是在对状态机做断言:限流、验证码过期、重试计数、错误码锁定。这部分用沙盒号码是对的。它们毫秒级跑完,且零成本。只要是在测你自己的分支逻辑,就都用它。
实体 SIM 卡适合的场景是:你需要同一个号码跨周持续存在,比如一个长期保持登录状态、用作回归测试固定装置的账号。它的成本是人力:得有人守着手机。
什么时候只能用真实租用号码
有四种情况是模拟方案回答不了的:
- 你要在一个从未发送过的国家上线。你得知道自己的发送方 ID 能不能挺过进入印度或美国的路由,短信正文是完整送达还是被截断。
- 某个服务会过滤 VoIP 号码。你的测试号码必须是该服务认可的运营商下的真实手机号。
- 你改了短信模板。有些发送方会重写或拦截含 URL 的正文,只有真实投递才能看到最终文本。
- 你需要一个和个人号码分开的 QA 二号账号,出于隐私卫生的考虑,而不是为了绕开什么规则。
MarioSMS 提供真实手机号租用,一次用于一条验证,$0.04 起,价格随服务和国家浮动,覆盖 35 多个国家和数百项服务。验证码会出现在网页控制台或 App 里,通常一分钟内到达。每个号码都带倒计时,如果超时仍未收到短信,接码会自动取消,费用自动退回你的余额。也就是说,一次投递失败除了留下数据,不会让你多花一分钱。
一次通过的测试到底证明了什么
要把范围说清楚。用租用号码跑出绿灯,只证明五件事,多一件都没有:
- 你的应用接受了该国家的这种号码格式。
- 你的发送方针对这次请求确实发出了一条消息。
- 在你所测量的窗口内,这条路由把正文送到了一台真实手机上。
- 正文里的验证码和你后端生成的验证码一致。
- 你的验证接口为该验证码签发了会话。
它不能证明同一国家另一家运营商的投递情况,不能证明换一个发送方 ID 的情况,也不能证明四小时后高峰时段走同一条路由的情况。投递按路由来看是概率性的,一次通过只是一个样本。每次跑测都要记录秒数,而不只是记通过或失败。上周 8 秒通过、今天 47 秒通过的测试,在它真正变红之前就已经在告诉你一些东西了,而这条趋势线,正是你开工单时能拿给服务商看的东西。
点击“发送验证码”到短信送达之间,究竟发生了什么?
一个只断言“验证码出现了”的验证测试,一次性覆盖了五个彼此独立的系统。它失败时,你根本不知道是哪一环出了问题。把整条链路拆成组件,你才清楚断言该放在哪里、哪个超时属于哪一跳。
每次测试运行一个号码的成本
| 服务 | 最便宜的国家 | 价格 | 库存号码数 |
|---|---|---|---|
| Telegram | Canada | $0.51 | 422,891 |
| United Kingdom | $0.90 | 197,726 | |
| Uzbekistan | $0.10 | 9,620 | |
| Discord | United Kingdom | $0.04 | 308,494 |
| Uber | Portugal | $0.02 | 18,704 |
| Amazon | Uzbekistan | $0.02 | 12,015 |
数据来源:MarioSMS 目录,2026-09-10。价格为单个号码价格,库存数已注明。
从客户端到服务商的请求路径
点击“发送验证码”会向后端发起一次 POST 请求,通常是 /auth/phone/start,携带 E.164 格式的号码。在任何短信产生之前,你的后端会做四件事。
单次验证每个服务的成本
| 服务 | 最便宜的国家 | 价格 |
|---|---|---|
| Telegram | Canada | $0.51 |
| United Kingdom | $0.90 | |
| Uzbekistan | $0.10 | |
| Discord | United Kingdom | $0.04 |
| Sweden | $0.06 | |
| TikTok | United Kingdom | $0.05 |
| United Kingdom | $0.06 | |
| OpenAI | United Kingdom | $0.06 |
- 号码规范化与校验。美国号码若输入为
(415) 555-0100,会转成+14155550100。非法输入在这一步就以 400 结束,不产生任何短信费用。 - 检查频率限制。按号码、按 IP、按设备指纹、按账号。多数团队至少会跑三个不同时间窗口的计数器。
- 生成并存储验证码(见下文)。
- 调用短信服务商的 REST API,传入号码、短信内容和发送方配置,然后向客户端返回 200,并附上可重发的等待时间。
服务商调用是第一个“响应结果不等于你真正关心的结果”的环节。服务商返回 201 Created,意味着消息被接收进了它的队列,而不是某台手机收到了什么。在“已接收”和“已送达”之间,还隔着聚合商、运营商网关和手机本身。任何把服务商的 2xx 当作送达证明的测试,测的其实是你的 HTTP 客户端。
验证码如何生成、哈希与存储
多数实现会用密码学安全的随机源生成 4 到 8 位数字,而不是 Math.random()。写入存储的记录通常包含号码、验证码的哈希值、签发时间戳、过期时间、尝试次数计数器和一个状态字段。
存哈希,不存明文。验证时重新计算用户输入的哈希再做比对。这一点对你的测试很关键:它意味着你无法在集成测试里从数据库读出预期的验证码,而这恰恰是团队转向使用真实号码接收真实短信的原因。MarioSMS 的租用号码会以用户实际收到的方式把验证码送达,在应用内或控制台中呈现,通常一分钟内到达,同时激活计时器正在走。
过期窗口是一个产品决策,常见为 5 分钟或 10 分钟。尝试次数计数器一般在连续输错 3 到 5 次后锁定。只要你留出时钟注入点,这两者都可以在不发送任何短信的情况下测试。
运营商路由、发送方 ID 与送达回执
服务商接收消息后会选择一条路由。路由是经由一个或多个聚合商通往目标运营商的路径,并携带一个发送方身份:长号码、短号码、字母数字发送方 ID,或美国的 10DLC 已注册号码。发送方类型并非只是外观问题。有些国家屏蔽字母数字 ID,有些运营商会降低未注册流量的优先级,还有些会针对看起来像营销模板的短信内容做过滤。
随后服务商会通过 webhook 向你的回调 URL 发送送达回执(DLR)。常见状态:
| DLR 状态 | 含义 | 你的测试该怎么做 |
|---|---|---|
queued | 已接收,尚未发往运营商 | 继续等待,不要断言失败 |
sent | 已交给运营商 | 开始计送达时长 |
delivered | 运营商确认手机已收到 | 与你的轮询结果做比对 |
undelivered | 运营商拒收或丢弃 | 记录错误码,明确报错 |
failed | 服务商侧失败 | 换一条路由重试并告警 |
DLR 并不总是诚实的。有些运营商在消息交到自家内部存储时就上报 delivered,所以“收到 delivered 回执,收件箱却没有验证码”是真实且常见的情况,值得为它单独写一个测试用例。
延迟从哪里来,正常的送达窗口是什么样
延迟是各跳累加出来的,而不是集中在某一处。
| 环节 | 典型耗时 |
|---|---|
| 客户端到你的后端 | 几十毫秒 |
| 验证码生成与写入存储 | 个位数毫秒 |
| 你的后端到服务商 API | 100 到 500 毫秒 |
| 服务商队列到聚合商 | 不定,负载高时变长 |
| 聚合商到运营商 | 随路由和国家而变 |
| 运营商到手机 | 不定,高峰时段最差 |
前三项归你所有,基本恒定。服务商 API 调用之后的一切都不在你的掌控之内,波动也都出在那里。实际情况是:健康的路由几秒内就能把验证码送到手机,拥堵的路由可能要几分钟,坏掉的路由则永远不会到达。测试超时时间应当根据号码激活窗口来设定,而不是靠猜;并且每次运行都记录耗时秒数,这样你拿到的是一个分布,而不是一次孤例。
为什么同一条验证码会重复到达或乱序
短信是存储转发系统,不同消息之间没有顺序保证。有三种机制会造成重复和乱序。
重试。聚合商在其窗口内没有收到确认,就会重发同一条消息。手机会收到两条一模一样的短信,有时相隔 30 秒,有时相隔 6 分钟。
用户重发。用户在第一条消息到达之前点了“重新发送”,于是有两个有效验证码同时在途。如果你的后端在重发时使旧验证码失效,那么先到的那条其实已经作废,而一个抓取“看到的第一条短信”的测试就会在系统完全正常的情况下失败。
路由切换。服务商在传输途中在聚合商之间做故障切换,可能把第二次尝试推上一条更快的路径,于是第二条消息反而先到手机。
你的轮询逻辑需要一条“哪条消息胜出”的规则。按接收时间排序,取最新的一条,并记录该号码在激活期间总共收到了多少条消息。如果一次单请求测试竟然看到了两条,说明你抓到了一个重试循环,值得在它影响生产流量之前查清楚。
如何为手机号验证设计测试矩阵?
测试矩阵把”测一下短信流程”变成一份有限的、带预期结果的用例清单。没有矩阵,团队会把同一条正常路径重测二十遍,然后在重发路径上带着 bug 上线。矩阵会强迫你列出每一个可控变量,为每个变量选定取值,并在运行任何用例之前写下应该发生什么。
目录中的每个服务和国家同时也是一个API调用
值得变化的五个维度
五个维度覆盖了手机号验证中大多数真实缺陷:
在团队中共享号码、权限和预算
| 问题 | 可行的答案 |
|---|---|
| 谁能花钱? | 一个账户,每个环境使用各自的API密钥,设置月度上限 |
| 谁能看到验证码? | 仪表盘和API;不要放到共享聊天中 |
| 如何避免冲突? | 每个测试worker使用一个激活,teardown阶段释放 |
| 如何审计? | 按密钥导出每月的激活历史记录 |
- 国家和号码类型。来自美国的虚拟手机号和来自印度的表现不同,送达时间和显示的发送方 ID 都不一样。号码类型同样重要,有些服务在注册环节对非 VoIP 号码和 VoIP 号码的处理方式不同。
- 输入格式。带加号的 E.164、带前导零的国内格式、空格、连字符、括号,以及重复输入两遍国家代码。每一种要么归一化成同一个值,要么给出清晰的报错。
- 时间点。在第 5 秒输入验证码、第 60 秒输入、过期前一秒输入、过期后一秒输入。时间类 bug 就藏在你声明的过期时间和实际 token TTL 之间的缝隙里。
- 尝试次数。首次验证码、重发、第三次重发,以及限流本该生效之后的那一次尝试。
- 验证码正确性。正确的码、错误的码、过期的码、上一次激活留下的码,以及从通知里粘贴过来带空白字符的码。
五个维度全部相乘会得到几百种组合。你不需要全跑。你要挑一个覆盖集,让每个维度的每个取值至少出现一次,然后再补上你怀疑会相互影响的特定组合,比如过期加重发。
正常路径、边界路径和滥用路径用例
把每个用例归入三个类别,优先级自然就清楚了。
正常路径用例证明功能可用。号码有效、验证码收到、在有效期内输入、账号创建成功。这里放三到四个用例,你支持的每个主要国家一个。
边界路径用例证明功能能优雅降级。短信根本没到、验证码过期后才到、用户中途换号、应用退到后台两分钟后再回来、提交和响应之间网络断开。用户真正卡住的地方就在这些场景里。
滥用路径用例证明你的限制真的生效。同一号码一分钟内请求六次、同一 IP 请求五十个不同号码、同一验证码换着数字提交 200 次。这些只能用你自己租的号码、针对你自己的系统测试,仅限 QA 用途,并遵守可接受使用规则。
一份带预期结果的示例矩阵
| 编号 | 国家 | 输入格式 | 时间点 | 尝试次数 | 验证码 | 预期结果 |
|---|---|---|---|---|---|---|
| H1 | 美国 | E.164 | 20 秒 | 第 1 次 | 正确 | 账号创建,签发会话 |
| H2 | 印度 | 国内格式,前导 0 | 30 秒 | 第 1 次 | 正确 | 归一化为 E.164,账号创建 |
| E1 | 美国 | E.164 | 一直没到 | 第 1 次 | 无 | 激活取消,余额退回,界面提供重试 |
| E2 | 美国 | E.164 | 过期后 5 秒 | 第 1 次 | 正确 | 拒绝并提示”验证码已过期”,提供重发 |
| E3 | 英国 | 带空格的 E.164 | 15 秒 | 第 2 次 | 第 1 个码 | 拒绝,旧码已被重发作废 |
| E4 | 印度 | E.164 | 45 秒 | 第 1 次 | 正确码 + 尾部空格 | 自动去空格,通过 |
| A1 | 美国 | E.164 | 不适用 | 60 秒内第 6 次 | 不适用 | 发送被拦截,提示冷却剩余秒数 |
| A2 | 美国 | E.164 | 10 秒 | 第 1 次 | 错误尝试 10 次 | 触发尝试锁定,验证码作废,需重新请求 |
每一行都用可观察的方式写明一个预期结果。“正常”不是预期结果。“签发会话并返回 201”才是。
每个版本该跑多少用例
按节奏拆分,别每次都跑全量。
| 触发时机 | 用例数 | 需要真实短信 | 大致成本 |
|---|---|---|---|
| 每个 pull request | 3 到 5 | 0 | $0 |
| 每晚构建 | 10 到 15 | 3 到 5 | 不到 $0.50 |
| 发版前 | 25 到 35 | 8 到 12 | 约 $1 |
| 认证逻辑或供应商变更后 | 全量矩阵 | 15 到 20 | 几美元 |
按起价每个号码 $0.04 计算,一次完整的发版前测试比一杯咖啡还便宜,而且没收到短信的激活会自动退款。
哪些用例值得用真实短信,哪些可以打桩
凡是只测你自己代码的都打桩。格式归一化、TTL 计算、限流计数器、尝试锁定,全都可以对着假的供应商在毫秒级跑完。这些其实就是披着矩阵行外衣的单元测试。
当用例依赖你进程之外的东西时,才去租真实号码:真实的运营商送达、发送方 ID 的显示、你的正则对短信正文的解析、iOS 和 Android 上的自动填充行为,以及真实号码激活的时序。其中 E1 这一行尤其需要真实号码,因为测试”短信根本没到”的唯一诚实办法,就是请求一个号码然后让计时器跑完。
如何为单次测试拿到一个真实的手机号?
租一个号码做单次测试,是一个五步循环,从头到尾大约两分钟。下面的步骤以 MarioSMS 为例:一次租用的号码只用于一次验证,价格根据服务和国家不同,最低 $0.04 起。
一个能捕捉常见故障的测试矩阵
| 用例 | 设置 | 通过标准 |
|---|---|---|
| 正常流程 | 全新号码,首次尝试 | 验证码送达,账户创建成功 |
| 短信延迟 | 在整个激活窗口期内轮询 | 界面持续等待,而不是在30秒时报错 |
| 完全没有短信 | 让窗口期过期 | 提供重试选项,不扣费,不产生孤立账户 |
| 验证码错误 | 输入四次错误数字 | 出现锁定提示,尝试次数按号码计数 |
| 重新发送 | 在同一号码上请求第二个验证码 | 遵守限速规则,两个验证码都能通过或以最新的为准 |
| 号码重复使用 | 已注册过的号码 | 触发你自己的重复处理逻辑,而非崩溃 |
| 国家不匹配 | 来自其他市场的号码 | 显示用户真正需要看到的提示信息 |
在每一个上线市场都运行以上每个用例,而不仅仅是本国市场。
第 1 步,选择服务和国家
打开网页版 app.mariosms.com,或者 iOS、Android 应用,选定两件事:你要测试的目标服务,以及号码所属的国家。库存覆盖 35+ 个国家和数百种服务,所以大多数测试矩阵用一个账号就能跑完。
测试号码的隐私和政策限制
| 规则 | 实际含义 |
|---|---|
| 只测试你自己的产品 | 租用号码仅用于你自己的流程和QA账户 |
| 禁止冒充他人 | 绝不能以他人名义验证账户 |
| 禁止规避封禁 | 被封禁的账户应保持封禁状态 |
| 数据卫生 | 不要在测试固件中保留收到的验证码或号码 |
此处以可接受使用政策为准:/acceptable-use/。
按你正在执行的矩阵行来选国家,别凭习惯选。美国号码和印度号码在你自己的代码里表现并不一样:位数不同,你的库渲染出来的格式不同,短信正文里的发送方 ID 也不同。如果这一行写的是「IN,首次尝试,Android 自动填充开启」,那就租印度号码,而不是碰巧更便宜的美国号码。
确认之前显示的价格就是你实际支付的价格。把它连同行 ID 一起写进测试运行日志,这样以后谈预算时手里有真实数字,而不是估算。
第 2 步,租下号码并记下激活窗口
确认租用。号码出现时旁边会有一个计时器,这就是激活窗口。这个计时器代表号码归你所有、并持续监听该服务短信的时长。
记下开始时间。此刻有两个时钟在跑:激活窗口,以及你自己应用设定的 OTP 有效期。大多数让人困惑的测试结果,都来自这两个时钟对不上。如果你的应用 5 分钟就让验证码过期,而你花了 3 分钟手动把号码抄进表单,那你测的是自己的耐心,不是你的流程。
租号之前,先把待测应用停在手机号输入页面。租用应该是你粘贴前做的最后一件事。
第 3 步,把号码粘贴到待测应用里
从控制台复制号码,粘贴到手机号字段。此时有两个细节值得检查,因为它们能在短信还没出现之前就抓出真实的 bug:
- 你的输入框能否接受控制台给出的格式,也就是带国家代码、不含空格?
- 你的归一化处理产出的 E.164 字符串,和短信服务商看到的是否一致?
然后点击「发送验证码」并按下秒表;如果是脚本化运行,就记录时间戳。
第 4 步,在控制台或手机应用里读取验证码
验证码会出现在控制台或手机应用中,通常一分钟以内。根据你用的客户端,刷新页面或盯着激活记录那一行看。对于脚本化运行,REST API 返回的激活状态和控制台显示的一致,所以测试可以轮询,不必让人守着屏幕。
把验证码填进你的应用,走完整个流程。每次运行记录三件事:
| 字段 | 示例值 | 为什么重要 |
|---|---|---|
| 收到验证码用时 | 14s | 你的送达 SLO 基准 |
| 完整短信正文 | “Your code is 481920. Do not share.” | 用于你的正则和自动填充测试 |
| 显示的发送方 ID | 短代码或字母数字 | 会影响 iOS 上的自动填充行为 |
你的解析正则、iOS 自动填充域名提示、以及支持文档,都依赖短信的确切文本,而不只是那六位数字。
第 5 步,如果什么都没来,就让计时器走完
有时候短信压根不会来。那就是矩阵里的 E1 行,它是一个合理的测试结果,不是测试环境出了问题。别去动这个激活记录,让计时器自然走完。窗口关闭且没有短信时,激活会自动取消,费用会自动退回你的余额。
计时器走的时候,观察你自己的应用在做什么。重发按钮是否在你设计的间隔时间变为可点击?90 秒后屏幕是不是还在转圈?有没有任何提示告诉用户下一步该做什么?这些答案才是这次运行真正的价值。
退款机制对测试预算意味着什么
未送达自动退款,会改变你给整套测试做预算的方式。你只为收到的验证码付费,而不是为尝试次数付费。
一个 40 行的矩阵,其中 32 行成功送达、平均单价 $0.06,总共约 $1.92,而那 8 行本就设计为收不到短信的,花费 $0。按预期成功送达的数量来估算开销,再为真实抖动后的重试留一点余量。用银行卡或加密货币每个冲刺周期充值一次,而不是每次运行都充,并且整个过程都要符合可接受使用规范:针对你拥有或已获授权测试的服务做 QA 和 OTP 测试。
如何把租用的号码接入自动化测试?
一个 OTP 测试有两半,分别跑在不同的系统里。你的测试客户端驱动被测应用或 API,另一个独立的客户端则和号码服务商通信。只有当两半达成一致时,测试才算通过:应用接受了一个验证码,而这个码发送到的号码是测试在几秒前刚刚租用的。下面所有内容,都是为了让这两半保持同步,而不用在整个测试套件里到处撒 sleep。
激活窗口期就是你的测试应遵守的超时时间
请求、轮询、提取、断言循环
这个流程在任何语言里都是一样的。五个步骤,按顺序执行:
上线前检查清单
| 检查项 | 完成标准 |
|---|---|
| 每个上线市场均已用真实号码测试 | 验证码送达且账户创建成功 |
| 已测试过期路径 | 窗口期耗尽后提供重试选项,不产生费用 |
| 已测试重试和锁定机制 | 限制生效且提示文案清晰 |
| 已测试回退方案 | 如提供语音或邮箱路径,确保其可用 |
| 监控已上线 | 仪表盘按国家展示输入率和延迟 |
| 预算已设上限 | 设置测试支出的每日限额 |
- 针对目标服务和国家请求一次激活。你会拿到一个激活 ID 和一个 E.164 格式的手机号。
- 把这个号码提交给被测应用,并触发发送验证码的操作。
- 轮询激活状态,直到出现短信正文或计时器到期。
- 用针对该服务的正则规则从短信文本中提取验证码。
- 提交验证码,对得到的会话或错误做断言,然后关闭这次激活。
第 1、3、5 步是服务商侧的调用。第 2、4 步是你自己的。把它们放在不同的模块里,这样服务商一变,只需要动一个文件。轮询这一半的可用实现可以在 Python 和 Node.js 指南里找到,Go 和 PHP 的循环写法也完全一致。
第 2 步的顺序很关键。先请求号码,再触发发送。如果你在号码激活存在之前就触发发送,这条短信没有落脚点,测试会因为一个和你代码毫无关系的原因而失败。
选择一个不浪费额度的轮询间隔
在 MarioSMS 上验证码通常一分钟内就会到达,所以有意义的轮询窗口是 60 到 180 秒,具体取决于服务。以 1 秒间隔轮询 120 秒,每次测试就是 120 次请求。跑 40 行测试矩阵,就是 4,800 次请求,只为了检测大约 32 条短信。
改成退避策略。一个可行的时间表:
| 已用时间 | 间隔 | 窗口内请求数 |
|---|---|---|
| 0-10s | 不轮询 | 0 |
| 10-40s | 3s | 10 |
| 40-120s | 5s | 16 |
| 120s 到计时结束 | 10s | 6 次或更少 |
这样每次测试大约 32 次请求,而不是 120 次,最坏情况下也只给一个通过的测试增加 5 秒延迟。前 10 秒实际上是空窗期,因为短信要先后经过发送方、聚合商和运营商,之后才可能被读取到。
当测试并行运行时,给每次 sleep 加上 200 到 500 毫秒的抖动。二十个 worker 卡在精确的 5 秒网格上轮询,会每隔 5 秒产生二十个同时发出的请求,在任何限流器眼里这都是一次突发流量。
安全地从短信文本中解析验证码
千万不要直接取正文里的第一段连续数字。真实短信里还有别的数字:短代码、年份、客服电话号码、退订编号。对 “Your code is 481920, valid for 10 minutes. Reply STOP to 40404” 做贪婪的 \d+ 匹配,可能返回 10 或者 40404。
改成基于长度和上下文来锚定:
- 匹配固定长度的分组:6 位验证码的服务用
\b(\d{6})\b,4 位的用\b(\d{4})\b。不要写一个正则同时兼顾两者。 - 在你知道短信模板的情况下,优先使用带引导语的正则,引导语匹配不上时再回退到纯长度匹配。
- 把每个服务的正则和验证码长度存在同一份 fixture 里,这样模板变了只需要改一行。
- 解析失败时把完整正文打进日志。一个只报 “no match” 而不给正文的失败测试,凌晨两点根本没法用。
- 当出现两个长度都正确但内容不同的候选时,直接判定失败。宁可大声报错,也不要提交错误的那个。
字母数字混合的验证码也是存在的。如果某个服务可能发送 A4K9QZ,你的正则需要的是字符类,而不是 \d。可以查看 OTP 术语条目,了解你可能遇到的各种格式。
处理收不到短信的情况,又不让测试挂死
每次激活都自带计时器。如果计时器到期前没有短信到达,激活会被取消,费用会自动退回你的余额,所以一次静默测试的成本是 $0。但你的测试仍然需要自己的截止时间。
把测试超时设得比激活计时器短,而不是更长。如果计时器是 20 分钟,就把轮询循环限制在 180 秒然后退出。等服务商侧过期,会把一个 3 分钟的测试套件拖成 20 分钟。
超时时要做三件事:显式取消激活;记录国家、服务和已用秒数;把结果标记为 no_delivery 而不是 failed。短信没到是一个投递信号,不是断言坏了。把两者都归为红色的测试套件,会失去发现某个国家突然静默的能力。
在两次运行之间清理状态
清理逻辑永远放在 finally 块里,包括断言在循环中途抛异常的情况。关闭或取消激活,别让任何东西一直挂着占用你的余额。
账号那一侧也需要清理。为一次验证租用的号码是一次性的,所以第 1 次运行创建的账号,在第 2 次运行租到另一个号码时就成了孤儿。要么在清理阶段通过你自己的 API 删除测试账号,要么给测试账号打上运行 ID 标签,然后定期批量清理。把号码、激活 ID 和账号 ID 一起存进本次运行的产物里,这样清理任务能同时找到两头。
绝不要缓存号码留到下次运行复用。租用只覆盖一次验证,而 fixture 里一个过期的号码会让测试基于错误的前提失败一整周,才有人去查。
Python、Node.js、Go 和 PHP 分别需要哪些代码示例?
四种语言,一套约定。如果你们组织里的每个客户端都暴露同样的三个方法,那么写过 Python 测试套件的测试人员,不用翻 API 文档就能读懂 Go 的套件。
验证码会同时出现在应用内和API返回中
四种语言共用的辅助接口
接口只定义一次,然后按语言分别实现。三个方法就覆盖了完整的租用生命周期:
一次激活:号码、窗口期、验证码、释放
| 方法 | 输入 | 返回 | 失败行为 |
|---|---|---|---|
rent(service, country) | 服务标识、ISO 国家代码 | 激活 ID、E.164 格式的手机号 | 缺货时抛异常,不扣费 |
wait_for_code(activation_id, timeout) | 激活 ID、秒数 | OTP 字符串 | 超时抛异常,激活自动取消并退款 |
release(activation_id) | 激活 ID | 无 | 幂等,调用两次也安全 |
两条规则保证实现不会走样。rent 绝不会只返回号码而不返回激活 ID,因为没有 ID 的号码是释放不掉的。wait_for_code 轮询时使用调用方传入的截止时间,而不是写死在辅助函数里的常量,这样慢速的印度线路和快速的美国线路就能用同一份代码、配不同的预算。
退款机制决定了错误处理的写法。号码激活如果在时间窗口内没收到短信,会自行取消,费用退回你的余额,所以 wait_for_code 超时不花一分钱。也就是说,激进的超时设置成本极低。设 90 秒,别设 10 分钟。
Python:requests 加 pytest fixture
用 requests.Session 并挂载 HTTPAdapter,把连接复用和重试策略集中在一处管理。把辅助函数包进函数级作用域的 pytest fixture,yield 出号码,在 teardown 块里释放,这样即便断言失败,激活也照样归还。
@pytest.fixture
def rented_number(sms_client):
act = sms_client.rent(service="telegram", country="us")
try:
yield act
finally:
sms_client.release(act.id)
轮询用 time.monotonic() 而不是 time.time(),否则运行途中系统时钟一调整,时间窗口就会被拉长或截断。完整的请求和响应结构见 Python 指南。
Node.js:fetch 加 Playwright
Node 18 及以上自带 fetch 和 AbortSignal.timeout(),不需要额外的 HTTP 库。每次轮询请求都带上 signal: AbortSignal.timeout(10_000),这样socket 卡住时 10 秒就会失败,而不会一直挂到整个测试套件超时。
在 Playwright 里,把租用放进 worker 级或 test 级的 fixture,清理逻辑注册在 fixture 的 teardown 里,而不是 test.afterEach。Playwright 的 fixture 按逆序拆解,所以在浏览器上下文之前创建的租用,会在上下文关闭之后才释放,而这正是应用仍持有会话时你想要的顺序。Node.js 指南演示了如何用 setTimeout promise 写轮询循环,而不是忙等的 while 循环。
Go:用 context 截止时间
Go 把超时约定摆在明面上。每个辅助方法都以 ctx context.Context 作为第一个参数,并把它传给 http.NewRequestWithContext。预算由调用方设定:
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
code, err := client.WaitForCode(ctx, act.ID)
轮询间隔用 time.NewTicker(3 * time.Second),并用 select 同时监听 ticker.C 和 ctx.Done()。在 defer 中释放时要用一个全新的 context,因为延迟调用执行时,父 context 早就失效了。这个小错误在大多数初版代码里都会导致释放悄无声息地失败。
PHP:Guzzle 加 PHPUnit
Guzzle 的 handler stack 支持中间件,把重试逻辑放在那里写一次,不用在每个调用点重复。在客户端配置里分别设置 timeout 和 connect_timeout,因为 DNS 卡顿和响应缓慢需要不同的上限。
在 PHPUnit 里,不要用 setUp 去租号码。在测试方法内部租用,并在 finally 块里释放;或者用 data provider 提供国家代码,让同一个测试主体覆盖美国和其他市场,无需重复代码。
各语言中的重试、退避和错误分类
三类错误,只有一类值得重试:
- 临时性错误(HTTP 429、502、503、连接重置)。用指数退避重试,起始 1 秒,上限 8 秒,最多 4 次。
- 终结性错误(该服务和国家组合缺货、余额不足、API key 无效)。立即失败,并在错误信息里打印出服务和国家。
- 空轮询(还没收到短信)。这不是错误。继续轮询,直到调用方设定的截止时间。
常见的 bug 是把空轮询当成临时性错误,塞进退避循环。退避会把轮询间隔推到 8 秒,于是第 41 秒到达的验证码要到第 48 秒才被读到,而那时断言早就跑完了。轮询保持 3 秒的固定节奏,退避只留给 HTTP 失败。
不同服务的测试差异在哪里?
一套测试在某个服务上跑通,却在另外四个服务上失败,通常说明里面藏着写死的假设:验证码是六位、短信 20 秒内到达、短信正文只有一行。这些假设在某些地方成立,在另一些地方就不成立。下面按类别列出那些会让粗糙的测试翻车的行为。
用于负载测试、库存号码数量最多的市场
| 国家 | 库存号码数 | 服务数量 | 起价 |
|---|---|---|---|
| United Kingdom | 37,001,905 | 189 | $0.02 |
| Italy | 24,353,864 | 118 | $0.03 |
| Austria | 12,315,289 | 105 | $0.04 |
| Canada | 11,766,481 | 37 | $0.05 |
| Indonesia | 8,380,976 | 45 | $0.04 |
| USA | 2,954,215 | 139 | $0.01 |
| Australia | 2,255,940 | 109 | $0.04 |
| Germany | 2,147,090 | 123 | $0.01 |
数据来源:MarioSMS 目录,2026-09-10。价格为单个号码价格,库存数已注明。
即时通讯类应用及其应用内验证码格式
即时通讯类应用在首次注册时会通过短信下发验证码,但其中不少还会在账号已经登录在另一台设备上时,通过自家的应用内通道下发同一个验证码。你的测试账号不会有那台设备,所以只按短信路径来规划,并且要预留应用延迟一段时间后回落到短信的时间。
选择你要测试的市场,行内显示库存和价格
这里有两个解析上的坑。第一,验证码往往夹在一个较长的句子里,后面还跟着一段安全提示,因此”取正文中第一串数字”这种粗糙的正则可能匹配到客服电话号码上。要把正则锚定到该发送方所使用的那个标签词上。第二,有些即时通讯类应用会为 Android SMS Retriever 附上一段隐藏的 app hash 后缀,也就是在验证码后面多出一个 11 个字符的令牌。比对之前要先把它剥掉。
即时通讯类应用的重新发送通常在首次发送后被限制一分钟以上,而且这个限制是按手机号计的,不是按会话计的。连点三次”重新发送”的重试循环,只会把自己的等待时间拉得更长。
社交与交易平台的注册流程
社交和交易平台的注册流程通常把手机验证放在邮箱验证、验证码图形挑战或资料填写之后,也就是说短信并不是你的测试要做的第一件事。请把前置步骤的耗时和 短信验证 断言分开计算,否则你为整个流程设定的超时,会在验证码请求还没发出去之前就用完了。
交易平台还有第二个坑。其中不少会在首次发布商品或首次发消息时再次要求验证,而不是在注册时。如果你的测试只覆盖注册,那第二道挑战就是没被测到的。把它写成一个独立的测试,并使用独立的号码,不要复用注册时的号码,因为第一次运行的那个激活已经关闭了。
网约车与配送类应用的短时间窗口
网约车和配送类应用是常见服务里验证码有效期最短的一类,往往只有 60 到 120 秒,而且其中有几家在输入框填满的瞬间就自动提交。这两点叠加起来,会狠狠惩罚轮询过慢的实现。如果你的轮询每次检查之间睡 5 秒,而司机端应用 60 秒就让验证码过期,那你一次睡眠就烧掉了窗口的 8%。
自动提交还会打破经典的 Selenium 写法:先往输入框里输入,再点击”提交”,因为这个点击落在页面跳转之后。正确做法是输入验证码,然后等待成功元素或”提交”按钮,哪个先出现就用哪个。
金融与钱包类应用更严格的校验
金融和钱包类应用对号码的校验最严。它们往往会拒绝已知的 VoIP 号码,要求号码归属国与账号所属国一致,并且在三次验证码错误后就锁定账号,而不是五次。有些还会把验证码绑定到设备指纹上,因此在一台机器上取到的验证码拿到另一台机器上输入会失败,报出的错误又很笼统,看上去就像验证码输错了。
测试这类服务时,请使用与账号资料同一国家的 非 VoIP 号码,并把账号锁定当作一等公民的预期结果,为它单独写一个测试,而不是等它在 CI 里出事之后才发现。
倾向于回落到语音来电的服务
有些服务在一两次短信失败之后会切换到语音来电,还有少数在某些国家默认就走语音。只读短信的测试看到的是一个空收件箱,于是报出一个”下发失败”,而实际上那是一次通道切换。
有两种应对方式。一是在界面上识别这种回落(按钮文案会变成”给我打电话”),把它作为一个合法分支来断言。二是当已知某个服务在某个国家倾向于走语音时,直接把这个组合从短信测试矩阵中剔除,而不是任由它每晚失败。在 MarioSMS 上,如果在激活窗口内没有短信到达,激活会自动取消,费用会自动退回你的余额,所以一个倾向语音的服务只会花掉你的时间,不会花掉你的钱。
验证码长度与时间窗口的服务对照表
验证码长度、有效期和重新发送行为因服务而异,而且会随时间变化。请在你自己的环境里实测,而不是相信一份静态清单,并把测得的结果记录成下面这样的表格。
| 类别 | 常见验证码长度 | 验证有效期 | 重发限制 | 测试要点 |
|---|---|---|---|---|
| 即时通讯类应用 | 6 位 | 数分钟 | 60 秒以上 | 剥掉 app hash 后缀 |
| 社交网络 | 5 到 6 位 | 数分钟 | 30 到 60 秒 | 发送前有图形验证码 |
| 交易平台 | 4 到 6 位 | 数分钟 | 60 秒 | 首次发布商品时有第二道挑战 |
| 网约车 / 配送 | 4 到 6 位 | 60 到 120 秒 | 30 秒 | 填满即自动提交 |
| 金融 / 钱包 | 6 到 8 位 | 很短 | 60 秒以上 | 国家需匹配,3 次错误即锁定 |
| 开发者工具 | 6 位 | 数分钟 | 30 秒 | 除短信外通常还支持 TOTP |
有效期那一列,请填入你自己实跑测出的秒数。每行有两个数字就足以确定轮询的截止时间:观测到的最短有效期,以及观测到的最长送达时间。如果两者之间的差距不足 10 秒,那这个服务就需要比测试套件里其他服务更快的轮询频率。
国家和运营商的差异如何改变测试方案?
一条验证流程并不是单一系统。它由你的代码、一家消息服务商、一条国际路由、一家目标运营商和一部手机的本地化设置组成,而当你更换国家代码时,这五者中有四个都会变。同一家服务商,4 秒就能把验证码送达美国号码的构建,发往印度号码时可能 90 秒都超时,而你这边不会报任何错。
号码格式、E.164 与本地拨号的怪癖
所有号码都用 E.164 存储(加号、国家代码、用户号码,不带空格和短横线),只在展示层做格式化。故障往往来自用户输入的内容和 E.164 所需格式之间的落差。
一个余额,一个统一的地方来限制测试套件的支出
- 前导的中继零。英国、德国和意大利的用户会输入
07911 123456。英国的 E.164 格式要求+447911123456,但意大利手机号会保留首位数字,所以一刀切地去掉零会把号码改错。 - 国内号码长度不固定。有些国家的手机号根据运营商号段是 8 到 11 位,因此写死
length === 10的校验会误拒有效号码。 - 国家代码冲突。+1 涵盖美国、加拿大以及 20 个左右的加勒比地区。把「仅限美国」的规则写成
startsWith('+1'),等于把它们全放行了。 - 粘贴带来的杂质。不间断空格、短破折号和 Unicode 数字会从密码管理器和通讯录里带进来。先做归一化,再做校验。
每个租用号码都要用三种输入形态测试:完整 E.164、选对国家的国内格式、以及选错国家的国内格式。第三种应当以一条可读的提示失败,而不是抛出 500。
发送方 ID 规则与字母数字发送方
From 字段在每个市场的监管方式都不同,而你的断言很可能依赖它。
| 市场类型 | 实际收到的内容 | 对测试的影响 |
|---|---|---|
| 允许字母数字发送方,无需注册 | 你的品牌名 | 回复退订的测试没有意义,发送方是单向的 |
| 字母数字发送方需预先注册 | 已注册的 ID,否则消息被丢弃 | 预发环境里未注册的 ID 会静默失败 |
| 禁止字母数字发送方 | 长号码或短代码 | 任何针对发送方文本的断言都会失效 |
| 动态路由选择 | 每次发送的号码都不同 | 永远不要断言某个确切的发送方值 |
从短信正文里解析验证码,绝不要从发送方解析。正则要写在消息文本上,并使用有边界的模式(\b\d{6}\b),对发送方只断言「存在且非空」。
运营商过滤与被拦截的内容
目标运营商会对短信内容做垃圾信息过滤,某些市场的过滤规则比其他市场严格得多。常见触发过滤的内容包括短链接、全大写单词、货币符号、多于一个 URL,以及未注册的发送方身份。结果通常是静默丢弃:服务商报告已受理,手机却始终没响。
准备两条测试:一条发送你线上使用的确切模板,另一条发送刻意设计成容易被过滤的变体。如果两条在 A 国都能收到,而在 B 国只有第一条能收到,那你发现的是模板问题,不是基础设施问题。消息形态的基础知识可参见 /glossary/sms-verification/。
挑选能覆盖大部分风险的五个国家
只要按不同维度而非市场规模来挑,五个就够了。
- 你用户量最大的国家,不管是哪个。
- 一个国内号码为 10 位、运营商过滤严格的国家,比如印度(/numbers/india/)。
- 一个美国以外的 +1 国家,用来抓出
startsWith('+1')之类的 bug。 - 一个带前导中继零的欧盟国家,用来检验你的归一化逻辑。
- 一个你的服务商走冷门路由的国家,这样投递变慢能在用户反馈之前先暴露出来。
美国(/numbers/united-states/)通常落在第 1 位或第 3 位。每次发版前跑完整套,每次合并只跑用户量最大的那个国家。
把价格和库存当作规划输入
MarioSMS 的号码价格低至 $0.04 起,按服务和国家浮动,覆盖 35 多个国家和数百种服务,均有现货。这带来两个规划上的结论。第一,价格排序告诉你哪些国家可以放开量跑、哪些只适合抽样,所以把高频回归放在便宜的一端,把五国矩阵固定成每周一次的节奏。第二,库存是按服务、按国家分别计算的,写死单一国家的测试套件会在这个组合缺货时挂掉。运行时读取库存,并回退到列表中的下一个国家。
每个号码只处理一次验证,带有倒计时,如果没有收到短信,激活会自动取消,费用自动退回你的余额。因此投递失败的测试不花钱,这让在慢速市场做负面测试变得可以承受。
时区对投递和测试排期的影响
运营商队列在当地清醒时段最繁忙,某些市场在高峰期对营销类和交易类流量的限速方式也不一样。一个 09:00 UTC 的每夜任务,打到印度是下午,打到美国西海岸是凌晨 02:00,于是你的延迟数据描述的是两种完全不同的网络状况,取平均后毫无意义。
在每次测量的同时记录目标地的当地小时数。然后为每个国家安排两次运行,一次在当地 10:00,一次在当地 22:00,比较两者的 p95 投递时间。差距超过 15 秒,说明你的线上超时时间应该按繁忙时段来设定,而不是按空闲时段。
每套 OTP 测试都应该覆盖哪些失败场景?
一个验证流程大概有六个活动部件,而每一个出问题时从外面看都很正常。登录成功了,仪表盘加载出来了,测试也是绿的。下面这些失败场景,恰恰是只跑正常路径的测试抓不到的,所以每一条都需要针对失败本身写测试,而不是针对成功。
后端接受了已过期的验证码
大多数团队会在配置里设一个 TTL(常见是 5 分钟或 10 分钟),然后就再也没验证过这个检查到底有没有生效。验证码落在一张带 expires_at 字段的表里,如果查询条件只有 code = ? 和 user_id = ?,漏掉了时间戳,那么一个旧验证码就能永远用下去。
写一个测试:先请求验证码,然后把时钟冻结或快进到 TTL 之后,再提交这个验证码。断言返回 HTTP 400 或 401,错误体为 code_expired。同时断言这条记录要么被删除、要么被标记,让第二次调用同样无法成功。如果你的测试框架控制不了服务端时间,可以直接插入一条 expires_at 已经是过去时间的记录,再通过公开接口提交它。
登录成功后验证码仍可重复使用
验证码应该是一次性的。常见的 bug 是 verify 处理函数直接返回了会话,却没有把验证码标记为已消费,于是同样的六位数字在接下来四分钟里能用三次。当这条短信留在通知历史或共享收件箱里时,这就成了问题。
在同一个测试里断言两次。第一次调用返回 200 和一个会话令牌。第二次用完全相同的入参调用,返回 400,且不返回令牌。然后断言第二次调用没有创建第二条会话记录,因为有些实现虽然拒绝了验证码,却仍然从缓存路径里签发了令牌。
没有尝试次数上限的暴力破解
六位数字是一百万种组合,听起来挺安全,直到你算一算攻击者实际需要多少次。在 10 分钟窗口内且没有次数上限的情况下,一个每秒 50 次请求的脚本远在过期之前就能跑完整个空间。修复办法是给每个验证码加一个尝试计数器,通常是 3 到 5 次,再加上按手机号维度的限流。
让测试循环提交错误验证码直到配置的上限,然后提交正确的验证码,断言它同样被拒绝。如果 5 次错误后锁定了,但第 6 次用正确验证码还能通过,那这个锁定毫无意义。
重发与验证之间的竞态
用户在第 29 秒点了重发,第一条短信在第 30 秒到达,然后他们输入了第一个验证码。这能不能通过,取决于重发是否会让旧验证码失效,还是两个验证码同时有效。两种策略都说得通,但测试必须钉死你选的是哪一种。
用两个线程并发地发出重发请求和第一个验证码的验证请求。断言结果有且只有一种。如果旧验证码仍然有效,就断言两者各自用自己的验证码都能成功,且从未签发第三个验证码。如果重发会让旧码失效,就断言第一个验证码返回 code_superseded。相关内容:租用号码上的号码激活窗口与你的应用 TTL 是各自独立结束的,所以一个跑得足够久、会同时撞上两者的测试,需要能区分这两类失败。
通过不同错误提示实现的枚举
注册返回「号码已被使用」,登录返回「未找到账号」。两种不同的文案,于是任何人拿着一份号码清单,都能把它们分成客户和非客户两堆。响应时间也会泄露同样的信息:已注册的路径要跑一次密码哈希,未注册的路径则立即返回。
断言已注册号码与未注册号码之间状态码相同、响应体逐字节一致。时间方面,每个分支跑 50 次请求,断言中位数差值小于 50 毫秒。未注册的一侧要用一个全新租用的号码来测,这样状态才是真正干净的;同时用美国和一个非美国的国家各测一遍,因为有些后端会按国家代码走不同分支。
关于 SIM 卡交换和号码回收的假设
运营商会在停机一段时间后回收号码,而 SIM 卡交换能在几分钟内把一个号码转到新设备上。如果你的应用把手机号当成永久身份,那么这两种情况都会把账号交到陌生人手里。
要测的是找回流程,而不是注册流程。断言仅凭一个已验证的号码、在没有第二因子的情况下无法重置密码;断言在已有账号上发生的重新验证事件会写入一条审计记录。断言更换账号绑定的号码会使活跃会话失效,并向原来的联系方式发送通知。
服务商静默失败却返回 200
短信网关接收了消息,返回 200 和一个消息 ID,然后在运营商那一端把它丢了。你的发送接口报告成功,你的测试也通过了,因为它只检查了 API 响应。这个缺口最后会以工单的形式浮现。
永远不要只对发送响应做断言。要对真实收件箱里实际读到的验证码做断言,而这正是租用号码能提供的。然后断言你的送达回执处理逻辑在 120 秒内记录了一个终态状态,并且回执缺失时会抛出异常,而不是被当成已送达写进去。
失败场景与断言对照表
| 失败场景 | 测试中的触发方式 | 能抓住它的断言 |
|---|---|---|
| 接受已过期验证码 | 将时钟快进到 TTL 之后 | 400 code_expired,记录已消费 |
| 验证码重复使用 | 用同一验证码验证两次 | 第二次返回 400,没有第二个会话 |
| 无尝试次数上限 | N 次错误验证码,然后一次正确的 | 达到上限后正确验证码也被拒绝 |
| 重发竞态 | 并发的重发与验证 | 有且只有一种已写明的结果 |
| 枚举 | 已注册号码 vs 未注册号码 | 响应体一致,中位数差值低于 50 毫秒 |
| SIM 卡交换 | 在已有账号上重新验证 | 写入审计记录,会话失效 |
| 服务商静默失败 | 发送后读取真实收件箱 | 从短信中读到验证码,120 秒内收到回执 |
表里每一行的成本是一个租用号码,$0.04 起,而没有收到任何内容的激活会自动退款,所以把这七项全部跑一遍,整套测试单次完整运行的花费不到一美元。
短信一直收不到,该怎么排查?
验证码没到,通常有五种可能,而且它们分布在不同的环节:客户端根本没发出去、服务商拒绝了、运营商丢弃了、目标号段有问题,或者号码本身就是废号。按顺序排查,每一环都先拿到一份证据再往下走。瞎猜的成本比逐项核实高得多。
第一步,确认请求确实离开了客户端
在怪罪下游之前,先证明 App 真的发出了这次调用。打开这次测试的网络日志,找到发往你的发送接口的 POST 请求。你需要确认三件事:请求发出去了、里面带的手机号格式和你预期的一致、返回的是 2xx。
这一环常见的问题都挺乏味。客户端的某条校验规则悄悄吞掉了提交。号码存成了 07700 900123,发出去时没带国家码。之前某次测试留下的重试保护,让按钮又禁用了 60 秒。过期的鉴权 token 让发送变成了 401,而界面上只显示了一个普通的加载动画。
需要留存的证据:UTC 格式的请求时间戳、实际发出的 E.164 字符串、HTTP 状态码、响应体,以及客户端的构建号。
第二步,检查服务商的响应和消息 ID
你自己后端返回 2xx,不代表服务商接收了这条短信。去看服务商针对这次发送返回的响应内容,有两种结果值得关注。
如果拿到了 message ID,说明服务商已经把它排进队列了。把这个 ID 记下来。它是唯一能把你的日志和运营商日志串起来的钥匙,没有它,找客服沟通根本推进不下去。
如果返回的是错误,要看错误码,而不是那段给人看的文字说明。服务商会用不同的错误码来区分前缀不可路由、目标国家被屏蔽、发送方 ID 未在该国注册、余额不足、触发限流。每一种对应的解决办法都不一样,而其中三种的提示文字往往是一模一样的。
第三步,读取送达回执状态
回执(DLR)是运营商给出的答复。状态通常在 5 到 30 秒内确定,如果 120 秒后回执还卡在 queued,这本身就是一条线索。
| 状态 | 含义 | 下一步动作 |
|---|---|---|
queued / accepted | 服务商还压着,运营商未确认 | 等 120 秒,之后按路由问题处理 |
sent | 已交给运营商,但没有确认回来 | 确认这条路由到底会不会返回回执 |
delivered | 运营商确认已送达手机 | 问题出在读取环节或收件箱,而不是发送 |
undelivered | 运营商先接收,随后丢弃 | 过滤、发送方 ID 或内容的问题 |
failed | 直接被拒 | 号码有误、号段被封或 SIM 卡失效 |
如果回执是 delivered 却看不到验证码,那说明问题出在你的轮询逻辑、解析器或时间窗口上,而不是短信链路。在把问题归到送达之前,先自己去读一遍收件箱的原始内容。
第四步,换一个国家或另一个服务测试
单条路由失败说明不了问题的范围。把同样的内容发到第二个目的地,然后做对比。如果美国号码20 秒就收到了,而印度超时,那就是这条通道上的路由或过滤问题,而不是模板坏了。如果两边都失败,问题就在上游,出在你自己的代码或服务商账户上。
按服务再做一次同样的对照。如果你自己 App 的验证码能到,而某个第三方服务的验证码到不了,那就是对方在过滤或限流,得由他们那边来解决。
第五步,换号码,只重试一次
单个号码是会出问题的。SIM 卡被回收、某个号段被某个特定发送方拉黑、运营商对某一整块号码停掉了 A2P 流量。租一个新号码,把完全相同的发送流程再走一遍。
在 MarioSMS,每个号码一次只用于一次验证,价格低至 $0.04;如果在激活时间窗内没收到短信,激活会自动取消,费用自动退回你的余额,所以再失败一次的重试是不花钱的。两个不同号码连续两次失败,就有力地说明是路由或服务的问题。一次失败接着一次成功,说明问题出在第一个号码上,记录下来,然后继续往前走。
只重试一次,不要重试五次。一分钟内向同一个目的地反复发送会触发防轰炸规则,把原本清晰的信号变成噪音。
记录哪些字段,能让下次排障更快
这些字段要在发送时就记下来,而不是等出了问题再补:
request_id,来自你自己的后端,并透传到客户端。provider_message_id,原样保存服务商返回的值。msisdn_e164,做哈希处理或只保留后 4 位。country_iso2和service_key。sent_at_utc,精确到毫秒。dlr_status以及dlr_received_at_utc,原地更新。activation_id,来自号码租赁服务商,这样号码和发送记录能落在同一行上。attempt_number,以及它所替代的上一次尝试的 ID。
有了这八个字段,一半以上的短信故障只要读一行记录就能定位,不用再靠时间戳去关联四套系统。Python 和 Node.js 示例在租号时都会返回激活 ID,所以第 7 项只需多写一行赋值。
如何在不烧掉预算的前提下对 OTP 流程做压力测试?
给每个虚拟用户都租一个真实号码的压测,那不是测试,那是账单。按每个号码 $0.04 算,模拟 5,000 次注册一轮就要 $200,如果这套测试在每次合并到 main 时都跑,一天就得付四次。正确的做法是把两件事分开测,因为它们出问题的原因根本不一样。
OTP经历的各个阶段
| 阶段 | 负责方 | 典型耗时 | 此处可能出现的问题 |
|---|---|---|---|
| 请求已受理 | 你的后端 | 200毫秒以内 | 限速、校验、重复提交 |
| 消息已排队 | 你的短信服务商 | 1秒以内 | 路由选择、发送方ID被拒 |
| 运营商已接收 | 运营商 | 1到10秒 | 过滤、发送方被屏蔽、灰色路由 |
| 已送达手机 | 网络 | 2秒到数分钟 | 漫游、拥塞、设备关机 |
| 验证码已输入 | 用户 | 数秒到数分钟 | 过期、重试、自动填充 |
你的日志通常只记录到第二阶段,这正是真实号码测试能发现而监控系统会遗漏问题的原因。
把压力测试和送达测试分开
吞吐量测试回答的是:你的 API 能不能扛住每秒 500 次发送请求,涉及连接池、数据库写入争用、队列深度,以及限流器本身。送达测试回答的是:发往印度某台真实手机的验证码能不能收到、内容对不对。前者需要量,不需要运营商;后者需要真实号码,几乎不需要量。
| 测试类型 | 单轮请求量 | 真实号码 | 成本来源 | 运行时机 |
|---|---|---|---|---|
| 吞吐量 | 1,000 到 100,000 次请求 | 否 | 仅计算资源 | 每次合并 |
| 限流 | 50 到 500 次请求 | 否 | 仅计算资源 | 每次合并 |
| 送达抽样 | 5 到 30 次激活 | 是 | 按激活计费 | 每晚 |
| 发版卡点 | 20 到 60 次激活 | 是 | 按激活计费 | 上线前 |
在边界处模拟服务商
接缝要留在你自己的短信客户端接口上,而不是 HTTP 库那一层。一个接口,带 send(to, body) 和 fetchCode(activationId) 两个方法,就能让你既有一个 0 毫秒返回固定验证码的假实现,又有一个真正对接服务商的真实实现。在压测场景下,假实现也要模拟延迟和失败:带抖动地睡眠 200 到 900 毫秒,2% 的调用返回 429,0.5% 返回 500。一个永远在 1 毫秒内返回 200 的 mock,会把你压测本来就想抓的超时和重试风暴全部藏起来。
用合成号码测试限流
限流逻辑的键是手机号字符串、IP 和设备指纹。这三样背后都不需要一个能真正租到的号码。在服务商永远不会分配的号段里生成合法的 E.164 字符串,对同一个号码在 60 秒内发 60 次请求,然后断言第 4 次返回 429 并带上 Retry-After 响应头。同样地,用同一个 IP 对 500 个不同的合成号码发请求,检查 IP 维度的限流桶。给这些记录打上 synthetic=true 标记,免得有人为一个压根没租过的号码去排查”短信怎么没收到”。
用低频抽样验证真实送达
真实激活应该放在每晚的定时任务里,而不是每次提交都跑的测试套件里。抽样要覆盖真正会变化的维度:3 到 5 个国家、你排名前 3 的服务,再加一个重复尝试的场景。每晚十二到二十次激活,就能发现发送方 ID 失效、或者某个国家不再能收到某个服务的短信,这些是任何 mock 都抓不到的。由于每个租用号码都带有一个激活窗口,没收到短信时会自动把费用退回你的余额,所以某天晚上某条线路挂掉,几乎不会产生成本。直接复用你现有测试套件里的 Python 或 Go 轮询循环,让这个夜间任务只是换了套配置,而不是另写一套代码。
估算送达抽样的成本
先把账算清楚,再去排期。用每轮激活次数乘以每个周期的轮数,再乘以对应服务和国家的价格。价格从 $0.04 起,各有不同,所以要按你测试矩阵里最高的价格算,而不是最低的。
| 频率 | 每轮激活次数 | 每月轮数 | 按 $0.04 算 | 按 $0.25 算 |
|---|---|---|---|---|
| 每晚抽样 | 15 | 30 | $18.00 | $112.50 |
| 每周深度矩阵 | 60 | 4 | $9.60 | $60.00 |
| 每次发版卡点 | 25 | 8 | $8.00 | $50.00 |
激活失败的退款会让实际支出低于上面这些数字。但预算还是按上限来留,然后把充值提醒设在上限的 40%。
防止测试循环失控的护栏
- 在代码里限制单次进程运行的激活数量,比如 40 次,计数器一旦触顶就强制退出。
- 限制每个 CI 项目每小时的激活数量,用共享计数器来校验,而不是本地变量。
- 如果账户余额低于本轮的预估上限,就拒绝启动,避免跑到一半留下半个未测完的矩阵。
- 设置 15 分钟的挂钟时间强杀,因为卡住的轮询循环租号码的速度比正常跑还快。
- 在
finally块里释放每一次号码激活,这样即使断言抛出异常,也不会让某个租用一直占着窗口。 - 触发护栏时直接让构建失败,而不是悄悄跳过,因为静默跳过看起来就像一个通过了的送达测试,而它其实压根没跑。
如何在生产环境中监控 OTP 送达情况?
CI 测试套件只能证明流程在提交代码那一刻是通的。生产环境的监控才能证明它在周二凌晨两点依然可用,哪怕某个国家的运营商开始悄无声息地丢弃来自某个 sender ID 的流量。这两者之间的空白地带,正是工单诞生的地方,而解决办法就是一小组指标,配上你真的愿意为之呼叫值班的告警阈值。
每个市场的最低测试矩阵
| 维度 | 需覆盖的取值 | 原因 |
|---|---|---|
| 国家 | 每一个上线市场 | 格式、区号和过滤规则各不相同 |
| 号码状态 | 全新、曾使用过、刚释放 | 重复处理逻辑 |
| 时间 | 立即、临近过期、过期后 | 窗口期逻辑 |
| 尝试次数 | 首次、重试、超出限制 | 锁定行为 |
| 渠道 | 短信、语音回退(如有提供) | 回退路径 |
最重要的四个指标
按小时跟踪四个数字,并按国家和服务商拆分。其余的一切都只是下钻分析。
| 指标 | 定义 | 为什么会波动 |
|---|---|---|
| 发送受理率 | 调用短信服务商 API 返回成功状态的次数 / 总发送尝试次数 | 服务商故障、凭证过期、号码格式错误 |
| 送达率 | 状态报告标记为已送达的数量 / 已受理的发送量 | 运营商过滤、sender ID 报备失效、被判定为垃圾信息 |
| 首条验证码耗时(p95) | 从发送请求到收到回执事件的秒数 | 聚合商队列积压、线路切换 |
| 验证完成率 | 提交了正确验证码的用户 / 请求了验证码的用户 | 以上任何一项,外加 UX 问题和输错率 |
发送受理率和送达率回答的是两个不同的问题。服务商可以受理你 100% 的请求,却只送达其中的 60%,而只有状态报告能告诉你这一点。
按国家和运营商拆分送达率
汇总后的送达率会掩盖真正要命的故障。假如你 85% 的流量是美国号码,送达率 97%,而 6% 来自某家印度运营商,送达率只有 40%,那么全局数字看起来是 93%,没人会察觉。先按国家分桶,再按运营商或者服务商能提供的 MCC-MNC 分桶,然后针对分桶而不是总量设置告警。
在允许某个分桶触发告警之前,先设一个最低量级门槛。一小时内 10 条发送、送达率 70%,那是噪声。50 条发送、送达率 70%,而 14 天基线是 96%,那就是线路出问题了。把每个分桶和它自己的历史基线比较,因为一个平时就在 88% 的国家,不该因为今天还是 88% 就把你呼起来。
首条验证码耗时要看分位数,不要看平均值
首条验证码耗时的平均值几乎毫无用处。大多数验证码 10 秒内就到了,所以少数几条 90 秒才送达的短信对均值影响甚微,却彻底毁掉了尾部那批用户的体验。要分别跟踪 p50、p95 和 p99。
盯住 p95 和你的重发按钮倒计时之间的关系。如果重发按钮 30 秒后解锁,而 p95 爬到了 34 秒,那你刚刚制造了一波重复发送、翻倍的支出,以及一条会让用户正在输入的第一条验证码失效的第二条验证码。这是一个送达问题,但它以一张抱怨“验证码不对”的工单形式出现。
验证完成漏斗
埋点记录五个步骤,并把计数存成一个漏斗:
- 用户提交手机号。
- 服务商受理发送请求。
- 收到状态报告。
- 用户输入验证码。
- 后端校验通过。
第 3 步到第 4 步的流失属于注意力和 UX 问题,包括自动填充是否生效。第 4 步到第 5 步的流失则是验证码到得太晚、用户从更早的一条短信里抄了验证码,或者在你同时支持双因素认证应用的情况下,TOTP 链路上出现了时钟偏差。把这两段流失拆开,能让移动端团队和消息团队不必围着一个混在一起的数字互相扯皮。
告警阈值,以及什么该呼叫值班
只为人能在接下来 30 分钟内解决的问题呼叫值班。其余的一律开工单。
| 信号 | 阈值 | 处理方式 |
|---|---|---|
| 发送受理率 | 低于 95% 持续 10 分钟 | 呼叫值班 |
| 任意每小时发送量 50 条以上国家的送达率 | 连续 2 个窗口低于其 14 天基线 15 个百分点 | 呼叫值班 |
| 首条验证码耗时 p95 | 高于重发倒计时持续 15 分钟 | 呼叫值班 |
| 验证完成率 | 环比上周下降 10 个百分点 | 开工单 |
| 单个运营商送达率 | 低于 60% 持续 2 小时 | 开工单 |
| 服务商 webhook 延迟 | 回执到达时已超过 5 分钟 | 开工单 |
送达率告警要求连续两个窗口才呼叫值班。运营商会出现短暂的、能自行恢复的下跌,而单窗口触发只会训练值班的人无视这条告警。
一套 20 分钟的每周复盘流程
- 打开按发送量排序的送达率表格,看前 10 个国家,记下任何波动超过 5 个百分点的分桶。
- 对比各国首条验证码耗时的 p95 与上周的差异。
- 读一遍完成漏斗,找出最大的单段流失。
- 给每个有问题的国家租一个号码,手动跑一遍完整流程,使用临时手机号,这样一次检查只花几分钱,而不是升级成一次客服事故。
- 为最糟糕的那个分桶开一张工单,附上国家、运营商、状态报告样本和时间戳。
第 4 步是各团队最常跳过的一步。用真实号码在可疑线路上跑真实流程,能把“巴西的送达率看起来不太妙”变成“验证码 45 秒才到,而我们的重发倒计时是 30 秒”,后者是一句可以动手修的话。
如何让 OTP 测试在 CI 中保持稳定?
只要一套 OTP 测试用到真实号码和真实运营商,它就永远不可能像单元测试那样确定。目标不是把波动降到零,而是让流水线里的红灯真正说明你的代码出了问题,而不是凌晨三点某个运营商队列卡住了。要做到这一点,就得按成本和不稳定程度拆分测试,再输出足够的上下文,让打开失败报告的人两分钟内就能动手。
值得明确测试的故障模式
| 故障 | 如何复现 | 正确行为 |
|---|---|---|
| 完全没有短信 | 让激活窗口期过期 | 提供重试选项,不产生任何费用,不出现半创建状态的账户 |
| 短信延迟到达 | 验证码在你的超时之后才到达 | 要么接受它,要么清楚说明为什么不接受 |
| 重复请求 | 连续点击两次发送 | 只生成一个验证码,而不是两个相互冲突的验证码 |
| 反复输错验证码 | 输入四次错误验证码 | 锁定按号码计数,提示信息说明应如何处理 |
| 号码重复使用 | 用已存在的号码注册 | 触发你的合并或拒绝逻辑,绝不能出现堆栈跟踪报错 |
| 国家不匹配 | 来自不受支持市场的号码 | 提示信息中列出所支持的市场 |
哪些测试应该跑在每个 pull request 上
Pull request 的流水线应该在 10 分钟内跑完,而且每次运行不产生任何费用。以下这些放进每个 PR:
- 针对 mock 短信服务商的契约测试,验证你的代码发出的请求结构正确,解析的验证码格式也正确。
- 验证会话的状态机测试:已创建、验证码已发送、验证码已输入、已验证、已过期、已锁定。
- 用假时钟测试限流和重发倒计时逻辑,让 30 秒的重发窗口在 3 毫秒内测完。
- 验证码校验的边界情况:错误的验证码、过期的验证码、重复使用的验证码、带空格的验证码、上一个会话的验证码。
- 每个服务商接口保留一份录制的 HTTP fixture,每周刷新一次,这样接口结构变化就会以 diff 的形式暴露出来。
这些测试都不需要租号码。如果某个 PR 改动的正是短信适配器本身,那就加一条真机冒烟测试,用标签控制,让贡献者按需触发。
哪些测试应该放进夜间任务
真实送达测试属于定时任务,不该跟着每次 push 跑。一个夜间任务租 6 到 10 个号码,按每个 $0.04 起算也就花几毛钱,却能每天给你一个信号,覆盖你真正在用的那些线路。
| 测试类型 | 触发方式 | 是否使用真实号码 | 单次运行典型数量 |
|---|---|---|---|
| Mock 契约测试 | 每个 PR | 否 | 40 到 200 |
| 单国家真机冒烟 | 合并到 main | 是 | 1 |
| 主要国家真机矩阵 | 每晚 | 是 | 6 到 12 |
| 全国家覆盖扫描 | 每周 | 是 | 30 到 60 |
| 压力与吞吐 | 按需 | 部分 | 不定 |
给夜间矩阵排序时,把量最大的市场放在最前面。这样即便任务被中途掐断,你至少已经知道了美国和印度的送达情况。
安全地保管 API 密钥和余额上限
把 API 密钥放进 CI 的密钥库,绝不要放进仓库,也绝不要放进 fixture 文件。三条规则能挡掉大部分事故:
- CI 用的密钥和生产监控用的密钥分开,这样吊销其中一个不会让另一个也失明。
- 在日志输出中对密钥打码,任何回显手机号的响应体在进入构建日志前先做清洗。
- 用余额来兜底,而不是靠信任。CI 账户只保持一个小额的可用余额,按计划充值,这样跑飞的循环会卡在余额上,而不是整晚不停地跑。
在矩阵启动前加一个预检步骤,检查余额,如果低于本次运行的预计成本就立刻失败并给出清晰提示。一个 5 秒内就报出「余额 $0.31,本次运行需要 $0.90」的构建,好过跑到一半才抛出 14 条让人摸不着头脑的错误。
隔离不稳定的送达测试
运营商延迟是真实存在的,那不是你的 bug。用策略来处理它,而不是到处加重试:
- 短信等待时间要基于实测数据设定,不要靠拍脑袋。如果到达时间中位数是 12 秒、p95 是 48 秒,那就等 90 秒。
- 真机测试最多重试一次,而且只在超时的情况下重试,验证码断言失败时绝不重试。
- 激活超时且没有收到短信时,按「结果不明确」处理,而不是判定失败。租用的号码会被取消,费用退回余额,所以重试只多花一次激活的钱,而不是两次。
- 统计每个测试在 14 天内的不稳定率。超过 10% 的挪到隔离任务里,照跑照报,但不阻塞流水线。
- 每周复查隔离区。在里面待了一个月的测试,要么对应一个真实的产品 bug,要么对应一条你该停止支持的线路。
让失败结果可以直接行动
失败信息才是这里真正的产出。每条真机测试失败都应该打印出服务、国家、激活 ID、号码租用时间、等待超时的时间点,以及经过的秒数。如果收到了短信但解析失败,把原始短信内容附上,数字打码。结果以 JUnit XML 格式输出,让 CI 按国家分组,每次夜间运行往团队频道发一行汇总,内容是数字,不是形容词。
一个流水线布局示例
jobs:
unit: # every PR, mocked, ~4 min
contract: # every PR, recorded fixtures
smoke-live: # merge to main, 1 number, US
matrix-live: # nightly cron, 8 numbers, 6 countries
quarantine: # nightly, non-blocking, reports only
把所有真机任务放在同一个文件里,共用一个 setup 步骤:租号码、轮询面板或 API 取验证码、结束时释放会话。轮询的辅助代码和你应用里用的是同一份,所以同样的 Python 接收短信或 Node.js 示例,只要把等待常量调大,就能直接当作 CI 的 fixture 使用。
团队该如何共享号码、权限和预算?
当四个工程师各自维护个人余额、没人清楚哪次运行花了多少钱时,测试号码的管理很快就会变得一团糟。解决办法是:一个共享账户、按环境划分密钥,再加上一条命名规则,让每个租用的号码都能追溯到发起请求的测试套件。
每次验证尝试应记录的内容
| 字段 | 重要性 |
|---|---|
| 请求ID | 将该次尝试与服务商日志关联起来 |
| 国家和区号 | 显示哪些市场出现故障 |
| 服务商和路由 | 比较不同厂商之间的送达情况 |
| 首次输入验证码所需时间 | 真实的用户可感知延迟 |
| 结果 | 已送达、已过期、验证码错误、已放弃 |
按国家和小时汇总这些数据;某个国家的数据突然变得稀少,往往说明出现了送达问题。
一个账户,按环境分配密钥
创建一个由团队持有的 MarioSMS 账户,而不是挂在某个随时可能离职的个人名下。然后为本地、CI 和预发布环境分别签发 API 密钥。余额共用,密钥各异,这样吊销其中一个不会影响另外两个。
| 密钥 | 使用方 | 典型用量 | 轮换周期 |
|---|---|---|---|
local | 开发者笔记本 | 每天 1 到 5 个号码 | 员工离职时 |
ci | 流水线运行器的密钥变量 | 每天 10 到 40 个 | 每季度 |
staging | 定时冒烟测试任务 | 每天 4 到 8 个 | 每季度 |
load | 吞吐量实验 | 突发用量,之后闲置 | 每次实验后 |
密钥要存放在密钥管理器里,绝不能提交到代码仓库。本地密钥放进 .env 文件并写入 .gitignore,CI 密钥则设为受保护变量,避免在任务日志中被打印出来。
按测试套件跟踪支出
号码起价 $0.04,具体价格因服务和国家而异,所以每晚租用 30 个号码的套件,和在高价国家租用 30 个号码的套件,花费并不相同。要在租用时就记录成本,而不是等到月底再算。
- 让租号辅助脚本为每次激活写一行记录:时间戳、套件名称、服务、国家、价格、激活 ID。
- 把这些记录发送到存放测试产物的同一位置(一开始,在任务输出里生成一个 CSV 就够了)。
- 每周按套件汇总,并与仪表盘上的余额下降情况做对比。
- 对任何环比增长超过 20% 的套件展开排查。
退款在这里很关键。如果在激活时限内没有收到短信,激活会自动取消,费用也会自动退回余额,因此除非你把取消的记录标注出来,否则记录的支出会高于实际成本。加一列 refunded,每次运行后做一次对账。
用银行卡或加密货币充值,以及何时充值
余额可通过银行卡或加密货币充值。设定一条能覆盖用量最大套件整整一周的下限,余额跌破时就充值。如果每晚运行要用 40 个号码、平均单价约 $0.10,一周大约是 $28,那么 $50 的下限既能留出充足余量,又不会积压用不上的资金。
设个日历提醒吧,别等到凌晨两点 CI 里租号失败才发现。余额见底会让流水线变红,而在有人去查仪表盘的那二十分钟里,它看起来就像是应用本身出了 bug。
QA 与工程之间如何交接一次运行
当 QA 发现验证流程出问题时,工程师需要四样东西才能复现:国家、服务、激活 ID 和短信的完整内容。把这些设为工单模板里的必填项。激活 ID 是关键锚点,因为它能把这次运行对应到某个具体租用的虚拟手机号及其计时器。
应用界面的截图有帮助,但原始短信正文更有用,因为发送方 ID 和验证码长度会随线路不同而变化,而这种差异往往正是问题所在。
记录用了哪些号码,以及为什么用
在仓库里维护一张普通表格,由租号辅助脚本自动更新,而不是手工填写。列包括:日期、套件、国家、服务、激活 ID、结果(收到验证码、超时、已退款)。保留 30 到 90 天后删除,因为这属于用完即弃的运维数据,长期保留只会带来不必要的隐私麻烦。每个号码同一时间只用于一次验证,所以日志始终保持一次尝试一行。
请仅将其用于隐私保护、团队合法拥有的第二账户,以及 QA 或 OTP 测试,具体请参见可接受使用规则。
用于手动测试的 iOS 和 Android 应用
自动化能覆盖重复场景,但探索性测试仍然靠手工完成。iOS 应用、Android 应用以及 app.mariosms.com 上的网页版显示的是同一份余额和同一份激活列表,所以测试人员用手机就能租号、等验证码到达(通常一分钟内),再粘贴到被测应用里,全程不用碰终端。从 35+ 个国家的现有库存中选择国家,点击租用,这次激活就会出现在共享仪表盘上,团队其他成员都能看到。
验证测试涉及哪些隐私与法律边界?
租用号码是测试工具包中很常见的一部分,它和团队经手的其他任何凭据一样,需要承担同等的责任。这些边界并不复杂,但必须写在某个地方,让你的 QA 工程师、外包人员和新入职的同事在租用第一个号码之前就能读到。
生产环境中应设置告警的指标
| 指标 | 健康状态 | 告警触发条件 |
|---|---|---|
| 验证码输入率 | 按国家和小时保持稳定 | 与上周同一小时相比下降三分之一 |
| 输入耗时中位数 | 数十秒 | 翻倍 |
| 已过期的激活 | 占比小且稳定 | 某个国家出现激增 |
| 重试率 | 较低 | 在没有发布新版本的情况下持续上升 |
隐私保护是核心使用场景
大多数团队开始租用号码,是因为不想让个人手机号出现在测试数据里。一位 QA 主管用个人手机号注册了十二个预发布环境账号,这个号码就留在了十二个数据库中,其中一些会被导出,一些会被备份到没人追踪的地方。营销短信随之而来。如果这些系统中有任何一个被攻破,密码重置请求也会接踵而至。
租用号码切断了这条链路。它完成一次验证,激活任务结束,号码回到号码池。测试账号与真人的联系方式之间不会留下任何关联。同样的逻辑也适用于测试竞品注册流程的创始人,或是复现客户问题的技术支持工程师:他们需要的是一个能用的号码,而不是自己的号码。
出于正当理由的第二个账号
有大量正当工作需要不止一个账号。社交媒体运营既要管理品牌账号,也有自己的个人账号。开发者会在同一个平台上把沙盒账号和生产账号分开。技术支持团队需要一个客户视角的账号,以便看到用户看到的界面。本地化测试人员需要一个在目标市场注册的账号,这通常意味着需要该国家的号码,比如印度号码或美国号码。
请查看你所注册服务的条款。有些服务完全允许多账号,有些在存在业务合作关系时允许,有些则加以限制。号码从哪里来,并不会改变这些条款的内容。
在自有或已获授权的系统上做 QA 与 OTP 测试
最清晰的情形是测试自己的产品。发送方、验证码格式、重试策略和账号生命周期都由你掌控,因此除了运营商链路之外,整个测试不会触及任何第三方。把测试账号写入预发布数据库,打上标签,并按计划定期删除。
测试他人的系统时,先取得书面授权。一份签署的渗透测试范围文件、一份明确列出系统名称的客户合同,或是一个有公开政策的漏洞赏金项目,都算数。来自并不拥有该系统的人的口头许可则不算。OTP 接口上的频率限制和滥用检测的存在自有其道理,狂轰滥炸一个你未被邀请测试的接口就是问题,无论号码从哪里来。
绝对不可接受的行为
有两件事永远越界。第一是重新登入一个你已被封禁的账号,无论封禁原因是垃圾信息、欺诈、骚扰还是违反条款。封禁是平台针对你本人做出的决定,而不是针对你的手机号。第二是冒充他人:用真人的姓名注册、盗用身份,或创建看起来像某家公司或某个人的账号,而你并不是他们。
欺诈、垃圾信息推广,以及任何针对他人的行为,同样属于不可接受的使用范围。MarioSMS 会因此封停账号。完整政策见 /acceptable-use/。
测试产物的数据留存
OTP 测试会产生各种产物:含号码的日志、验证界面的截图、CI 任务输出、测试账号的数据库记录。即便号码是租来的,也要把所有这些都当作个人数据来对待。
| 产物 | 建议留存期 | 处理方式 |
|---|---|---|
| 含激活 ID 的 CI 日志 | 14 到 30 天 | 对号码后 4 位做掩码处理 |
| 失败运行的截图 | 7 天 | 从产物存储桶自动删除 |
| 预发布环境中的测试账号记录 | 直到本次冲刺结束 | 按标签删除,不要手动删 |
| 收到的验证码短信正文 | 不要存储 | 对提取出的验证码做断言,然后丢弃正文 |
验证码本身有效期很短,通常是 5 到 10 分钟,但一条在日志里躺了一年的短信正文,仍然是一条发往真实号码的消息记录。
把可接受使用页面指给同事
把链接放在三个地方:新入职 QA 的上手文档、调用该 API 的测试仓库的 README,以及团队用来申请预算充值的那个频道里的置顶消息。当有人问某个使用场景是否被允许时,请依据政策原文来回答,而不是凭记忆。如果这个场景确实不明确,就在租用之前咨询客服,而不是等账号建好之后再说。
关于 OTP 与短信验证测试的常见问题
测试验证码一般多久能收到?
大多数验证码会在发送请求后一分钟内出现在应用或控制台里。差异来自线路,而不是号码本身。发往主流运营商的本地线路通常 5 到 15 秒就能送达,而经过多次转接的跨境线路可能要 40 秒以上。把轮询超时设为 120 秒,比这更快就算意外之喜,别当成必然。
在CI中保持测试套件稳定
| 问题 | 解决方法 |
|---|---|
| 超时导致的不稳定 | 将测试超时时间设置得比激活窗口期更长,而不是更短 |
| 并行运行发生冲突 | 每个worker使用一个号码,在teardown阶段释放 |
| 预算失控 | 限制每天的运行次数,冒烟测试使用最便宜的市场 |
| 日志中出现敏感信息 | 在测试输出中对号码和验证码进行脱敏处理 |
如果完全收不到短信会怎样?
每个号码都有一个激活时间窗。如果窗口关闭时仍未收到短信,激活会自动取消,费用会自动退回你的余额。你不需要提交工单,也不用等人工审核。对测试套件来说,这一点比听上去更重要:一次出错的构建触发 200 次失败激活,除了浪费几分钟之外不会产生任何费用。不过还是要把这次取消记为测试失败,因为收不到验证码本身就是关于发送方的真实信号。
一个号码能收两条验证码吗?
不能。MarioSMS 的一个号码一次只对应一次验证。在同一个激活窗口内重发,有时确实会落在同一个号码上,但不要按这个来设计。如果你的测试用例覆盖“用户点两次重发并使用第二个验证码”,请把重试路径当作独立场景处理,并确认该流程是否需要重新租用号码。默认一个号码对应一个验证码,你的测试才能保持确定性。
一个测试号码要多少钱?
价格从 $0.04 起,因服务和国家而异。供应充足的国家里的廉价服务接近这个下限;而在较小市场中、对发送方规则要求严格的服务,号码会更贵。给测试套件估算成本时,用每次运行的号码数量乘以你矩阵中最高的那个价格,而不是最低的,然后再和工程师追查一个不稳定的手工测试所耗费的成本做比较。
每个测试用例都需要不同的号码吗?
只要涉及创建账号,就需要;只要涉及验证“该号码已注册”这条分支,也需要。复用号码会引入跨用例污染,导致用例 B 通过只是因为用例 A 留下了残留状态。纯粹的送达检查(短信到底能不能走通这条线路)可以在一次激活内共用同一个号码,但这是唯一安全的重叠场景。
怎样不靠干等来测试过期?
三种办法,按推荐程度排序:
- 在后端拨动时钟。通过仅用于测试的接口把 OTP 记录的
created_at往前调,然后提交验证码。 - 在测试环境缩短 TTL。30 秒的过期时间能让你在一分钟内跑完一次真实的墙钟测试。
- 租一个号码,收到验证码,然后把它拖过真实的时间窗。这种方式慢且贵,但只有它能完全按线上实际情况检验生产环境的 TTL。
CI 里用第 1 种,每个版本发布前用一次第 3 种。
整个流程能通过 API 自动化吗?
可以。REST API 覆盖号码租用、验证码获取和取消,示例见 Python、Node.js、Go 和 PHP。一个可用的自动化用例只需要四次调用:申请号码、触发应用发送、轮询获取验证码、释放号码或让激活自然过期。把这些包成一个 fixture,这个测试读起来就和其他集成测试没什么两样。
有多少个国家可选?
现有库存覆盖 35+ 个国家和数百种服务,具体可用性会随供应变化。请在租用时查询库存,不要把国家硬编码进测试配置。如果你的测试矩阵必须用到某个特定市场,加一个跳过条件,让套件报告“XX 无库存”,而不是抛出一个红色失败、让工程师白白排查一个小时。
模拟器能收到短信吗?
收不到真实短信。iOS Simulator 没有射频模块,也没有 SIM 卡。Android 模拟器可以通过控制台注入短信(在 5554 端口执行 sms send),这能测试你的解析和自动填充逻辑,但完全说明不了运营商的送达情况。把两件事分开:注入消息用于验证 UI 行为,租用真实号码用于验证送达链路。
怎么测试语音电话回退?
回退通常在短信尝试失败后触发,或者用户点击“改用电话呼叫”时触发。要稳定走到这条分支,让短信尝试自然超时,而不是想办法强行制造错误。先确认应用在正确的时机显示了回退入口,再单独验证语音服务商已配置妥当。为接收短信而租用的号码只能收文字消息,因此语音相关的检查要在你自己的服务商控制台上做。
该用自己的私人号码测试吗?
不该,有四个原因:你的号码会累积注册状态,从而干扰结果;你没法把“新用户”路径测两遍;队友无法复现你的运行结果;而且你的私人号码会出现在共享的 CI 日志里。请使用租用号码,别让私人号码进入代码仓库。
验证失败的 bug 报告里该写什么?
| 字段 | 示例 |
|---|---|
| 时间戳(UTC) | 2026-09-10 14:22:05 |
| 国家和服务 | 印度,通讯类应用 |
| 号码(仅后 4 位) | …4471 |
| 激活 ID | act_8812f |
| 发送请求状态 | HTTP 200,发送方已接受 |
| 超时时长 | 118 秒 |
| 看到的发送方 ID(如有) | 无 |
| 应用版本 | 4.7.1 (2211) |
八个字段,客服或你的短信服务商就能追溯线路,不用再来回追问。
怎样测试限流又不会被封?
把限流测试指向你自己的后端,配上一个短信发送的 stub。请求计数是你自己的逻辑,不需要真实消息。在 60 秒内向 stub 发起 10 次发送,断言 429 响应和 retry-after 响应头,然后用一个真实的租用号码跑一遍正常流程,确认限流没有拦住正常流量。
下周还能复用同一个号码吗?
请默认不能。每次租用只对应一次激活,同一个号码不会一直等着你。把测试设计成与身份无关:不要有期望某个特定手机号字符串的 fixture,不要有写死号码的黄金文件,也不要有必须跨运行存活的账号。在测试运行时生成身份,结束时清理掉。
上线短信验证前,先走一遍发布前检查清单
把这份清单打印出来,从上到下逐条走。只有在真实环境里亲眼看到对应行为之后,才能打勾。下面每一条,都曾有团队上线时踩过坑。
测试计划中的费用去向
| 运行 | 使用号码数 | 大致成本 |
|---|---|---|
| 每次部署的冒烟测试 | 1到2个 | 几美分 |
| 每个市场的完整矩阵测试 | 6到10个 | 不到1美元 |
| 每周回归测试,覆盖8个市场 | 60到80个 | 几美元 |
| 负载测试,1,000次注册 | 1,000个 | 按目录价格计算需数十美元 |
目录价格,2026-09-10;失败的激活会自动退款,所以实际账单会更低。
验证码生成与存储检查
- 验证码来自密码学安全的随机源,而不是
Math.random()或带种子的伪随机数生成器。 - 验证码长度和字符集固定且有文档记录(OTP 流程通常默认 6 位数字)。
- 验证码以哈希形式存储,并且记录中绑定了手机号和用途,这样为登录签发的验证码就无法被重放到密码重置上。
- 有效期以绝对时间戳存储,在服务端校验,即使客户端传来的是过期值也照样生效。
- 验证码一次性使用。签发会话的那个事务内,同时把记录标记为已消费。
- 比对采用常量时间,这样响应耗时就不会泄露前面匹配了几位。
- 手机号在存储前和查询前都归一化为 E.164 格式,让
+1 415 555 0142和4155550142指向同一条记录。
下发与兜底检查
- 发送调用设有超时(5 到 10 秒),重试策略不会在响应慢但其实成功的情况下重复下发。
- 服务商错误被映射为面向用户的状态:号码无效、不支持该国家、运营商拒收、服务商故障。
- 有重新发送按钮,前 30 到 60 秒内置灰,并且复用同一个验证码,而不是每点一次就生成新的。
- 至少接好一条兜底通道(第二家服务商、语音电话或邮件),并且你手动触发过,而不只是看过文档。
- 超长短信内容、非拉丁字符的发送方 ID 以及模板里的 Unicode,都在每个上线国家用真机实测发送过。
- 消费并存储送达回执,这样”已发送”和”已送达”在你的数据里是两个独立状态。
滥用与限流检查
- 按手机号、按 IP、按账号、按设备指纹分别设限,每个维度都有各自的时间窗口。
- 无论号码是否已注册,返回的响应完全一致,防止号码枚举。
- 设置每小时和每天的成本上限,触顶直接硬性阻断,而不只是发个告警。
- 高费率目的地要么走白名单,要么直接封禁,因为短信刷量盯上的恰恰是你并不做生意的那些号段。
- 验证失败次数设上限(通常是 5 次),达到上限后作废整条记录,而不只是拒绝本次。
监控与告警检查
| 指标 | 何时告警 | 为什么重要 |
|---|---|---|
| 发送成功率 | 比 7 天基线低 5 个百分点 | 服务商或账号出问题 |
| 分国家送达率 | 任一国家 1 小时内下跌 10 个百分点 | 运营商拦截 |
| 验证码输入耗时中位数 | 超过 90 秒 | 上游下发变慢 |
| 验证完成率 | 在某一个平台上骤降 | 客户端有 bug,不是下发问题 |
| 每小时花费 | 超过你设定的上限 | 刷量,或者重试死循环 |
每条告警都要有明确负责人,以及”是电话叫醒还是开工单”的处理级别,这些要在上线前定好,而不是等第一次事故时再临时决定。
文档与应急手册检查
- 应急手册页面里写明服务商、账号负责人、支持渠道和预期响应时间。
- 切换服务商的步骤写成具体命令或配置项,而不是大段文字描述。
- 客服团队有一套应对”我一直没收到验证码”的话术,覆盖国家、运营商、漫游、免打扰名单登记和手机端拦截等情况。
- 测试凭据、沙箱号码和租号流程都有文档,让新来的工程师第一天就能跑通整套测试。
- 手机号和验证码记录的数据留存期限白纸黑字写明具体天数,删除任务也已排期。
- 内部对可接受用途的立场清晰:支持第二账号、QA 测试和隐私防护,不支持封号规避和冒充他人。
每次发布后的五分钟冒烟测试
- 在你最大的市场从 MarioSMS 租一个号码,比如美国,$0.04 起,具体价格视服务而定。
- 从已部署的版本触发一次发送,同时开始计时。
- 确认验证码在激活时间窗口内出现在面板上(通常不到一分钟;如果一直没收到,激活会自动取消并退款)。
- 输入验证码,确认会话正常签发,然后把同一个验证码再提交一次,确认被拒绝。
- 故意触发一次失败(验证码过期),确认错误提示文案和客服预期听到的说法一致。