首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >想提高代码质量?教你用Mock框架编写单元测试

想提高代码质量?教你用Mock框架编写单元测试

原创
作者头像
写bug的高哈哈
发布2024-11-02 08:52:06
发布2024-11-02 08:52:06
8780
举报

在软件开发的生命周期中,单元测试扮演着至关重要的角色,它犹如汽车制造过程中的部件质量检测,确保每个组件都符合既定标准。显然,单元测试的必要性不容忽视,只有当每个软件元素都经过严格的质量把关,整个系统才能稳定运行。否则,任何潜在的问题都可能导致整个系统出现故障。

软件行业普遍认同单元测试是提高代码质量与准确性的关键步骤。然而,在实际项目开发中,单元测试的缺失或低覆盖率依然屡见不鲜,这直接导致了许多bug在产品上线后才被发现,不仅延误了项目进度,还频繁引发线上问题。那么,为何在广泛认可其价值的同时,单元测试在实际操作中仍常被忽视?

在我参与的项目中,有些项目完全缺失单元测试,而大部分开发者倾向于在main方法中直接编写测试代码,这实际上反映了开发者对单元测试的忽视。即便有些项目包含少量单元测试,也往往局限于简单的工具类或静态方法,测试用例简单且缺乏足够的覆盖度,尤其是针对复杂依赖的处理。

综合来看,不写单元测试主要的原因主要有三:一是初级开发者对单元测试的认知不足;二是开发者的单元测试编写技能有限;三是代码本身的可测试性较差,编写单元测试面临巨大挑战。针对前两点,需要通过培训来提升开发者的意识和技能;而对于代码可测试性问题,则可通过设计原则加以改善。

提升代码可测试性的关键原则

分离不确定输入

我们首先来看编写可测试代码的第一个原则:分离不确定输入。假设有一个判断当前年份是否为闰年的方法,它没有参数,返回布尔值。该方法首先获取当前时间,然后根据年份判断是否为闰年。如果年份是 4 的倍数,就是闰年。否则,不是闰年。

代码语言:java
复制
public boolean isLeapYear(){
    Date date = new Date();
    int year = 1900 + date.getYear();
    return year % 4 == 0;
}

这个方法虽然简单,但编写单元测试却不太容易,因为它依赖于当前时间,而当前时间是不确定的,取决于你运行单元测试的时机。这个方法的可测试性很差。试想一下,如果要测试一个未来的时间,只能等待。如果要测试一个过去的时间,只能使用时光机回到过去啦。还有一个方式是修改系统时间,每次运行单元测试之前,先修改一下系统的当前时间,这样虽然可以测试任何的时间,但是会增加测试的成本。

这里就引出了编写可测试代码的一个原则:分离不确定输入。基于这个原则,对代码进行一些修改。我们可以将获取当前日期的逻辑从方法中移除,并为方法添加一个当前年份的参数,这样就可以方便地测试任何年份了。

代码语言:java
复制
public boolean isLeapYear(int year){
    return year % 4 == 0;
}

你可能已经注意到了,仅根据年份是不是 4 的倍数来判断闰年是不严谨的。闰年的计算方法是:四年一闰,百年不闰,四百年再闰。比如 2008 年是闰年,1900 年不闰,2000 年是闰年,所以会存在一些特殊年份,在编写单元测试时,需要覆盖这些特殊的年份,也就是边界值。比较典型的边界值有空值,Null 值,零值等。敲黑板了,编写单元测试要覆盖边界值。

面向抽象编程,而非具体实现

编写可测试代码的第二个原则是面向抽象编程,而不是具体实现。这其实是在面向对象程序设计中的一个原则。它可以提高代码的可扩展性,让我们很灵活地替换具体的实现。同样,这个原则可以提高代码的可测试性。

你可以思考一个例子,有一个爬虫程序,它会爬取淘宝网的商品信息,如果发现淘宝网页面访问失败时,会重试三次,每次间隔 10 秒钟。如果是一个初级开发者,可能会直接使用 new 关键字创建一个 HttpClient,然后使用它来访问淘宝网。这样的代码足够简单,也能够实现功能。那么假设需要对它编写单元测试,验证当访问淘宝失败时,是否会最多重试三次,且每次间隔 10 秒钟。这时候,你会发现,为它编写单元测试是多么的困难。

你面临的一个关键问题是,如何让访问淘宝网返回 500 错误呢?这的确是一个世纪难题,你可以给马云打电话,说我们在进行单元测试,让他配合一下,暂时关闭淘宝网几分钟,等我们测试完了再恢复。当然,我们不一定非得麻烦马老师,也可以配置本地 DNS,将淘宝网的域名指向一个错误的 IP,或者修改 HttpClient 的代码,对淘宝网的请求进行特殊处理。

你发现了吗?这个爬虫程序几乎不可测试,根本原因就是它通过 new 创建了一个 HttpClient 的具体实现,它是面向具体实现编程,而不是面向抽象编程的。

其实,几乎所有的项目中,都会有这样的代码。比如在构造函数中使用 new 创建一个具体实现,在方法中 new 一个局部变量。当你发现由于使用了 new,而导致代码很难测试时,你就要考虑使用抽象的接口来替换它们了。正常的代码是需要在生产环境运行的,而在单元测试这个上下文中,代码运行的环境是不一样的。这就需要代码是基于抽象的,当它在生产环境运行时,使用正常的环境,而当在单元测试中运行时,可以通过某种手段将其替换为一个方便测试的特殊实现。这种技巧被称为 Mock,下面我会具体说明。现在,请你记住面向抽象,而不是具体实现,这是编写可测试代码的基础。

现在我们了解了编写可测试代码的两个原则:分离不确定输入和面向抽象编程。当然影响代码可测试性的因素很多,相信你遵守了这两个原则后,你就可以编写可测试的代码了。代码已经可测试了,那单元测试该怎么写呢?下面我就和你聊聊编写单元测试的一些技巧,主要是 Mock 框架的使用。

编写单元测试的技巧

使用 Mock 框架

刚才,我们举了一个判断闰年的例子。它比较简单,有简单的输入和简单的输出,并且没有任何其他依赖。但在真实场景中,往往更加复杂。类之间有相互依赖,以及依赖一些框架、数据库、缓存、消息队列等。这给编写可测试代码和单元测试带来了巨大的挑战。接下来,我们举一个经典的使用 Spring MVC 框架的三层架构应用示例,说明如何在实际项目中编写单元测试。

我们来看这段代码,假设有一个用户 Service 类,它是一个 Spring Bean。通过@Autowired 注入了一个 private 变量 UserDao,用于操作数据库。Service 类有一个 save 方法,调用 DAO 对象的 insert 方法。第一个参数是用户的 ID,第二个参数是把用户的 firstName 和 lastName 拼接在一起的字符串。

代码语言:java
复制
@Service
public class UserService{
    @Autowired
    private IUserDao userDao;
    public void save(User user){
        userDao.insert(user.id, user.firstName + " " + user.lastName());
    }
}

这是一个特别经典的例子,你几乎可以在所有使用 Spring 框架的项目中都能看到它。那么对于这样一个类,该如何测试呢?

在我们编写单元测试之前,首先需要回答关于单元测试的三个基本问题:

第一个问题:单元测试测什么?如果方法没有返回值,我们到底要测试什么?

第二个问题:如果类有外部的依赖,即便当前类逻辑正确,如果外部类有 Bug,也会导致当前类不能正常工作,所以,编写单元测试时,如何处理依赖的行为不符合预期的情况?

第三个问题:被测试类依赖 Spring 框架,依赖数据库。如何在运行单元测试时启动 Spring 容器和数据库呢?

这三个问题困扰了很多开发者。如果你也有这样的疑惑,下面可要认真听了。

第一个问题,单元测试是验证类的行为是否符合预期,类的行为有很多,方法的返回值只是其中一种情况,其他的行为还有操作数据库、调用其他服务、抛出异常等。在实际项目中,一般会验证类是否正确地调用了其他依赖,并且参数和调用次数是符合预期的。

第二个问题,对于一个有外部依赖的类,单元测试需要保证的是“当类的所有依赖都能够正常工作的情况下,被测试类就能够正常工作”。所以,编写单元测试有一个基础的前置条件,那就是“类的所有依赖都是正确的”。

第三,单元测试不能够启动 Spring 容器,不能连接数据库,启动 Spring 容器和连接数据库是集成测试阶段所需要的。

现在我们解决了这三个问题,再来想想如何写这个单元测试。

首先,要验证 save 方法调用了 DAO 对象的 insert 方法,且只调用了一次,并且参数依次是 ID,firstName 和 lastName 拼接的字符串,这是预期的行为。其次,单元测试不能够启动 Spring 容器,也不能够连接数据库。

如果不启动 Spring 容器,UserDao 是不能被初始化的,它的值为 Null。当然我们可以为了测试专门写一个 UserDao 的实现。但是怎么断言 insert 方法被执行了一次,且参数是对的呢?这时候就需要使用 Mock 框架了。

Mock 是单元测试中经常使用的技术。Mock 就是“假”的意思,它可以基于一个接口或类来生成一个假的对象。并且可以对假对象进行 Stub(也称为打桩)。比如当方法的入参是“什么”的时候,返回值是“什么”。我们还可以断言假对象的某个方法是否被执行了,执行了几次,执行的参数是什么。你看,Mock 技术刚好满足我们的需求。

接下来,我们就使用 Mock 技术来编写单元测试。

第一步:创建被测试对象的一个实例,就是 new 一个新的 UserService。

第二步:创建 Mock 对象,就是模拟一个假的 UserDao 对象,并传递给 UserService。

第三步:对假对象进行打桩,即调用假对象的 insert 方法时,该做什么。这里什么都不用做。

第四步:对假对象进行断言,判断假对象的 insert 方法是否执行了,并且参数是否符合预期。

大部分语言都有成熟的 Mock 框架,如果你使用 Java,我推荐你使用 Mockito 框架。它的功能完善,API 比较友好,大部分开源框架包括 Spring 都是使用它进行单元测试。按照刚才我说的 4 个步骤,选用任何一种 Mock 框架,都能很容易的完成单元测试。你可以参考一下我给的代码。

代码语言:java
复制
// 第一步,创建被测试对象
UserService userService = new UserService();
// 第二步,创建 Mock 对象
IUserDao mockDao = mock(IUserDao.class);
// 第三步,对假对象进行打桩
when(mockDao).insert(anyString(),anyString()).doNothing();
... Use reflection to inject mockDao into userService
// 第四步,对假对象进行断言
userService.save(new User("123", "hello", "world"));
verify(mockDao).insert(eq("123", eq("hello world")));

尽量使用 POJO 类

现在,我们已经使用 Mock 框架,完成了 UserService 的单元测试。这样就完事了吗?你有没有发现我们遗留了一个小问题?

UserService 使用了@Autowired 来注入依赖,也就是字段注入。相信大部分开发人员都会使用这种方式来注入依赖,因为这样代码比较简洁,加一个@Autowired 注解就可以了。但是如果你细心的话就会发现,IDEA 会有一个大大的 Warnning,提示字段注入是不推荐的,而应该使用构造函数注入。

你知道这是为什么吗?明明添加一个@Autowired 就可以完成注入,如果使用构造函数注入,需要多写很多的代码。我在面试的时候,问了很多候选人这个问题,能回答上来的人不多,你知道原因吗?为什么 IDEA 不推荐 Spring 的字段注入呢?

其实在刚才的例子中,已经给出了答案。字段注入会导致类严重依赖于 Spring 框架。如果你将所有 Spring 相关的注解,比如@Service、@Autowired 全部去掉,你会发现,失去 Spring 支持的 UserService 有一个严重的问题,那就是没有任何办法对它的 private 字段赋值,也就是说它们会一直为 Null。唯一能够赋值的方式是使用反射,在使用 Mock 框架时,需要使用反射将假对象赋值给 UserService 的 private 字段,增加了测试的难度,降低了类的可测试性。

如果使用构造函数注入,就不会有这个问题。可以通过构造函数将 Mock 对象传递给真实对象。使用构造函数注入的 UserService,即便将所有 Spring 注解都去掉,它依然是一个正确的 POJO 类,可以独立工作。它没有和 Spring 强耦合,只是 Spring 框架帮我们调用了它的构造函数,并传入了正确的参数。

总结 + 延伸思考

对于这篇文章我画了一张思维导图进行总结,供读者参考。

最后,我想请你思考一个问题:所有的代码都需要测试吗?既然单元测试可以提升代码的正确性,那是不是应该为所有代码都编写单元测试呢?通常情况下,不是这样的。首先,编写单元测试本身也是需要花费时间的,并非零成本。其次,对于那些非常简单、不太可能变更或一次性使用的代码,编写单元测试就不那么重要了。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 提升代码可测试性的关键原则
    • 分离不确定输入
    • 面向抽象编程,而非具体实现
  • 编写单元测试的技巧
    • 使用 Mock 框架
    • 尽量使用 POJO 类
  • 总结 + 延伸思考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档