【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

使用伪对象进行单元测试:避免过度设计,降低测试成本

作者:Tyson Gern

  • 2023-04-30
    北京
  • 本文字数:3499 字

    阅读完需:约 11 分钟

使用伪对象进行单元测试:避免过度设计,降低测试成本

开发人员编写测试是为了增强对产品代码正确性的信心、记录意图和为应用程序设计提供帮助。最近,我们看到开发人员在单元测试中大量使用测试替身,尤其是模拟对象。这样做是为了提高测试的速度,减少对基础设施的依赖,或减少依赖的对象数量。然而,它常常以低可信度、不清晰的文档以及实现和测试代码之间的高耦合为代价,这是不可接受的。

 

为了避免这些问题,开发人员应该考虑使用伪对象而不是模拟对象,因为伪对象不仅提供了类似的隔离性,而且带来了高可信度、清晰的文档以及实现和测试代码之间的松散耦合。

 

背景

 

我们将较低级别的测试归类为单元测试,表示这些测试与周围的其他代码存在某种形式的隔离。由于这种隔离,单元测试应该执行得快、编写简单、易于理解和维护。

 

开发人员通常使用测试替身作为提升这种隔离性的一种方式。测试替身是在测试中用来代替协作者的对象。Gerard Meszaros 在他的著作《xUnit测试模式》中定义了几种类型的测试替身:控对象(Dummy)、间谍、存根、伪对象(Fake Object)和模拟对象(Mock Object)。在本文中,我们将关注最后两个:

 

  • 模拟对象预先置入了它们期望接收到的调用和它们对这些调用的响应。它们有一种机制来验证在测试期间是否收到了正确的调用,如果调用不符合它们的期望,测试就会失败。人们经常使用 Mockito、Mockk 或 GoMock 这样的框架来创建模拟对象。

  • 伪对象是协作者的功能实现,它们通过某种快捷的方式让它们更适合用在测试环境中。例如,在执行本地测试时,开发人员可以创建内存数据存储来代替将数据保存到 S3 的对象。

 

对于现代代码库测试套件和在没有任何支持服务情况下运行的测试套件,几乎所有东西都是模拟的。在这种情况下,测试套件为系统每一个部分独立运行的准确性提供了很高的可信度,但对于它们被放在一起运行时的准确性却没有提供多少可信度。稍后,我们将讨论何时不适合使用模拟对象。

 

例如,许多测试套件会在测试期间模拟数据库层。测试案例会检查是否对数据库进行了正确的调用,并返回预先置入的响应。这样的测试套件很难让我们相信代码在生产中会正确地运行,因为数据库调用从未真正被执行过,预先置入的调用可能是不正确的,更何况 SQL 语句无法被测试到。

 

隔离

 

大家普遍认为,单元测试中的单元指的是隔离单元。也就是说,单元测试在某种程度上与其他的代码库是隔离开来的。然而,在定义什么是隔离单元时,存在不同的意见。

 

这个定义很重要。隔离单元决定了每个测试的范围、测试代码和产品代码之间的关系,并最终决定了应用程序架构。从历史上看,隔离是有定义的,并且被广泛接受,我们将在下面讨论。

 

测试隔离

 

经典测试方法代表人物 Kent Beck 认为:

 

“单元测试彼此完全隔离,每一个测试都会从头开始创建它们所需的测试资源。”

 

在这里,单元指的是测试本身:单元测试之间是相互隔离的。Beck 认为“测试应该与代码的行为耦合,并与代码的结构解耦。”

 


使用这种方法编写的测试往往只有很少的模拟对象,更多的是使用协作对象的实例,甚至是真实的基础设施(例如数据库)来执行每个测试。

 

例如,有一个经典的测试,它的主体是进行数据库调用,所以它会在测试期间使用真实的数据库。这类测试将确保数据库在运行之前处于正确的状态,并检查结果数据库状态与预期是否匹配。

 

以外部 HTTP 调用为主体的测试将在执行测试时进行 HTTP 调用。由于外部调用通常会降低测试的可靠性,因此作者可能会在本地启动一个行为与外部服务类似的 HTTP 服务器。

 

经典的测试为代码行为的正确性提供了高度的可信度。当代码被重构时,测试往往不需要发生变化,因为它们与协作者的外部接口是松散耦合的。

 

主体隔离

 

模拟对象方法代表人物 Steve Freeman 和 Nat Pryce 认为:

 

“单元测试孤立地测试对象或一小组对象。”

 

Freeman 认为,单元测试“可以帮助我们设计类并让我们相信它们的行为是正确的,但并没有说明它们是否可以正确地与系统的其他部分协作。”在这里,单元指的是被测试的主体。

 


使用这种方法编写的测试必须使用测试替身来代替协作者,并且往往会用到许多模拟对象。他们很少使用真正的基础设施,而更倾向于使用模拟对象或替身。我们的想法是,在测试过程中,我们应该将测试对象与其协作者的行为隔离开来,一个对象行为的变化不应该影响另一个对象。开发人员还使用模拟对象来提高测试的速度和可靠性,使用模拟对象来取代缓慢或不可靠的协作者。

 

例如,一个以数据库调用为主体的模拟测试将在执行测试时模拟数据库层。主体将与模拟数据库对象发生交互,在测试期间记录调用,并在测试结束时执行检查。

 

一个以外部 HTTP 调用为主体的模拟测试将在执行测试时使用模拟 HTTP 客户端。这个客户端将在测试期间返回预先置入的对 HTTP 调用的响应。在测试之后,测试作者将使用模拟对象来检查是否进行了正确的 HTTP 调用。

 

这些测试能够快速可靠地执行,但它们提供的行为正确性可信度较低。当代码发生变化或被重构时,测试往往也需要做出重大的修改,因为它们深度耦合了协作者的外部接口。

 

此外,使用模拟对象会增加测试代码的数量。在许多语言中,比如 Go,作者必须编写或生成所有的模拟对象,并将代码保存在代码库中。这样会让测试套件的大小翻番。即使在 Kotlin 和 Java 中模拟对象是在运行时生成的,也必须在每次执行测试之前预先置入模拟对象,并在执行测试之后进行验证,这样会导致需要维护更多的测试代码。

 

实践

 

为了确定在实践中使用哪一种方法,我们首先必须列举出我们的测试目标。我们想要:

 

  • 增强对代码行为正确性的信心。

  • 记录我们的代码应该如何运行。

  • 帮助设计出松散耦合、高度内聚的软件。

 

基于这些目标,我认为应该从单元测试的测试隔离方法开始。如果每个测试都可以可靠独立地运行,同时使用尽可能多的真实协作者,那么我们将可以实现以下这些目标。

 

信心,因为我们的测试是在与生产环境类似的环境中运行的。我们可以确信,我们的测试对象在独立和协作的情况下都能正常运行。我们的测试也给了我们信心,测试主体与它们的外部协作者具有一致的正确行为。在进行模拟测试时,我们对测试主体是否能很好地协作没有那么强的信心。

 

清晰的文档,因为阅读文档的人可以看到我们的代码是如何在与生产环境的环境中运行的。例如,阅读测试文档的开发人员可以简单地检查指定的操作将产生怎样的预期数据库状态,以便了解在生产环境中将会发生什么。而阅读模拟测试文档的开发人员必须将每个模拟对象的响应和期望转换为实际协作者的操作,这大大降低了清晰度和可读性。

 

深思熟虑的设计。重构与测试代码是相互独立的,因此可以频繁地进行重构。但如果使用的是模拟测试,那么改变对象的外部接口时也需要重写或重新生成这个对象的所有模拟对象。而在使用测试隔离方法时,不需要重写模拟对象,重构所需的测试代码修改也更少。这使得重构更容易进行,也意味着可以更频繁地进行重构,并且代码库的设计会随着时间的推移而改进。

 

灵活变通

 

在实践中,我建议使用一种测试隔离方法,从经典的方法开始,在必要时可以回退到模拟测试。Martin Fowler 说:“我并不认为在获取外部资源时使用替身是绝对的规则。如果获取资源足够稳定和快速,那么在单元测试中就没有理由不这么做……事实上,当 90 年代 xunit 测试开始起步时,我们并没有试图另辟蹊径,除非与协作者(比如远程信用卡验证系统)的交互很困难。”

 

只要我们使用快速、可靠的协作者(这应该是我们的目标),那么使用真正的协作者进行测试并不会对我们测试的速度和可靠性产生负面影响。如果情况并非如此(例如,当通过 HTTP 与外部服务交互时),那么测试替身是提高测试速度和可靠性的好方法,只是牺牲了一点可信度、清晰度和灵活性。

 

在考虑使用哪种测试类型时,最好选择伪测试对象而不是模拟测试对象。伪对象相比模拟对象有几个关键优势:

 

  • 伪协作者比模拟协作者更接近真实的协作者,这为我们提供了更高的可信度。

  • 我们与伪协作者的交互方式与我们与真实协作者的交互方式是相同的,这样可以获得更好的文档。

  • 每当真正的协作者发生变化时,也必须更新伪对象,这与模拟对象一样。但在使用伪对象时,我们不需要改变期望或验证,因此在使用伪对象时重构代码库往往比使用模拟对象更容易。

 

总结

 

在确定选择哪一种测试方法时,请仔细考虑一下单元隔离问题,这样你就会意识到经典方法或模拟测试方法的利与弊。你要根据协作者的性质来调整你的测试方法。最后,我们都想要快速、可靠且可以让我们更有信心发布软件、清楚地记录我们的意图并帮助我们设计可扩展的系统的测试套件。

 

原文链接

https://www.infoq.com/articles/unit-testing-approach/


相关阅读:

“TDD 就是死亡”?我要为单元测试辩护

从忽略到重视,Stack Overflow 改变了对单元测试的态度

2023-04-30 08:006566

评论

发布
暂无评论
发现更多内容

如何避免企业在碳排放数据上造假?

石云升

学习笔记 碳中和 碳交易

初探DispatcherServlet#doDispatch

Java 程序员 后端

十面阿里Java岗,看我怎么吊打面试官!

Java 程序员 后端

厉害!腾讯T3-2都还在学的微服务+MySQL+Kafka+boot2

Java 程序员 后端

双非本科七面成功入职阿里面经分享!(附面试原题+复盘笔记)

Java 程序员 后端

南邮《网络技术与应用》4次作业

Java 程序员 后端

初识Java语言(六)- 多态、抽象类以及接口

Java 程序员 后端

初识Servlet

Java 程序员 后端

区块链编程七大语言,使用最多的竟是Java

Java 程序员 后端

十个超酷的java谋生方式,你喜欢吗?

Java 程序员 后端

怎样选择最合适的Linux发行版?23个版本横向对比,总有适合你的?

奔着腾讯去

Linux

工作10年,面试超过500人想进阿里的同学,总结出的108道面试题

Java MySQL redis spring JVM

初次远程面试蚂蚁金服,三面过后本以为凉凉,没想到直接被录取了

Java 程序员 后端

互联网通信云盛会WICC广州站绿色报名通道开启

融云 RongCloud

华为初面+综合面试(Java技术面)附上面试题

Java 程序员 后端

华为安全技术专家与Linux内核到底发生了什么?这本小册子是怎么回事?

Java 程序员 后端

创建型模式之建造者模式——链式调用

Java 程序员 后端

别再找了,这就是全网最全的SpringBean的作用域管理!

Java 程序员 后端

前后端项目练习(整合Spring)

Java 程序员 后端

原来书中说的JVM默认垃圾回收器是错的!

Java 程序员 后端

剖根问底:Java 不能实现真正泛型的原因是什么?

Java 程序员 后端

别再说你不会-JVM-性能监控和调优了,看完这篇再发言!

Java 程序员 后端

华为java工程师的提升程序员实力的几点建议

Java 程序员 后端

博客之星:我去,你竟然还不会用 synchronized

Java 程序员 后端

又一巅峰神作!14年工作经验大佬出品“JVM&G1 GC深入学习手册”

Java 程序员 后端

北上广深,2020,多少K的Java程序员应该懂高并发多线程和JVM优化

Java 程序员 后端

华山论剑!滴滴CTO五轮面试真是太刺激了,已拿到offer

Java 程序员 后端

制作Docker镜像,用来编译OpenJDK11源码

Java 程序员 后端

前华为18A架构师,总结“RabbitMQ”开发手册,已开源

Java 程序员 后端

力扣前400题解答笔记,全被字节大神整理到了这份文档里

Java 程序员 后端

动手造轮子:实现一个简单的-AOP-框架

Java 程序员 后端

使用伪对象进行单元测试:避免过度设计,降低测试成本_软件工程_InfoQ精选文章