news 2026/6/27 4:55:27

断言那些事儿:单测只需要一个断言?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
断言那些事儿:单测只需要一个断言?

一个测试用例,而不是一个测试断言。

断言轮盘并不意味着多重断言就是坏事。当我指导团队或单个开发人员进行测试驱动开发(TDD)或单元测试时,经常会遇到一种特别的观念: 多个断言是不好的。一个测试必须只有一个断言。这种想法很少有用。让我们看一个实际的代码示例,然后来试着理解这种观念的起源。

由外至内的 TDD

考虑使用 REST API 进行和取消餐厅预订。首先,通过 HTTP POST 请求进行预订:

  1. POST /restaurants/1/reservations?sig=epi301tdlc57d0HwLCz[...] HTTP/1.1

  2. Content-Type: application/json

  3. {

  4. "at":"2023-09-22 18:47",

  5. "name":"Teri Bell",

  6. "email":"terrible@example.org",

  7. "quantity":1

  8. }

  9. HTTP/1.1201Created

  10. Content-Type: application/json; charset=utf-8

  11. Location:/restaurants/1/reservations/971167d4c79441b78fe70cc702[...]

  12. {

  13. "id":"971167d4c79441b78fe70cc702d3e1f6",

  14. "at":"2023-09-22T18:47:00.0000000",

  15. "email":"terrible@example.org",

  16. "name":"Teri Bell",

  17. "quantity":1

  18. }

请注意,在适当的 REST 方式下,响应会在 Location 标头中返回已创建预订的位置。

如果你改变主意了,可以通过 DELETE 请求取消预订:

  1. DELETE /restaurants/1/reservations/971167d4c79441b78fe70cc702[...] HTTP/1.1

  2. HTTP/1.1200 OK

假设这就是我们想要的交互。使用由外至内的TDD编写如下测试:
  1. [Theory]

  2. [InlineData(884,18,47,"c@example.net","Nick Klimenko",2)]

  3. [InlineData(902,18,50,"emot@example.gov","Emma Otting",5)]

  4. public async TaskDeleteReservation(

  5. int days,int hours,int minutes,

  6. string email,string name,int quantity)

  7. {

  8. usingvar api =newLegacyApi();

  9. var at =DateTime.Today.AddDays(days).At(hours, minutes)

  10. .ToIso8601DateTimeString();

  11. var dto =Create.ReservationDto(at, email, name, quantity);

  12. var postResp = await api.PostReservation(dto);

  13. Uri address =FindReservationAddress(postResp);

  14. var deleteResp = await api.CreateClient().DeleteAsync(address);

  15. Assert.True(

  16. deleteResp.IsSuccessStatusCode,

  17. $"Actual status code: {deleteResp.StatusCode}.");

  18. }

这个例子是在c#中使用xUnit.net,因为我们需要一些语言和框架来展示真实的代码。不过,本文的观点适用于各种语言和框架。本文中的代码示例基于我的著作《Code That Fits in Your Head》中的示例代码库。

为了通过这个测试,你可以像这样实现服务器端代码:

  1. [HttpDelete("restaurants/{restaurantId}/reservations/{id}")]

  2. publicvoidDelete(int restaurantId,string id)

  3. {

  4. }

虽然这显然是一个空操作,但它通过了所有测试。新编写的测试断言 HTTP 响应会返回 200(成功)范围内的状态代码。这是 API 的 REST 协议的一部分,因此该响应非常重要。你希望保留此断言作为回归测试。如果 API 开始返回 400 或 500 范围内的状态代码,这将是一个重大变化。

到目前为止,一切顺利。TDD 是一个渐进的过程。一个测试并不能驱动一个完整的功能。既然所有测试都通过了,你就可以将更改提交到源代码控制中,然后进行下一次迭代。

加强后置条件

你应该能够通过发起一个GET请求来检查资源是否真的消失了:

  1. GET /restaurants/1/reservations/971167d4c79441b78fe70cc702[...] HTTP/1.1

  2. HTTP/1.1404NotFound

然而,这并不是 Delete 当前实现的行为,它什么也没做。这样看来你需要再做一次测试。有一种方法是复制现有测试并更改断言阶段,执行上述 GET 请求,以检查响应状态是否为 404:
  1. [Theory]

  2. [InlineData(884,18,47,"c@example.net","Nick Klimenko",2)]

  3. [InlineData(902,18,50,"emot@example.gov","Emma Otting",5)]

  4. public async TaskDeleteReservationActuallyDeletes(

  5. int days,int hours,int minutes,

  6. string email,string name,int quantity)

  7. {

  8. usingvar api =newLegacyApi();

  9. var at =DateTime.Today.AddDays(days).At(hours, minutes)

  10. .ToIso8601DateTimeString();

  11. var dto =Create.ReservationDto(at, email, name, quantity);

  12. var postResp = await api.PostReservation(dto);

  13. Uri address =FindReservationAddress(postResp);

  14. var deleteResp = await api.CreateClient().DeleteAsync(address);

  15. var getResp = await api.CreateClient().GetAsync(address);

  16. Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode);

  17. }

这个方法确实可以提示你正确地实现服务器端Delete方法。但这真的是一个好主意吗?使用这个方法,测试代码是否易于维护呢?

测试代码也是代码,你必须维护它。在测试代码中复制和粘贴会造成问题,原因与在生产代码中复制和粘贴会造成问题的原因相同。如果以后要修改某些内容,你必须确定所有需要编辑的地方。生产代码很容易遗漏掉某一处,从而导致错误。测试代码亦是如此。

一个操作,更多断言

与其复制粘贴第一个测试,为什么不加强第一个测试用例的后置条件呢?

只需在第一个断言后添加新的断言即可:

  1. [Theory]

  2. [InlineData(884,18,47,"c@example.net","Nick Klimenko",2)]

  3. [InlineData(902,18,50,"emot@example.gov","Emma Otting",5)]

  4. public async TaskDeleteReservation(

  5. int days,int hours,int minutes,

  6. string email,string name,int quantity)

  7. {

  8. usingvar api =newLegacyApi();

  9. var at =DateTime.Today.AddDays(days).At(hours, minutes)

  10. .ToIso8601DateTimeString();

  11. var dto =Create.ReservationDto(at, email, name, quantity);

  12. var postResp = await api.PostReservation(dto);

  13. Uri address =FindReservationAddress(postResp);

  14. var deleteResp = await api.CreateClient().DeleteAsync(address);

  15. Assert.True(

  16. deleteResp.IsSuccessStatusCode,

  17. $"Actual status code: {deleteResp.StatusCode}.");

  18. var getResp = await api.CreateClient().GetAsync(address);

  19. Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode);

  20. }

这意味着你只需要维护一个测试方法,而不是两个几乎完全相同的重复方法。但是,我指导过的一些人可能会说,这个测试有两个断言!的确如此。那又怎样?这是一个测试用例: 取消预订。

虽然取消预订是一个单独的操作,但我们关心的是多个结果:DELETE 请求成功后的状态代码应在 200 范围内。预订资源应该消失了。在进一步开发系统的过程中,我们可能会添加更多我们关心的行为。也许系统还应该发送一封关于取消预订的电子邮件。我们也应该断言这一点。不过,这仍然是相同的测试用例: 成功取消预订。

在一个测试中使用多个断言并没有什么问题。上面的例子说明了它的好处。一个测试用例可以有多个应该被验证的结果。

单一断言概念的起源

每次测试只有一个断言的概念从何而来?我不知道,但我可以猜测。

优秀的《xUnit Test Patterns》一书中描述了一种名为 “断言轮盘”(Assertion Roulette)的测试气味。它描述了一种很难确定到底是哪个断言导致了测试失败的情况。在我看来,每项测试只有一个断言的 “规则 “是对断言轮盘描述的误读造成的。(甚至我自己可能也有责任。我不记得我是否参与过)。

xUnit 测试模式描述了断言轮盘的两个原因:

  • 急于测试: 单个测试验证的功能过多。

  • 缺失断言信息。

你可能正试图模拟一个 “会话”,在这个会话中,客户端会执行许多步骤来实现一个目标。正如 Gerard Meszaros 就测试气味所写的那样,这适用于人工测试,但很少用于自动化测试。导致问题的不是断言的数量,而是测试做得太多。

另一个原因是,当断言非常相似时,你无法判断哪一个失败了,同时它们也没有断言信息。

上面例子的情况并非如此。如果 Assert.True 断言失败,断言信息会告诉你:

  1. Actual status code:NotFound.

  2. Expected:True

  3. Actual:False

同样,如果 Assert.Equal 断言失败,也会一目了然:
  1. Assert.Equal()Failure

  2. Expected:NotFound

  3. Actual: OK

这里没有歧义。

一次测试,一个断言

既然你已经明白了每个测试可以有多个断言,那么你就可以无所顾忌地添加断言了。不过,在通常情况下,像 “一次测试,一个断言 “,这样根深蒂固的理念中也蕴含着真理的萌芽。所以需要我们进行正确的判断。

如果你认真思索一下什么是自动化测试,它基本上就是一个谓词。它是一种声明,表明我们期待一种特定的结果。然后,我们将实际结果与预期结果进行比较,看两者是否相等。因此,从本质上讲,理想的断言是这样的:

Assert.Equal(expected, actual);

我并不总能实现这一理想断言,但只要能做到,我就会感到非常满足。有时,expected 和 actual 是原始值,如整数或字符串,但它们也可能是复杂值,代表测试所关注的程序状态子集。只要对象在结构上相等,这样的断言就是有意义的。有时,我无法找到像这样简洁表达验证步骤的方法,不得不再添加一两个断言时,我就会这么做。

总结

有一种观点认为,每个单元测试只能写一个断言。这可能是出于对错误测试代码的真正担忧,但多年来,”断言轮盘”(Assertion Roulette)这一微妙的测试气味已经变成了一种更简单、但不太有用的 “规则”。

这个“规则”经常会阻碍测试代码的可维护性。遵循“规则”的程序员诉诸于无端的复制和粘贴,而不是在现有测试中添加另一个断言。如果在现有测试中添加相关断言是最好的方法,就不要让一个被误解的规则阻止你。

感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/25 3:34:58

人工智能药学大会现场

如题,生信基地的小伙伴前两天参加了场学术会议,简单记录一下此次参会的收获。校长报告人工智能如何重塑药物研发的未来?智能药学将如何推动医药产业转型升级?是加速进程还是改变范式?首先上午第一场报告由校长提出三个…

作者头像 李华
网站建设 2026/6/25 23:21:05

X-CLIP多模态模型深度解析:视频理解的技术之旅

X-CLIP多模态模型深度解析:视频理解的技术之旅 【免费下载链接】xclip-base-patch32 项目地址: https://ai.gitcode.com/hf_mirrors/microsoft/xclip-base-patch32 在人工智能的快速发展中,多模态理解技术正成为连接视觉与语言世界的重要桥梁。X…

作者头像 李华
网站建设 2026/6/26 11:21:49

【Java】java 集合框架(详解)零基础入门到精通,收藏这篇就够了

1. 概述 🚀 🔥 Java集合框架 提供了一系列用于存储和操作对象组的接口和类。这些工具是为了解决不同数据结构通用操作的需求而设计的。集合框架主要包括两种类型的容器: 一种是 集合(Collection),用于存储…

作者头像 李华
网站建设 2026/6/26 3:44:30

告别手动提交:用Git Auto Commit Action实现自动化工作流

告别手动提交:用Git Auto Commit Action实现自动化工作流 【免费下载链接】git-auto-commit-action Automatically commit and push changed files back to GitHub with this GitHub Action for the 80% use case. 项目地址: https://gitcode.com/gh_mirrors/gi/g…

作者头像 李华
网站建设 2026/6/24 10:35:54

解决ComfyUI-SeedVR2视频超分项目wandb依赖冲突的3种实用方法

解决ComfyUI-SeedVR2视频超分项目wandb依赖冲突的3种实用方法 【免费下载链接】ComfyUI-SeedVR2_VideoUpscaler Non-Official SeedVR2 Vudeo Upscaler for ComfyUI 项目地址: https://gitcode.com/gh_mirrors/co/ComfyUI-SeedVR2_VideoUpscaler 你在使用ComfyUI-SeedVR2…

作者头像 李华
网站建设 2026/6/25 1:16:31

iOS动画开发终极指南:用lottie-ios组件库打造高性能可复用动画

iOS动画开发终极指南:用lottie-ios组件库打造高性能可复用动画 【免费下载链接】lottie-ios airbnb/lottie-ios: Lottie-ios 是一个用于 iOS 平台的动画库,可以将 Adobe After Effects 动画导出成 iOS 应用程序,具有高性能,易用性…

作者头像 李华