在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的理论指导,我们应该优先完成测试用例的编写,再去动手实现。
那么再来看下之前单元测试遇到的四个问题:
对于外部系统的调用,无法保证正确性;