摒弃无意义的单元测试
在ThoughtWorks经历过几个项目后,我从一个只会莽code的糙汉子变成了一个会写UT的糙汉子。写过UT,也写过集成测试,也实践过TDD,发现了一些有趣的地方,跟大家分享下。
一些基础的概念
作为一个开发,我对测试理解偏向在开发人员编写的自动测试上。其中,最常见的是单元测试(UT)和集成测试(Integration Test),另外也有维护接口契约的契约测试等等。但在这篇博客里,主要讨论的是最常见的单元测试和集成测试。
单元测试,覆盖的范围比较小,只针对一个组件(比如类),测试的目标往往是这个组件的公开方法。测试方法往往使用的是白盒测试。对于这个组件所需要的依赖,可以通过测试框架来模拟(mock)。
集成测试,覆盖的范围比较大,会将系统内的多个组件,按照实际运行时组装,运行在测试框架内,测试这些组件集成后,是否能完成业务逻辑。测试的方法也更偏向使用黑盒测试。对于这些组件所需要的依赖或外部服务,可以通过测试框架来模拟,也可以编写专门的测试类,或者直接使用专门的服务(比如内存数据库)。
一些实践的发现
事情源自我对一次返工的思考。当时的项目,因为历史原因,只在项目中采用了单元测试,所以对于开发编写的SQL语句,是否可以在数据库中正确执行,是无法在只有单元测试的自动测试阶段中检验出来的。
当时在准备Desk Check的我,望着全绿的测试报告陷入沉思:为什么还有漏网之鱼?!
从这个例子可以看出,在应用服务的开发的过程中,我们无法避免我们的应用与外部服务(例如数据库、Web Service等)的交互。而对这些外部服务的交互,我们往往依赖于框架。我们可以mock框架里接口的输出,但是无法确保我们的输入是否正确。比如,开发编写的一条SQL,除非将其运行在真正的数据库服务中,否则我们无法保证这条SQL是否可以正确的运行,或者满足我们的业务需求。
单元测试的局限性不仅仅这一点,AOP做为OOP的重要补充,广泛的应用在我们的开发过程中。针对AOP逻辑(比如参数校验、权限校验等)的测试,是无法通过单元测试完成的,因为AOP的代码在被测试代码之外。
还有,单元测试往往使用白盒测试的方法,比如在Controller的单元测试中,会检查是否调用了某个Service的某个方法。但如果在重构中,这个Service的这方法的签名,或者返回值发生了变化,面对着测试中几十上百个编译错误,你是否突然觉得原来的代码也挺眉清目秀的?
最后,我在重构的过程中,发现了很多方法中的部分分支,只会在单元测试中被调用,并没有在实际业务中运行过。也就是说,我辛辛苦苦看明白的一大段代码,没!卵!用!结果,只能在沧海桑田的感慨中,含泪删除。
所以,从我经历过的例子中可以看出,如果仅仅依靠单元测试来保证应用服务的正确性,那么就会出现以下问题:
- 对于外部系统的调用,无法保证相关接口输入的正确性;
- 无法保证AOP功能的正确性;
- 重构难度大,不适合敏捷实践;
- 缺乏大局观,存在过度设计的可能;
那么,在采用集成测试后,情况是否能得到好转呢?
集成测试的应用
一开始,我使用集成测试,只是为了检查编写的SQL是否可以正确的运行:将H2内存数据库集成到测试中,启动Spring容器,只加载Repository实例并运行。
然后我就发现:我可以将连接着H2数据库的Repository实例注入到Service中,这样我就可以省去一些在ServiceTest中对于Repository的mock。
接着,我又尝试将注入了真实Repository的Service注入到Controller中,也就是说几乎将应用服务完整的运行在测试容器中。那么我只需要拼接一个HTTP请求并传入,就可以从这个运行在测试容器的应用服务中得到HTTP响应。
这时,我意识到:如果把应用服务看作一个大的组件,把它对外提供的RESTFul API看作组件的公开方法。那么我们更应该关注这些公开方法的输入输出,而不是其内部组件的实现。那么我们更应该mock的是应用服务所依赖的外部服务,而不是内部的私有方法。
如此看来,那些针对Controller、Service、Repository的单元测试,通通可以摒弃!只需要拼接一个HTTP请求,发送到运行在测试容器中的应用服务,校验返回值,检查内存数据库中数据的变更。这些测试用例,是可以参考QA小姐姐们的。依据TDD的理论指导,我们应该优先完成测试用例的编写,再去动手实现。
那么再来看下之前单元测试遇到的四个问题:
-
对于外部系统的调用,无法保证正确性;
对于数据库服务来说,在集成测试中,往往会引入H2内存数据库来模拟真实环境中的数据库服务。一般不是太特殊的SQL,都可以在H2内存数据库中运行。
对于Web Service,我暂时还没有很好的解决方案。之前有过CXF的项目经历,在测试环境中,魔改了client,从测试文件中读取XML响应体。但这么做也无法确保我们应用的对外调用参数是否输入正确。
-
无法保证AOP功能的正确性;
在集成测试中,整个应用服务都已经运行起来,所有AOP都是正常工作的,通过调整请求中的参数和头信息,就可以触发AOP的拦截,进而检查AOP逻辑的正确性。
-
重构难度大,不适合敏捷实践;
在集成测试中,所有的测试用例只在应用服务的外部检查,并不依赖内部的实现,所以如果重构时,对外的接口没有变化,无需修改测试用例,只需要完成实现的重构即可。
-
缺乏大局观,存在过度设计的可能;
如果我们的测试用例完整的覆盖了业务需求,那么运行过这些测试用例后,还存在着没有行覆盖到的代码,那么这些代码就是过度设计的代码,可以考虑删除或者检查测试用例是否存在缺失。
带来的挑战
集成测试可以解决很多单元测试无法解决的问题,但也会带来新的挑战:
-
对于卡片,要拆分为前端卡与后端卡甚至更多的有着更多技术细节的子卡。在这些子卡中,BA需要清楚地认识到,想要达成业务需求,接口的格式应该是怎样,接口调用前后的数据变化。这些技术细节可以依赖团队里的TL或Sr Dev。
这样的实践,有些传统开发中概要设计的味道。虽然很多情况下,我们不会将卡片拆至如此细的粒度,但是这么做,可以更早的意识到这张卡的依赖项,同时也可以方便QA,针对这个接口设计测试用例。
-
由于集成测试中的测试用例可以完全来自QA,如果这些测试用例完全来自QA,可能需要QA摸索出一条新的工作节奏。如果这些测试用例完全来自开发,QA再独立写一套,那么可能会存在重复工作的现象。如果测试用例由开发编写,再由QA审核,这可能是个好实践,但我还没有尝试过。
-
在后端技术栈中,我们会使用数据库版本管理工具来管理数据库版本。在Java的技术栈中,通常我们会使用Flyway。但Flyway的一个局限性是就是过度依赖SQL,这使得一些DDL可以运行在真实环境中数据库,但却无法运行在H2数据库。所以在这里,我推荐Liquibase,这个框架会对数据库的更新做出自己的抽象,可以做到一个脚本运行在多种厂商的数据库,更适合集成测试的场景。
-
由于集成测试要启动一个真实的容器,所以自动测试时间也会更长,构建时间也会更长,不过还是在可以接受的范围内。
重申下适用范围
尽管我这篇博客的主题是呼吁大家摒弃无意义的单元测试,但这是建立在我们所经历的大部分工作,都是针对接口的开发。在这样的工作中,单元测试有着很大的局限性,而集成测试有着更好的匹配度。
但如果你在开发一个类库,或者在DDD建模的早期,在这些场景中,单元测试才是更好的选择。