单元测试是非常重要的,我认为编写单元测试是程序员需要最自觉的一件事,也就是就算没有外部要求及约束的情况下,也要主动编写单元测试。

没有单元测试的项目,最终都不可避免地滑向代码难以维护的深渊。

今天,就稍微聊一下在单元测试中,如何处理第三方依赖这个小的点吧。最近晨跑时突然想到这个并总结了下,于是想着用文字把自己的思考记录下来。

不可避免的第三方依赖

任何一个项目,一定都会有第三方依赖,这些依赖有可能是技术类,比如数据库,缓存等;也有一些是外部系统提供的接口或服务;当然也有一些框架等。

而单元测试的目的主要是证明你写的某一小块代码是否是合理与正确的,但问题在于,可能任何一小块功能实现,都耦合着一个第三方依赖,举例说明:

所以,就单元测试来说,处理这些第三方依赖有着困难性。

第三方依赖带来的困难

编写单元测试时,众多的第三方依赖会显著地给编写单元测试带来困难,主要表现在:

缺少第三方服务的测试支撑

对于第三方服务,有些可能你还可以自己控制一下,整一个,比如数据库等。或是授权等,你可以事先造好各种用户权限,再使用就行了。

但也有一些外部系统的依赖,你很难建立这样的测试支撑环境,让外部系统给你部署一个测试环境专门给你执行单元测试?有可能么?

单元测试要非常快,非常专注

单元测试只关注特定的一小块代码逻辑,这意味着需要尽量避免与排除与之无关的代码的影响。

什么叫与之无关,也就是这一块代码无法干预与控制的就属于与之无关的代码,比如上面举例的授权的正确与否,数据库操作的成功与否,查询第三方系统是否及时正确返回等,这些都是当前代码难以控制与干预的,它们都依赖于第三方。

而如果在单元测试中,无法排除这些第三方依赖带来的干扰,则意味着本身你的单元测试也是不可预测的。因为第三方依赖可能正确,可能失败,你没法正确地去断言。

因为同样的断言,如果第三方服务正常或不正常,当然结果会完全不同。

难以覆盖正确与错误的路径

很多人在编写单元测试时,仅仅编写正确的路径,甚至有些程序员,编写假的单元测试,仅仅为了达到要求的单元测试覆盖率。

后面的一种情况就不聊了,没有任何谈论的意义,就说下编写正确的路径这个行为吧,其实坦率地说,愿意编写单元测试就已经是非常不错的程序员了。

但是,仅仅编写正常路径是不够的。因为我们的业务充满了各种异常路径,比如取款时每次最多只允许5000,这就是一个错误路径,你在编写单元测试时,不能只编写小于5000的正常路径,你得有一个超过5000的,并断言会出错的。

而第三方依赖,则显著地增加了覆盖路径的难度。由于第三方依赖压根不是你能控制的,这导致你压根不可能覆盖各种路径。

增加了单元测试的总体执行时间

单元测试不仅单个要快,整个项目的单元测试也要能非常快地执行完成。比如《持续交付》这本书中就主张不能超过10分钟。因为CI/CD时,如果项目的单元测试要很久才执行完,这不利于CI/CD的快速反馈,是不合适的。

而众多的第三方依赖,则显著地加大了单元测试的时间。

想像一下吧,单元测试中,你调用了一个第三方服务提供的Rest Api接口,这个接口有点缓慢,于是这个调用等待了一些时间;你又调用了某个第三方依赖,时间又延长了。

这样积累下来,你就不会想频繁地运行单元测试了,因为时间太久了。慢慢的单元测试就会被整个项目组忽略,没有谁希望把时间总浪费在等待执行的过程中。

解决之道

当然,没有什么是不能解决的。

我对自己写的代码,有严格的单元测试覆盖率的自我要求,在我很多年的经验积累之上,我总结了几种编写单元测试中应对解决第三方依赖的措施与方法,以供参考。

总共有四个,相信我,来来去去都离不开这几种方式的。

方法一:使用Mock或Stub桩等技术

这是你首要需要考虑的方式。而事实上,对于很多外部系统提供的服务来说,这是唯一的方式。

Java语言中我最常用的就是Mockito框架,当然这种框架其实挺多的,你可以选择你喜欢的一个就是。Mock的原理很简单,针对接口提供一个虚假的实现。由于是虚假的实现,你可以随意控制它的返回。

@Test void sendEmailCode(){ var email = "lingen.liu@gmail.com"; Assertions.assertDoesNotThrow(() -> verificationCodeApplication.sendEmailCode(email)); Mockito.when(emailGateway.isMock()).thenReturn(false); Mockito.doThrow(RuntimeException.class).when(emailGateway).sendSmsToEmail(anyString(),anyString()); Assertions.assertThrows(RuntimeException.class,()->verificationCodeApplication.sendEmailCode(email)); Mockito.when(emailGateway.isMock()).thenReturn(false); Mockito.doNothing().when(emailGateway).sendSmsToEmail(anyString(),anyString()); Assertions.assertDoesNotThrow(() -> verificationCodeApplication.sendEmailCode(email)); }

Copy

比如上述我写的一个单元测试,测试邮件发送验证码,与其去真正发送一个邮件,不如mock一个邮件网关`,这样在单元测试中,我就可以方便的Mock它正确与错误的情况下,我的代码的执行是否符合预期。

当然,这有个前提,对于第三方服务,你最好面向接口编程,否则你很难Mock。

因此,单元测试除了持续地证明你的代码正确性以外,还有一个重要的作用:改善你的设计与编码实现,不好的代码与实现,对它编写单元测试都会非常困难。

方法二:使用内存或轻量级实现

Mock技术非常好用,但一些场景下它并不是非常方便,有些东西Mock起来有点麻烦。

比如数据库,Mock一个数据库的行为,并不是不可以,但有点麻烦。于是,可以考虑借助内存或轻量级实现了。这也是很方便的一种方式了。

比如H2内存数据库,我认为它是一个绝佳的提供数据库内存实现的可选方案。

我的myddd(基于整洁构架与领域驱动而构建的基础类库)及任何一个使用JPA的项目,在涉及数据库单元测试中,一律使用H2,它简单,方便,无须你关注,也不需要费劲去Mock。

@Test @Transactional void testQueryMediaByDigest(){ //createMedia的背后实现其实是H2数据库 var created = createMedia(); var query = Media.queryMediaByDigest(created.getDigest()); Assertions.assertNotNull(query); var notExists = Media.queryMediaByDigest(UUID.randomUUID().toString()); Assertions.assertNull(notExists); }

Copy

是不是很方便?所以,当你依赖一个第三方时,去寻找下它是否有内存或轻量级的实现吧。比如Mongo也有一些内存的实现技术与框架mongo-java-server

当然,这种方式有个不足,难以Mock出不正常的响应。也就是使用H2,你难以模拟一些错误行为。

有得必有失吧。它仍然是一个非常好的方案。

方法三:使用Testcontainers等支持工具

相信我,当你觉得有困难时,也许业界或我们的前辈们早就遇到并思考出解决之道了。

关于一些第三方依赖环境难以搭建的问题,有很多现成的解决方案在等我们选择了。最靠谱的就是基于容器技术来实现了。

我也曾有过思考,能不能在执行单元测试之前,快速启动一个容器服务,执行完成之后删除它,这样就做到了不依赖特定环境实现单元测试了。

后面发现,我这种想法早就被实现了,这就是TestContiner了,TestContiner的网站是: https://www.testcontainers.org/

@Testcontainers public class RedisBackedCacheIntTest { private RedisBackedCache underTest; // container { @Container public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:5.0.3-alpine")) .withExposedPorts(6379); // } @BeforeEach public void setUp() { String address = redis.getHost(); Integer port = redis.getFirstMappedPort(); underTest = new RedisBackedCache(address, port); } }

比如上述单元测试,基于Docker容器技术,运行一个容器镜像服务。这样你就有了个Redis可供你单元测试使用了。

是不是也是非常方便的一种方式?

方法四:在内部环境中搭建支持环境

对于你确实无法控制的外部服务,Mock可能是唯一可行的方式。但对于很多依赖的框架或工具,除了上述几个方式以外,还有一个最愚蠢但也是非常实用的方式。

就是在内部环境中,搭建相应用支持环境,专门提供给单元测试使用。

比如,你使用了Redis服务,那就搭建一个Redis服务,只用于单元测试吧,需要Mongo,提供一个Mongo服务吧,这是很容易做到的。

我在自己的项目中,对于Redis以及Mongo也都是采取这种方式,因为都在内部环境中,网络也非常快,无论是开发人员,还是CI/CD去执行单元测试,都可以使用这些服务。

最重要的

好了,这就是我思考到的几种方式了,也基本是我会使用的,上述几种方式我也并无特别偏好,也会混着使用。

但我认为,最重要的不是这几种方式,而是做为程序员的你,是否有一个自我信念与约束,就是:

做为一个程序员,要自我约束去编写单元测试,这不是外部强加给我的要求

比如,我的myddd开源框架,我就约束自己每个发行的版本,都要达到不低于80%的单元测试覆盖率,这是一种自我约束。

最新0.3.4-RC的数据

协作类测试常使用的方法(在单元测试中如何正确地处理第三方依赖)(1)

十年磨一剑,myddd已经在提供实现领域驱动核心支撑的能力之上,陆续添加了

  • 缓存,分布式ID主键生成,健康检查,验证码等工具类模块
  • 在完善中的媒体模块,组织模块以及用户权限等通用模块能力
  • 基于gRPC 容器编排的云原生,以及基于Dubbo Nacos的微服务推定架构

可以随时访问myddd的官网 myddd.org 以了解更多。

忠告

好了,如果你从未写过单元测试,你认为或有人告诉你,编写单元测试会延长完成一个功能的时间,相信我,这是瞎扯。

事实上,我多年的实际经验得出的结论是:

没有比编写单元测试更快的编码方式了

你不用相信我,但我认为做为程序员,你需要去尝试一下。不要在从未尝试之后轻易的去定一个结论,也不要给自己寻找借口。

,