自动化测试怎么处理邮箱验证码:4 种做法与 Playwright 示例
自动化测试怎么获取验证码?如果是发到邮箱的验证码,常见有 4 种做法:测试环境用固定验证码或测试接口、用本地工具截获邮件、用 IMAP 读取自己的测试邮箱,或者通过 API 租用真实的 @gmail.com / @icloud.com 邮箱。只有需要确认邮件真的送达 Gmail 或 iCloud 时,才用得上最后一种。
如果你是 QA 或开发者,正在给自家产品写 E2E 测试或接口测试,下面先帮你判断该选哪种,再给出 Playwright、Selenium 和 Cypress 的接法。这里只讨论邮箱验证码,不涉及手机短信;图形验证码、滑块验证码不在本文范围,OTPGmail 也不处理这类验证码。
4 种做法对比:自动化测试验证码怎么处理
| 做法 | 怎么拿到验证码 | 测到了什么 | 每次成本 | 适合阶段 | 主要问题 |
|---|---|---|---|---|---|
| 测试环境固定验证码 / 测试专用接口 | 测试环境里验证码固定为某个值,或者开一个只在测试环境可用的接口直接查 | 只测业务逻辑,不测发信 | 几乎为零 | 单元测试、日常 CI | 配置一旦带到生产就是安全漏洞 |
| 本地邮件捕获工具 | 应用的 SMTP 指向本地捕获服务,测试通过它的 HTTP 接口读邮件 | 邮件模板、内容、链接 | 几乎为零 | 本地开发、集成测试 | 邮件不出本机,测不到真实投递 |
| 自己的测试邮箱 + IMAP | 团队开一个 Gmail、QQ 或 163 测试邮箱,脚本用 IMAP 读最新邮件 | 真实投递 | 维护成本 | 少量、串行的回归测试 | 地址固定:第二次注册常提示“邮箱已注册”;并行时邮件混在一起;要管理授权码 |
| 租用真实邮箱 + API | 每个用例通过 API 租一个新地址,轮询拿验证码 | 真实投递到 Gmail / iCloud | 按次付费,没收到自动退款 | 预发布冒烟、发版前回归 | 要花钱;每次等几秒到几分钟 |
国内团队常说的“万能验证码”,就是第一种。它跑得最快,适合大多数用例;但它刻意跳过了发信,所以只靠它,发现不了“生产环境的验证邮件根本没发出去”这类问题。比较稳妥的组合是:日常用前两种,发版前或每晚用真实邮箱跑一组核心注册用例。
什么时候需要真实邮箱
- 预发布或生产冒烟测试:确认你的发信配置改动之后,验证邮件仍能送达普通用户常用的 Gmail、iCloud 地址。
- 每次都要一个“没注册过”的地址:新用户注册、邀请、找回密码这类流程,用固定的测试邮箱很快就会撞上“该邮箱已被使用”。
- 注册流程里嵌着第三方平台:例如用户要先在某个平台完成邮箱验证,测试只能在该平台条款允许的范围内这样做。
测试你自己的应用时,服务代码用 ot,也就是任意服务:你的网站不在服务列表里,只能走这个选项,价格比列表里的服务高(目前 Gmail 每个 US$0.136)。它的 Gmail 选项每个邮箱只收一个验证码;iCloud 选项会显示 24 小时内发到该邮箱的所有新邮件,而且只分配你从未租过的地址。如果用例在注册后还要登录、再收一次验证码,选 iCloud 选项更合适。
Playwright 示例:用 fixture 自动租用、自动退款
Playwright 的 fixture 很适合这件事:每个用例拿到自己的邮箱,用例结束时如果没收到验证码,就自动取消订单,费用 100% 退回余额。下面用 Playwright 自带的 request 发 API 请求,API 密钥从环境变量读取,不会进入浏览器。
// fixtures.ts
import { test as base, expect, request, type APIRequestContext } from "@playwright/test";
import { randomUUID } from "node:crypto";
const HOST = "otpgmail.net";
type Inbox = { orderId: string; email: string; waitForCode: (maxMs?: number) => Promise<string> };
export const test = base.extend<{ inbox: Inbox }, { otpApi: APIRequestContext }>({
// 每个 worker 一个 API 客户端
otpApi: [async ({}, use) => {
const ctx = await request.newContext({
baseURL: `https://${HOST}`,
extraHTTPHeaders: { Authorization: `Bearer ${process.env.OTPGMAIL_API_KEY}` },
});
await use(ctx);
await ctx.dispose();
}, { scope: "worker" }],
// 每个用例一个新邮箱;没收到验证码就在用例结束时取消(全额退款)
inbox: async ({ otpApi }, use) => {
const res = await otpApi.post("/v1/orders", {
headers: { "Idempotency-Key": randomUUID() },
data: { service: "ot", domain: "gmail.com" },
timeout: 15_000,
});
const body = await res.json();
if (!body.success) throw new Error(body.error.code);
const order = body.data[0];
let gotCode = false;
await use({
orderId: order.orderId,
email: order.email,
waitForCode: async (maxMs = 180_000) => {
// 不超过订单的 codeDeadlineAt(Unix 秒),每 4 秒查一次
const end = Math.min(order.codeDeadlineAt * 1000, Date.now() + maxMs);
while (Date.now() < end) {
const o = (await (await otpApi.get(`/v1/orders/${order.orderId}`)).json()).data;
if (o.otp.length) { gotCode = true; return o.otp[o.otp.length - 1].code; }
if (o.status === "cancelled") break;
await new Promise((r) => setTimeout(r, 4_000));
}
throw new Error("NO_CODE");
},
});
if (!gotCode) await otpApi.post(`/v1/orders/${order.orderId}/cancel`);
},
});
export { expect };
// signup.spec.ts
import { test, expect } from "./fixtures";
test("新用户用邮箱验证码完成注册", async ({ page, inbox }) => {
test.setTimeout(5 * 60_000); // 邮件可能要几分钟
await page.goto("/signup");
await page.getByLabel("邮箱").fill(inbox.email);
await page.getByRole("button", { name: "发送验证码" }).click();
const code = await inbox.waitForCode();
if (code.startsWith("http")) {
await page.goto(code); // 有的网站发的是验证链接
} else {
await page.getByLabel("验证码").fill(code);
await page.getByRole("button", { name: "验证" }).click();
}
await expect(page.getByText("注册成功")).toBeVisible();
});
几点说明:POST /v1/orders 返回的是数组,每个邮箱一个订单;otp 按到达顺序排列,最新的在最后;每个新订单用一个新的 Idempotency-Key,只有超时重发同一个请求时才沿用旧的,这样不会重复租用。每个用例各租一个邮箱,所以多个 worker 并行也不会互相读错验证码。
Selenium(Python)和 Cypress 怎么接
Selenium:流程完全一样,只是换成 Python。rent() 和 wait_for_code() 两个函数直接用Python 接收验证码教程里的写法,这里不再重复:
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
order = rent("ot") # 返回订单:orderId、email…
try:
driver.get("https://staging.example.com/signup")
driver.find_element(By.NAME, "email").send_keys(order["email"])
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
code = wait_for_code(order["orderId"], max_wait=180) # 超时会自己取消订单
assert code, "没有收到验证码,订单已取消并退款"
driver.find_element(By.NAME, "code").send_keys(code)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
finally:
driver.quit()
Cypress:测试代码跑在浏览器里,API 密钥不该放进去。做法是把请求写成 cy.task,在 Node 端执行:
// cypress.config.js
const { defineConfig } = require("cypress");
const { randomUUID } = require("node:crypto");
const HOST = "otpgmail.net";
const API = `https://${HOST}/v1`;
const H = { Authorization: `Bearer ${process.env.OTPGMAIL_API_KEY}` };
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
on("task", {
async rentInbox(service) {
const res = await fetch(`${API}/orders`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": randomUUID() },
body: JSON.stringify({ service, domain: "gmail.com" }),
});
const body = await res.json();
if (!body.success) throw new Error(body.error.code);
return body.data[0];
},
async waitForCode(orderId) {
for (let i = 0; i < 45; i++) { // 约 3 分钟
const o = (await (await fetch(`${API}/orders/${orderId}`, { headers: H })).json()).data;
if (o.otp.length) return o.otp[o.otp.length - 1].code;
await new Promise((r) => setTimeout(r, 4000));
}
await fetch(`${API}/orders/${orderId}/cancel`, { method: "POST", headers: H });
return null; // cy.task 不能返回 undefined
},
});
},
},
});
用例里 cy.task("waitForCode", order.orderId, { timeout: 5 * 60 * 1000 }):Cypress 的任务默认 60 秒就超时,等邮件往往不够,要把超时调大。
让测试稳定:轮询、超时与失败判定
- 只能轮询:API 没有回调通知,用例要自己每 3–5 秒请求一次
GET /v1/orders/{id},查得再快也只是返回同样的数据。 - 超时对齐
codeDeadlineAt:订单返回的codeDeadlineAt是系统自动取消的时间,最长 25 分钟。用例的等待上限可以更短,到点自己取消;不取消也没关系,到时系统会自动取消并全额退款。 - 验证码可能是链接或整封邮件:
otp[].code通常是数字或链接;系统提取不出验证码时,可能是整封邮件的原文(HTML 或文本),这时由你的代码自己找出验证码。 - 并发和频率:同时处于“等待验证码”的订单数有上限,超出返回
WAITING_LIMIT_REACHED;请求太快会返回RATE_LIMITED(HTTP 429),按响应头Retry-After的秒数等待。具体限额以 API 文档为准。 - 只用测试数据:系统自己读不出验证码时,可能会把邮件交给 AI 语言服务识别,所以注册信息里不要放真实的个人数据。
测试报告里最怕“不知道是谁的问题”。下面这张表把 API 的结果和测试结论对应起来:
| 你看到的 | 含义 | 测试里建议怎么判 |
|---|---|---|
NO_MAILS_AVAILABLE / OUT_OF_STOCK |
暂时没有可用邮箱,不扣费 | 记为环境问题,稍后重跑或换 domain |
INSUFFICIENT_BALANCE |
余额不足 | 中止整批,先充值 |
RATE_LIMITED、WAITING_LIMIT_REACHED |
请求太快或并发太高 | 降低并发,按 Retry-After 重试 |
订单 status 变成 cancelled |
到期仍没有邮件,已全额退款 | 判为失败,先查被测系统的发信日志 |
| 收到验证码,但页面提示错误或过期 | 邮件到了,验证逻辑有问题 | 判为被测系统缺陷 |
成本和 CI 的安排
真实邮箱按次付费:分配到邮箱才扣费,没收到验证码的订单自动退款,完整规则见退款与质保政策。所以通常不在每次提交时都跑,而是放在夜间任务或发版前。API 密钥放在 CI 的密钥管理里;跑之前用 GET /v1/balance 查一下余额,不够就跳过这组用例。
请只用于测试你自己的注册流程,或在第三方平台条款允许的范围内使用。使用前请确认符合该平台的服务条款和你所在地的法律法规;OTPGmail 只提供接收验证邮件的邮箱,不提供绕过平台或地区限制的方法。
常见问题
有沙箱或测试余额吗?
API 文档里没有沙箱环境,每个订单都从真实余额扣费;不过没收到验证码的订单会全额退回,所以“没等到邮件”的用例不花钱。
带验证码的登录测试怎么做?
注册后同一个订单可以继续轮询,网站再发验证码时,新的会追加到 otp 数组末尾;网站会发多个时,订单有效期内不另收费。注意任意服务的 Gmail 选项每个邮箱只收一个验证码,这类用例请选 iCloud 选项。
不用浏览器的接口测试也能用吗?
能。顺序是:租邮箱 → 调你的注册接口提交地址 → 轮询验证码 → 调校验接口提交验证码。任何能发 HTTPS 请求的工具都行,包括 Postman 和 requests。
验证码过期了怎么办?
拿到验证码就立刻填写,不要在用例里多次点“重新发送”,否则旧验证码通常会失效。如果每次都过期,多半是被测系统的有效期设置太短,应当作缺陷处理。
