自动化测试怎么处理邮箱验证码: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 按次付费,没收到自动退款 预发布冒烟、发版前回归 要花钱;每次等几秒到几分钟

国内团队常说的“万能验证码”,就是第一种。它跑得最快,适合大多数用例;但它刻意跳过了发信,所以只靠它,发现不了“生产环境的验证邮件根本没发出去”这类问题。比较稳妥的组合是:日常用前两种,发版前或每晚用真实邮箱跑一组核心注册用例。

什么时候需要真实邮箱

测试你自己的应用时,服务代码用 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 的结果和测试结论对应起来:

你看到的 含义 测试里建议怎么判
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。

验证码过期了怎么办?

拿到验证码就立刻填写,不要在用例里多次点“重新发送”,否则旧验证码通常会失效。如果每次都过期,多半是被测系统的有效期设置太短,应当作缺陷处理。

相关教程

现在就获取你的验证码

注册账户不收费。分配到邮箱才扣费;等待时间内(最长 25 分钟)没有收到验证码,自动 100% 退款。

找不到你需要的服务?在租用页面点添加服务,如果我们的基础设施支持,就会把它加上。

需要可以登录的 Gmail 账号,而不只是验证码?请看购买短效 Gmail 账号 · 退款与质保