如果你的邮件测试在笔记本电脑上运行完美,但一到 CI 环境就崩溃,你并不孤单。通常的做法是在测试代码中到处添加 sleep 调用,或者不断增加重试次数直到构建通过。这或许能暂时掩盖问题,但并不能修复 bug,只是在掩耳盗铃。
问题的核心在于你的测试是如何识别要打开哪封邮件的。
共享收件箱问题
在本地机器上,你一次只运行一个测试。一封邮件到达,你将其获取。很简单。
CI 完全是另一种环境。一个 pull request 可能会触发 4 个、8 个甚至 16 个并行任务。如果它们共享同一个测试收件箱——无论是 Mailosaur 服务器、Mailtrap 收件箱,还是 staging 域名的真实账号——它们都在同一时间向同一个“桶”中写入数据。任务 A 发送密码重置邮件,任务 B 发送邀请邮件,任务 C 正在重试失败的欢迎流程。与此同时,后台工作进程和投递队列会引入你无法控制的抖动(jitter)。
当每个任务都试图从该共享收件箱中获取主题为“重置您的密码”的最新的邮件时,竞争(race)就发生了。胜出的测试获取了正确的邮件;而失败的测试则点击了属于另一个任务的链接,对错误的内容进行断言,并最终以一个看起来像是“时序问题”的错误而失败。这其实不是时序问题,而是身份识别问题。
为什么“最新邮件”模式会失效
这种脆弱的模式很容易让人陷入,因为它看起来很直观:
- 触发用户流程。
- 每隔几秒轮询一次收件箱。
- 打开匹配主题行的最新邮件。
- 点击第一个链接并运行断言。
除了简单的并行性之外,这种模式还会因为以下几个原因而崩溃。前一次失败运行的重试可能会延迟到达,恰好在你当前测试轮询时变成了最新邮件。应用程序内部的后台工作进程可能会排队发送两封邮件,并先于第一封交付第二封。仅靠主题行作为标识符是非常脆弱的;你的 staging 应用可能会从不同的路径发送类似的主题邮件。按时间戳排序的情况更糟,因为 CI 运行器与邮件提供商之间的时钟偏移(clock skew)是真实存在的,而且邮件 API 通常会缓存或批量处理其索引。
在繁忙的环境中,时间戳会变得模糊不清。你需要更直接的方式。
什么是运行令牌(Run Token)
运行令牌不过是在测试开始时生成并注入到应用程序发送的邮件中的一个唯一字符串。它不需要面向用户,也不需要看起来很优雅。它只需要保证你能证明这封特定的邮件属于这次特定的测试执行。
具体的例子最有说服力。在测试开始前,生成如下令牌:
- 一个 UUID:
550e8400-e29b-41d4-a716-446655440001 - 一个构建范围内的请求 ID:
req_ci_build_4821_a7f3 - 一个邀请 slug 或元数据后缀:
signup-token-8k2m9n - 由测试运行器生成的随机十六进制字符串:
test-run-a4f9c2d1
如果你可以控制后端代码,请将令牌传入邮件上下文,并在正文的某个地方进行渲染。如果你是在对黑盒应用进行测试,请查看应用是否已经接受某个你可以利用的引用字段。如果不行,有时你可以使用加号地址(plus addressing)将令牌嵌入到收件人的本地部分(local-part)中——例如 testuser+a4f9c2d1@example.com——但这只有在你的应用程序保留并将其回显到邮件中时才有效。
核心点在于:停止匹配邮件系统已经拥有的元数据,转而匹配由你的测试所拥有的数据。
可靠的模式
用一种精确的、由令牌驱动的搜索算法来取代“最新邮件”算法:
- 在触发任何流程之前生成运行令牌。
- 开始用户操作,确保应用程序会在发出的邮件中包含该令牌。
- 使用限定该令牌的过滤器轮询邮件提供商。如果 API 支持正文搜索,请使用它。如果不支持,请获取候选邮件并在客户端使用
grep搜索其正文。 - 在点击任何链接、按钮或验证码之前,先断言令牌存在于邮件正文中。
- 只有在完成这一步后,才提取确认 URL 或验证码并继续后续操作。
这个顺序至关重要。如果你先提取链接,然后再检查令牌,那么你其实已经点击了错误的邮件。断言就是你的守门员。
从实践角度来看,你的辅助函数应该寻找 Subject:"Welcome to AppName" AND Body:"a4f9c2d1",而不是 Subject:"Welcome to AppName" sort:-received。许多邮件测试服务都提供了接受正文内容过滤的搜索 API。请利用它们。如果你使用的是较简单的服务商,请将轮询逻辑集中在一个地方,以便在每个测试中都能一致地添加客户端过滤。
保持系统可靠性的三条规则
运行令牌(run token)可以固定选择范围,但你仍需在轮询方式以及出错时的处理流程上保持严谨。
在失败时记录收件箱状态。 当测试失败时,输出收件箱标识符、你查询的主题行、确切的时间戳窗口,以及有多少条消息符合你的标准。这能将模糊的“未找到电子邮件”错误转化为一个具体的过程。如果任务 7823 因为任务 7821 的重试消息晚到了三秒而将其抓取,你的日志应该能清晰地体现这一点。如果没有这些上下文,你只会归咎于时机问题,然后又加一个 sleep。
将所有邮件轮询逻辑保留在一个辅助文件中。 不要将 setTimeout 和 cy.task 调用散落在二十个测试文件中。将等待消息、重试 API 调用和应用退避(backoff)逻辑集中化。如果每个测试都使用同一个辅助函数,你的过滤规则就能保持一致,而且当你改进搜索逻辑时,所有测试都会受益。这也有助于强制执行令牌检查;如果辅助函数需要令牌参数,就没有人会不小心退而求其次,依赖“最新消息”这种权宜之计。
关注你的重试机制。 在 CI 中,测试重试很常见,但每次重试都会在收件箱中产生另一封邮件。如果你的测试在第三次尝试时通过了,你可能会庆祝并继续下一步。但你忽略的是,前两次尝试其实暴露了一个真正的 Bug——比如竞态条件、重复发送或索引缺失——而这些额外的消息掩盖了问题。如果你必须使用重试,请检查失败后收件箱是否包含意外的重复项。更好的做法是,如果你的服务商支持动态收件箱,请考虑清理收件箱或为每个任务使用唯一的地址。重试不应成为掩盖不可靠选择逻辑的一种策略。
核心启示
按日期对收件箱进行排序并获取第一条结果并不叫测试,那只是披着代码外衣的猜测。运行令牌几乎没有任何成本——只需一个字符串变量、一个额外的过滤参数,可能还需要一点模板改动——但它能赋予你的测试确定性的身份。它能证明你面前的消息确实属于你当前正在执行的运行实例。
停止通过添加 sleep 并寄希望于网络表现良好。生成一个令牌,将其放入邮件中,然后直接搜索它。这样你的 CI 运行会更快,日志会更具可读性,你最终也能真正信任邮件测试套件反馈给你的结果。
