单元测试在软件开发过程中扮演着关键角色,就像在汽车制造中对各个部件进行质量检测一样,确保每个组件都达到标准。很显然,单元测试是很有用且必要的。只有当每个零件都符合质量要求时,汽车才能正常工作,否则汽车很可能会出现问题。
整个软件行业已经达成了共识:单元测试是可以提高代码质量和正确性的。但是在实际的开发过程中,许多项目要么缺乏单元测试,要么测试覆盖率极低,结果导致代码中存在大量 bug,只能在集成测试阶段或产品上线后才被发现,这不仅延误了项目进度,也频繁引发线上故障。你是否曾思考过,尽管单元测试得到了广泛认可,为何在实际的开发过程中却常常被忽视?
在我参与的项目中,有些是完全没有单元测试的,另外大部分人会在 main 方法中写测试代码,这说明大家还没有编写单元测试的意识,有些人会编写少量的单元测试,但覆盖率很低,并且测试用例都是测试一些简单的工具类,只包含一些静态方法,输入和输出比较简单,也没有其它依赖。
综合来看,不写单元测试主要还是开发者能力和技术的原因:
第一,一些初级开发者还没有编写单元测试的意识;
第二,开发人员编写单元测试技能不足,无法编写测试用例;
第三,代码的可测试性不足,使得编写单元测试变得极为困难,有时甚至不可能实现。
对于前两点原因,是无法通过技术手段去解决的,而对于代码可测试性差的问题,我们是可以通过一些设计原则来解决的。下面我们首先通过两个示例说明如何提高代码的可测试性,然后再学习编写单元测试的技巧。
提高代码的可测试性的两个原则
分离不确定输入
我们先来看编写可测试代码的第一个原则:分离不确定输入。假设有一个判断今年是不是闰年的方法,它没有参数,返回值是 Boolean 类型。方法先获取当前时间,得到年份,然后判断年份是不是 4 的倍数。如果是,就是闰年。否则,不是闰年。
public boolean isLeapYear(){
Date date = new Date();
int year = 1900 + date.getYear();
return year % 4 == 0;
}
代码很简单,只需要 3 行代码。但是为这个方法编写单元测试,却不那么简单。也就是说,这个方法的可测试性很差。因为它依赖了当前时间。而当前时间是不确定的,依赖于你运行单元测试的时机。试想一下,如果要测试一个未来的时间,只能等待。如果要测试一个过去的时间,只能使用时光机回到过去啦。还有一个方式是修改系统时间,每次运行单元测试之前,先修改一下系统的当前时间,这样虽然可以测试任何的时间,但是会增加测试的成本。
这里就引出了编写可测试代码的一个原则:分离不确定输入。基于这个原则,对代码进行一些修改。将获取当前日期的逻辑从方法中移除,并给方法添加一个当前年份的参数。这样,就可以很方便地测试任意一个年份了。
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 拼接在一起的字符串。
@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 框架,都能很容易的完成单元测试。你可以参考一下我给的代码。
// 第一步,创建被测试对象
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 框架帮我们调用了它的构造函数,并传入了正确的参数。
总结 + 延伸思考
对于这篇文章我画了一张思维导图进行总结,供读者参考。
最后,我想请你思考一个问题:所有的代码都需要测试吗?既然单元测试可以提升代码的正确性,那是不是应该为所有代码都编写单元测试呢?通常情况下,不是这样的。首先,编写单元测试本身也是需要花费时间的,并非零成本。其次,对于那些非常简单、不太可能变更或一次性使用的代码,编写单元测试就不那么重要了。
最后: 下方这份完整的软件测试视频教程已经整理上传完成,需要的朋友们可以自行领取【保证100%免费】
软件测试面试文档
我们学习必然是为了找到高薪的工作,下面这些面试题是来自阿里、腾讯、字节等一线互联网大厂最新的面试资料,并且有字节大佬给出了权威的解答,刷完这一套面试资料相信大家都能找到满意的工作。