怎么使用单元测试提升代码质量
单元测试在软件开发过程中扮演着关键角色就像在汽车制造中对各个部件进行质量检测一样确保每个组件都达到标准。很显然单元测试是很有用且必要的。只有当每个零件都符合质量要求时汽车才能正常工作否则汽车很可能会出现问题。整个软件行业已经达成了共识单元测试是可以提高代码质量和正确性的。但是在实际的开发过程中许多项目要么缺乏单元测试要么测试覆盖率极低结果导致代码中存在大量 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 方法且只调用了一次并且参数依次是 IDfirstName 和 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%免费】软件测试面试文档我们学习必然是为了找到高薪的工作下面这些面试题是来自阿里、腾讯、字节等一线互联网大厂最新的面试资料并且有字节大佬给出了权威的解答刷完这一套面试资料相信大家都能找到满意的工作。