news 2026/2/17 10:15:02

如何培养单元测试的习惯?怎样才算一个好的单元测试?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
如何培养单元测试的习惯?怎样才算一个好的单元测试?

你是怎么编写单元测试的呢?很多人的做法是先把所有的功能代码都写完,然后,再针对写好的代码一点一点地补写测试。

在这种编写测试的做法中,单元测试扮演着非常不受人待见的角色。你的整个功能代码都写完了,再去写测试就成了一件为了应付差事不得不做的事情。更关键的一点是,你编写的这些代码可能是你几天的工作量,你已经很难记得在编写这堆代码时所有的细节了,这个时候补写的测试对提升代码质量的帮助已经不是很大了。

所以,想要写好单元测试,最后补测试的做法总是很糟糕的,仅仅比不写测试好一点。你要想写好单元测试的话, 最好能够将代码和测试一起写。

你或许会说,我在功能写完后立即就补测试了,这不就是代码和测试一起写的吗?其中的差异在于,把所有的功能写完的这个粒度实在是太大了。为一个大任务编写测试,是一件难度非常大的事,这也是很多人觉得测试难写的重要因素。要想做好单元测试,关键就是工作的粒度要小。

I’m not a great programmer; I’m just a good programmer with great habits.

我不是一个伟大的程序员,只是一个有着好习惯的优秀程序员。

—— Kent Beck

任务分解是每个程序员都应该拥有的好习惯,即便你 想写好单元测试也要从任务分解开始。所以,你需要把一个要完成的需求拆分成很多颗粒度很小的任务。粒度要小到可以在很短时间内完成,比如,半个小时就可以写完。只有能够把任务分解成微操作,我们才能够认清有足够的心力思考其中的每个细节。千万不要高估自己对于任务把控的粒度, 一定要把任务分解到非常小,这是能够写好代码,写好测试的前提条件,甚至可以说是最关键的因素。

当我们把需求拆分成颗粒度很小的任务时,我们才开始进入到编码的状态。而从这里开始,我们进入到代码和测试一起写的状态。

编写单元测试的过程

对于一个具体的任务,我们首先要弄清楚的是,怎么样算是完成了。一个完整的需求我们需要知道其验收标准是什么。具体到一个任务,虽然没有业务人员给我们提供验收标准,我们自己也要有一个验收标准,我们要能够去衡量怎么样才算是这个代码写合格了。

经过我们这一系列关于测试的介绍,你应该已经知道我要说什么了:一个任务的代码要通过测试才算编码阶段的完成。

但测试用例从哪来呢?这就需要我们设计了。不同于业务测试的测试用例,我们现在要写的是单元测试。而我们要测的单元现在还没有写,所以,没有人会给我们提供测试用例,单元测试的用例只能我们自己来。

还记得我们在实战里怎么做的添加 Todo 项吗?接下来,我们就结合这个部分来谈谈具体怎么做。

我们首先要确定的是待测单元的行为,也就是要实现的类里的一个函数,它的行为是什么样的。或许你已经发现了,这其实就是一个软件设计的过程。这里的设计指的是微观的设计,就是具体的一个函数准备写成什么样子。通常到了动手写代码这一步,大的设计已经在前面做完了。

因为我们现在不仅仅要写代码,还要写测试。所以,我们在设计这个函数接口时,还必须增加一点考量:它要怎么测。

在添加一个 Todo 项时,我们经过设计出来的函数接口就是下面这样。

TodoItem addTodoItem(final TodoParameter todoParameter);

有了一个具体的函数接口设计,我们就可以针对它进行更具体的测试用例设计,也就是设计测试用例来描述这个接口的行为。

是的,这里我们并没有着急写代码。对很多人来说,写代码的优先级很高,但是,如果不在这里停一下的话,你可能就不会去思考是否还有要考虑的问题,而是直奔代码细节去了。而当我们专注于细节时,有限的注意力就会让你忽略掉很多东西。所以, 先设计测试用例,后写代码,这是一个编码习惯的问题。

有了添加 Todo 项接口之后,我们就准备了两个测试场景:

添加正常的参数对象,返回一个创建好的 Todo 项;

有了测试场景,接下来把这些场景实例化出来,这个步骤相对来说就比较简单了。比如,对于添加正常的参数对象来说,那什么样的参数对象是正常的?我们就代入一个具体的正常参数(比如 foo)。有了这个实例化过的参数,我们就可以把具体的测试用例表现出来了。

  1. @Test

  2. public void should_add_todo_item() {

  3. TodoItemRepository repository = mock(TodoItemRepository.class);

  4. when(repository.save(any())).then(returnsFirstArg());

  5. TodoItemService service = new TodoItemService(repository);

  6. TodoItem item = service.addTodoItem(new TodoParameter("foo"));

  7. assertThat(item.getContent()).isEqualTo("foo");

  8. }

在实际的工作中,究竟是先写测试,还是先写实现代码,这是个人工作习惯的问题。当我们有了测试用例之后,其实就是把一个具体的任务进一步拆分成更小的子任务了。只要我们完成一个子任务,我们就可以做一次代码的提交,因为我们这个时候,既有测试代码又有实现代码,而且实现代码是通过了测试的。

测接口还是测实现?

不知道你是否注意到了,在前面我一直在说,我们要测的是函数接口的行为。我一直说,单元测试是一种白盒测试。在一些人的理解中,白盒测试的关注点应该是内部实现。那单元测试到底应该关注接口,还是应该关注实现呢?

或许你还不清楚二者之间的区别,让我们把前面添加 Todo 项的例子拿过来。如果采用更加面向实现的做法,我们应该对 addTodoItem 这个函数的内部实现有进一步的约束,就像下面这样。

  1. @Test

  2. public void should_add_todo_item() {

  3. TodoItemRepository repository = mock(TodoItemRepository.class);

  4. when(repository.save(any())).then(returnsFirstArg());

  5. TodoItemService service = new TodoItemService(repository);

  6. TodoItem item = service.addTodoItem(new TodoParameter("foo"));

  7. assertThat(item.getContent()).isEqualTo("foo");

  8. verify(repository).save(any());

  9. }

这段代码中核心的差别就是增加了一句 verify,这也就意味着,我规定在 addTodoItem 的实现中必须要调用 repository 的 save 函数。

你或许会好奇,repository 本来就要调用 save 方法,那我在这里校验它调用了 save 方法,似乎也没什么大不了的。

单独这么看确实看不出什么问题,但是,如果你有很多测试都是这么写,当你准备重构时,你就会发现问题了。很多团队代码一调整,测试就失败,一个重要的原因就是代码实现和测试之间紧紧地绑定在了一起。因为测试约束的是实现细节,而只要调整实现细节,测试当然就失败了。这也是很多团队抱怨单元测试问题很多的重要原因。

所以, 在实际的项目中,我会更倾向于测试接口,尽可能减少对于实现细节的约束。其实,这个原则不仅仅是在接口层面上,在一些测试的细节上也可以这么约定,比如下面这行代码。

when(repository.save(any())).then(returnsFirstArg());

这其实是一种宽泛的写法,所以用了 any。如果严格限制的话,应该严格限定一个非常具体的参数。

when(repository.save(new TodoItem("foo"))).then(returnsFirstArg());

使用 Moco框架,我们设置模拟服务器可以设置得非常具体,像下面这样。​​​​​​​

  1. server

  2. .request(and(by("foo"), by(uri("/foo"))))

  3. .response(and(with(text("bar")), status(200)));

也可以设置得非常宽泛,像这样。

server.request(by(uri("/foo"))).response("bar");

除非这个测试里面有多个类似的请求,必须要做区分,否则,我倾向于使用宽泛一些的约束。这在某种程度上会降低未来重构代码时带来的影响。

不过实话说,要想完全消除对于实现细节的依赖,有时候也是很难的。比如在我们前面的 TodoItemService 的例子里面,repository 本身也是 TodoItemService 的一种实现细节,一旦进行一些重构,把 repository 的依赖从 TodoItemService 中拿掉,很多测试代码也需要调整。所以,在实际的项目中,我们只能说尽可能减少对于实现细节的依赖。

其实,关于实现细节的测试也是一种重复,等于你用测试把代码又重新写了一遍。程序员的工作中有一种重要的原则:DRY(Don’t Repeat Yourself),这不仅仅是说代码中不要有重复,而且各种信息都不要重复。

总结

很多团队由于多方面的原因(比如设计做得不好),导致单元测试写得少。但为了提高代码质量以及更准确地定位问题,我们应该多写单元测试。

单元测试最好是和实现代码一起写,以便减少后续补测试的痛苦。想写好测试,关键要做好任务分解,否则,面对一个巨大的需求,没有人知道如何去给它写单元测试。

编写单元测试的过程,实际上就是一个任务开发的过程。一个任务代码的完成,不仅仅是写了实现代码,还要通过相应的测试。一般而言,任务开发要先设计相应的接口,确定其行为,然后根据这个接口设计相应的测试用例,最后,把这些用例实例化成一个个具体的单元测试。

单元测试常见的一个问题是代码一重构,单元测试就崩溃。这很大程度上是由于测试对实现细节的依赖过于紧密。一般来说,单元测试最好是面向接口行为来设计,因为这是一个更宽泛的要求。其实,在测试中的很多细节也可以考虑设置得宽泛一些,比如模拟对象的设置、模拟服务器的设置等等。

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

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

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

[CTF]攻防世界:web-unfinish(sql二次注入)

题目:web-unfinish(sql二次注入)二次注入打开页面是一个登录页面步骤 扫描一下目录:有登录有注册先测试登录是否存在sql,测试了一下发现似乎不存在。继续测试注册,先正常注册一个用户,登录看看。…

作者头像 李华
网站建设 2026/2/11 8:24:17

高吞吐场景下 Kafka 消费者积压问题排查与解决

在大数据架构中,Kafka 凭借高吞吐、低延迟的特性成为消息队列的核心组件,广泛应用于日志收集、实时数据传输等场景。然而,当业务流量迎来峰值(如电商大促、直播带货爆发)时,消费者端常出现消息积压问题——…

作者头像 李华
网站建设 2026/2/7 21:21:17

Charticulator终极指南:零代码打造专业级数据可视化图表

Charticulator终极指南:零代码打造专业级数据可视化图表 【免费下载链接】charticulator Interactive Layout-Aware Construction of Bespoke Charts 项目地址: https://gitcode.com/gh_mirrors/ch/charticulator 想要快速创建精美数据可视化却苦于编程门槛&…

作者头像 李华
网站建设 2026/2/6 1:56:37

四旋翼的ADRC姿态控制总给人一种“玄学调参“的错觉,其实从模型到代码落地,整个过程比想象中有意思得多。咱先甩出核心公式——滚转通道的角加速度方程

四旋翼无人机ADRC姿态控制器仿真,已调好,附带相关参考文献~ 无人机姿态模型,力矩方程,角运动方程 包含三个姿态角的数学模型,以及三个adrc控制器。 简洁易懂,也可自行替换其他控制器。 \dot{p}…

作者头像 李华
网站建设 2026/2/17 4:13:56

鸿蒙 Electron 深度整合:从桌面应用到鸿蒙全场景的进阶实践

开发者还需要面对鸿蒙分布式能力的深度调用、Electron 与鸿蒙的数据双向同步、跨端权限管理等进阶问题。本文将聚焦这些核心痛点,通过实战代码案例,展示鸿蒙 Electron 整合的进阶玩法,帮助开发者打造真正的全场景跨端应用。一、进阶整合的核心…

作者头像 李华
网站建设 2026/2/7 21:47:52

Wi-Fi CERTIFIED Optimized Connectivity™ 技术概述

引言 Wi-Fi CERTIFIED Optimized Connectivity 是一个 Wi-Fi Alliance 认证计划,它提供的功能可以优化发现 Wi-Fi 网络的过程,并在进出网络以及各网络之间建立连接。通过减少信令负载,这些优化还可以为所有连接到网络的用户带来更高的网络容量和更好的体验质量。 移动设备…

作者头像 李华