写点什么

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

作者: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:006992

评论

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

中断操作:AbortController学习笔记

zhoulujun

喜报|海泰方圆通过CMMI-3资质认证,研发能力获国际认可

电子信息发烧客

面试官:你简历上说精通mysql,那你说下聚簇/联合/覆盖索引、回表、索引下推

Java全栈架构师

MySQL 数据库 程序员 面试 java架构

DDD概念复杂难懂,实际落地如何设计代码实现模型?

Java全栈架构师

程序员 后端 领域驱动设计 DDD 架构师

六大专题全方位优化,阿里巴巴性能优化小册终开源,带你直抵性能极致

Java全栈架构师

Java 程序员 面试 性能优化

源码其实很简单!阿里巴巴最新出品Spring学习脑图+视频+文档真香

Java全栈架构师

Java spring 源码 程序员 面试

web内容如何保护:如何有效地保护 HTML5 格式的视频内容?

zhoulujun

DRM 视频版权保护 数字版权保护 h5视频版权

什么是算子?

华为云开发者联盟

人工智能 算子

Tiger DAO VC产品正式上线,Seektiger生态的有力补充

鳄鱼视界

说下你对方法区演变过程和内部结构的理解

阿Q说代码

Java JVM 方法区 元空间 永久代

读配置、讲原理、看面试真题,我只能帮你到这了。。。

阿Q说代码

redis 主从架构 增量同步 全量同步 过期key处理

MyBatis 执行流程及源码解析

码农参上

mybatis java实战

绕过技术聊"跨端"......

hiisea

前端架构 前端框架 跨端开发 小程序框架 跨端框架

SDN系统方法 | 10. SDN的未来

俞凡

架构 网络 sdn SDN系统方法

不要小看了积分商城,它的作用可以很大!

CRMEB

IET出席2022世界科技社团发展与治理论坛 为构建国际科技共同体献言献策

E科讯

Google I/O 2022: Android Jetpack 最新进展

fundroid

android Google android jetpack

揭秘GaussDB(for Redis):全面对比Codis

华为云开发者联盟

数据库 后端 算力

揭秘GES超大规模图计算引擎HyG:图切分

华为云开发者联盟

人工智能 图计算 图切分

Wireshark网卡无法找到或没有显示的问题

岚哲

网络 Wireshark 域名 vpn 网卡

居家办公让我绩效拿了C | 社区征文

大菠萝

初夏征文

微博评论的计算架构

极客土豆

《睡眠公式》:怎么治睡不好?

郭明

读书笔记

地理位置数据存储方案——Redis GEO

程序员架构进阶

redis 源码剖析 geo 6月日更 6月月更

Java代码优化请求合并与分而治之

码农参上

Java 大数据 高并发 java实战

TML转义字符:xss攻击与HTML字符的转义和反转义

zhoulujun

XSS xss攻击 转义字符 反转义

vue项目的创建和托管【ForVue3】

Python研究所

6月月更

商城风格也可以很多变,DIY 了解一下!

CRMEB

解析数仓lazyagg查询重写优化

华为云开发者联盟

数据库 后端

华为发布两大昇腾计划 推动AI人才发展和科研创新

郝俸🦁好棒

昇腾

TLog 助力盘古框架实现微服务链路日志追踪

码农大熊

盘古开发框架

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