深入探讨单元测试:开发者如何高效编写优质单测并提升代码质量

很多开发者都在苦恼如何编写单元测试,因此本文将为大家提供一些实用的指南。


单元测试的真正含义是什么?

这个问题表面上看似简单,单元测试通常指开发者为自己编写的代码逻辑撰写测试类,以验证其正确性。然而,这个定义并不够精准。

我们必须注意到,“单元测试”中的“单元”二字,其本质在于测试的粒度要足够小。这意味着一个测试应只关注一个方法的逻辑。这是理解单元测试的关键所在。

市面上常见的错误单元测试例子是:

启动整个项目,进行真实调用,使用数据库中的实际值作为入参,进行一条龙操作,从 controller 到 service 再到 dao,全部贯通。

这样的测试实际上属于集成测试而非单元测试。如果你的测试类依赖于数据库、缓存、其他团队的服务或者第三方开放平台的 HTTP 服务,那么你的测试就变得脆弱。一旦外部因素出现问题,你的测试结果也会随之失败。

入参和出参不应受到外部影响,理想情况下,我们需要控制其他团队的服务以返回我们所需的特定数据。例如,假设我希望测试依赖的服务 A 返回成功时我的代码逻辑是否正确,同时还希望测试服务 A 返回失败和为空的逻辑。此外,数据库或缓存的返回值也需要控制,这无疑增加了复杂性,令人望而却步。

通常情况下,启动整个项目所需的时间可能长达几分钟甚至数十分钟,这使得开发者在写两个单元测试时可能会花费整个下午的时间在调试启动上。因此,许多程序员感到单元测试难以编写,既耗时又容易出现失败。

所以,究竟什么是单元测试?

在 Java 中,单元测试的对象是类中的某个方法。一个测试只应关注该方法的逻辑正确性,无需关注外部逻辑。

以测试 trainingYes 方法为例,该方法内部依赖 yesDaoOneOneZeroProvider,前者是数据库操作,后者是 RPC 服务。

在这种情况下,我们要保持的思维是,无论传入的 ID 在数据库中对应的 yes 数据是什么,我们都应该能够让 yesDao 返回 null,或返回其他非 null 的值。对 OneOneZeroProvider 也一样,我们应能够控制其返回为 false 或 true。

因为数据库和外部服务的逻辑与当前的服务方法无关,我们只需获取所需的值以测试方法内部的逻辑分支。只有如此,我们才能轻松验证我们编写的代码逻辑。

想象一下,如果你在测试 trainingYes 时还需关注哪个 ID 能获取值,那么 yesDao#getYesById 内的逻辑、状态过滤等都会让你感到疲惫不堪。更糟糕的是,若要确保 OneOneZeroProvider#call 返回 true 或 false,这就更具挑战性,因为你可能根本无法访问该服务的代码。

总之,单元测试应仅关注自身方法的内部逻辑,而无需考虑外部依赖。

许多读者或许会疑惑,这该如何实现?毕竟我的代码确实依赖于其他服务。

这就涉及到了 mock 的概念。

Mock 即伪造一个假的依赖服务,替代真实服务。在上面的例子中,我们需要创建伪造的 yesDaoOneOneZeroProvider,从而控制其返回值,满足我们对 trainingYes 的测试需求。

yesDao 为例,我们可以这样进行 mock:

图片

然后在单元测试中,通过反射或 set 注入的方式将 MockYesDao 注入到测试中的 YesService,这样就可以控制逻辑。

例如,当传入 ID 为 1 时,我们必定能获取到一个非 null 的 yes 对象,而传入其他值时则应返回 null,这样就能够轻松控制我们想要测试的逻辑。

当然,上述只是说明 mock 的具体作用。在实际的单测中,开发者通常不会手动编写 mock 服务,而是使用 mock 框架,例如常用的 Mockito,这将在后文中进一步探讨。

如何更高效地编写单元测试

在掌握了单元测试的基本概念后,我们需要进一步探讨编写单元测试的挑战。

单元测试难写的核心原因在于代码本身的解耦程度不足。

有人可能会质疑,单元测试难以编写的原因难道不是业务逻辑复杂吗?

实际上,逻辑简单的类通常并不需要编写单元测试,除非是为了追求覆盖率而强加的要求。例如,studentService.getStudentById(Long id) 这样的简单方法,虽然可以为其编写单元测试,但收益却极其有限。

单元测试的价值在于针对复杂场景进行验证,尤其是在开发周期紧迫时,核心且易错的逻辑更值得关注。

回到单元测试难写的问题,从专业的角度来看,代码的可测试性不足是导致难以编写单元测试的主要原因。

可测试性不足的代码表现为:

  • 使用静态方法,导致难以 mock 替换。
  • 直接在内部创建依赖,形成强依赖,无法进行 mock。
  • 继承关系复杂,测试当前类的方法逻辑时需关注父类逻辑。
  • 全局变量影响测试结果,尤其在并发执行测试时。
  • 时间等未决行为造成测试失败。

虽然上述情况并不意味着这些代码无法存在,但它们确实会使单元测试变得更为复杂。

单元测试示例

经过前面的理论分析后,接下来我们将进行实际操作。以下是针对 trainingYes 方法的单元测试示例,使用了 Mockito 测试框架。

通过注解,我们 mock 了所需的 dao 和 provider,并将其注入到待测的 yesService 中:

图片

具体逻辑中,我编写了四个方法来进行测试,使用 when(xxxx).thenReturn(xxx) 设置 mock 行为,确保测试能顺利进行:

图片

执行测试后,我们可以看到,运行时间仅为 59 ms,且无需启动 Spring 框架。

图片

通过 mock 方法,我们成功忽略了依赖服务的逻辑,使得测试更加简洁和高效。

结语

了解了如何编写单元测试和面临的挑战后,我们的思路已经日渐清晰。然而,现实常常残酷。

许多老旧代码增加了单元测试的难度,而项目紧迫的进度使得单元测试常常被搁置。尽管从长远来看,单元测试带来的好处会显著提高开发和维护效率,但许多开发者仍然会面临来自领导的压力,例如要求在短时间内提升代码覆盖率。

在此,我认为对于已经稳定的代码,除非有显著改动,通常不必强制编写单元测试,尤其是在没有时间的情况下。而在新功能开发时,尽量同步编写单元测试可以帮助理清思路,提升代码质量。

要记住,编写单元测试时不应过于关注内部实现。例如,在简单的加法运算中,我们只需确认 add(1, 1) 的返回结果为 2,而非关注其内部实现细节。

希望本篇文章能够帮助你更好地理解单元测试,并在实际工作中能有所收获。