MoeMail

开发者邮件测试完全指南

端到端测试真实邮件流程——验证码、密码重置、OTP 与免密登录链接——只需一个临时邮箱和一个 API。

邮件是大多数测试套件干脆放弃的那一步。在它之前的一切都在你掌控之中——填表单、点按钮、断言跳转——然后流程彻底离开你的应用,穿过一段你并不拥有的基础设施,落到一个测试看不见的地方。于是套件把发信调用打个桩,断言应用"尝试过"了,就算覆盖过了。

这样漏掉的,恰恰是真正会出问题的部分:信到底发出去没有、模板渲染对没对、里面那串验证码和后端愿意接受的那串是不是同一个。

这份指南就是把它认真测起来的路线图。

先分清一件事,它能直接淘汰掉一半工具

在挑任何工具之前,先搞清楚你要解决的是两个问题中的哪一个。这个领域的工具泾渭分明,但从它们的宣传页上通常看不出来。

真实收信邮箱给你的是一个可寻址的地址,能收下互联网上任何地方发来的邮件,再通过 API 读取或由 Webhook 推送。只有这一类,才能测试一封不是你自己代码发出的邮件——注册确认信、找回密码信、第三方服务发来的 2FA 验证码。

SMTP 捕获器则是一个假的 SMTP 服务器,你把应用指向它。它捕获的是你的应用发出去的邮件,并在一个界面里展示。用在本地开发循环里、用来断言自家外发模板,都非常好用。但它收不到外部来信——因为互联网上根本没有路由能到达它。

Mailpit、MailHog、MailCatcher、MailDev,以及 Mailtrap 的 Email Testing 沙箱,全都属于后一类。它们会和前一类并排出现在每一份"邮件测试工具"清单里,却没有任何提示说它们回答的是另一个问题。拿其中一个去干另一类的活,是这个领域最常见的错误,而且通常是在接入三天之后才发现。

多数团队最后两样都要:开发循环里用捕获器,端到端 CI 里用真实收件箱。

核心模式:每个用例一个收件箱

对于"收信"这一半,整门手艺可以归结为一条规则:为每个测试开一个全新的收件箱,而不是每个套件一个。

整个套件共用一个邮箱,等于把你正想消除的不稳定又请了回来:只要两个测试并行跑,其中一个就会读到另一个的邮件,于是你得到一个只在并发下才复现的失败。一个单独跑能过、放进套件就挂的测试,调试成本比它覆盖的功能还高。

按顺序有四个环节:

  1. 一个本次测试运行专属的地址;
  2. 触发发信的浏览器或 API 流程;
  3. 读取到达的邮件;
  4. 提取验证码或链接,然后断言。

第 1 步和第 3 步才是工具选择真正起作用的地方,其余都是普通的测试代码。

能跑起来的最小实现

两个接口就能干完全部的活——创建地址,然后轮询直到有信落地:

const API = 'https://moemail.app/api'
const H = { 'X-API-Key': process.env.MAIL_KEY, 'Content-Type': 'application/json' }

// expiryTime 单位是毫秒。不传 name 就会随机生成本地部分,
// 这正是你想要的——并行测试永远不会撞车。
export async function createInbox() {
  const res = await fetch(`${API}/emails/generate`, {
    method: 'POST',
    headers: H,
    body: JSON.stringify({ expiryTime: 3_600_000, domain: 'moemail.app' }),
  })
  if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
  return res.json() // { id, email }
}

export async function waitForMessage(inboxId, timeoutMs = 30_000) {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/emails/${inboxId}`, { headers: H })
    const { messages } = await res.json()
    if (messages?.length) return messages[0]
    await new Promise(r => setTimeout(r, 1500))
  }
  throw new Error(`no email arrived within ${timeoutMs}ms`)
}

返回的邮件形如 { id, from_address, to_address, subject, content, html, received_at }——content 是纯文本正文,html 是 HTML 部分。

轮询要用截止时间来约束,而不是重试次数。一旦有人调了间隔,固定次数就会悄悄变成另一个超时值。

另外,对提取本身也要断言:

const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()

正则没匹配上时,match(...)[0] 会抛 Cannot read properties of null,于是堆栈指向你的正则,而不是真正的问题——邮件内容和你预期的不一样。把主题行放进失败信息里,能把十分钟的困惑变成扫一眼报告。

轮询还是 Webhook

轮询更简单,测试只有几个时完全够用。Webhook 省掉的是延迟和白白浪费的请求;当套件大到「1.5 秒间隔 × 一百个测试」变成实打实的墙钟时间时,它就开始重要了。

无论哪种,超时都要设得紧,但要诚实。测试环境里的事务性邮件应当在几秒内送达,30 秒已经留足余量。如果某个套件非要 90 秒,那是在掩盖一个投递问题,而不是修好它。

别忘了读 HTML 部分

有个坑值得提前避开:如果你的模板只把验证链接放在 HTML 正文里,那么读 content 会什么都找不到。纯文本备份部分和 HTML 脱节的情况,比大多数人以为的要常见得多,而用户真正点击的是 HTML。content 里找不到链接时就去读 msg.html,并且优先测试用户实际收到的那一版。

怎么选工具

如果你在评估付费服务,真正该比的不是功能条数,而是三件事:它能不能收真实来信、免费额度实际允许你做什么、能不能自托管以摆脱按收件箱计费的配额。

有两篇专门写了这个,也直说了在哪些情况下商业产品才是更划算的选择:Mailosaur 替代方案按上面那条「收信 / 捕获」的分界把整个领域理了一遍;MailSlurp 替代方案则算了一笔账——当正确的做法就是每个测试建一个收件箱时,按收件箱计费意味着什么。

MoeMail 如何契合

MoeMail 是真实收信邮箱:可寻址的地址、带密钥鉴权的 OpenAPI,以及 Webhook。它开源,并且可以自托管在 Cloudflare 上,从而用上自定义域名、也不受外部配额限制。

坦白说说它不做什么:没有短信/手机 OTP 测试,没有跨邮件客户端的渲染预览,开箱即用的框架集成也远少于付费产品——HTTP 调用要你自己接,也就是上面那二十行。如果你的测试需要在同一个工具里覆盖短信,那就去买 Mailosaur;这是一个真实的缺口,不是小瑕疵。

从个人资料里取一个 API 密钥,读一读 OpenAPI 文档,或者直接创建一个邮箱看看数据长什么样。

下面的各篇指南,会把这套模式落到具体的框架、流程、语言和 CI 环境里。