写点什么

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

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

评论

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

跨端生态×AI赋能:移动研发模式的双擎驱动

xuyinyin

拒绝停服,随时回退:MS SQL 到 ≈ 的无缝数据库双向迁移方案

tapdata

容灾备份 高可用数据库迁移 mssql迁移 sqlserver迁移 双向同步

跨链模式:多链互操作架构与性能扩展方案

区块链软件开发推广运营

dapp开发 链游开发 公链开发 公链开发代币开发 链游开发交易所开发

金九银十春招必看的2025年Java高频面试题汇总(附答案)

Geek_Yin

Java 编程 程序员 java面试 Java面试题

腾讯云TencentOS Server AI,助力荣耀打造高性能AI底座

极客天地

“深时数字地球”新进展!科学智能助推地球科学研究范式变革

ModelWhale

科学智能 地球科学 AI4S DDE深时数字地球

内网im,局域网环境下BeeWorks 如何保障数据安全?

BeeWorks

即时通讯 企业级应用

DAO模式:去中心化治理与代币激励设计

区块链软件开发推广运营

交易所开发 dapp开发 链游开发 公链开发 公链开发代币开发

创业,是加速“祛魅”最有效的办法

程序员郭顺发

C# 模式匹配全解:原理、用法与易错点

量贩潮汐·WholesaleTide

C#

视觉与图像识别自动化测试 | 音频转文字

测试人

人工智能

大数据-07-HDFS集群 基础知识 分布式文件系统 读写原理 读流程与写流程 基本语法上传下载拷贝移动文件

武子康

大数据 hadoop mapreduce hive

HarmonyOS运动开发:打造你的专属运动节拍器

王二蛋和他的张大花

鸿蒙 os

Java面试高频核心宝典(含答案)

Geek_Yin

Java 程序员 java面试 Java面试题

为什么别人工资比你高?「程序员性能优化手册」你有吗

Geek_Yin

Java 编程 程序员

DePIN代币正构建 Web3 和 AI 融合的新范式

PowerVerse

AI Web 3.0 DAO DePIN gpu 算力

甘其果:以《道德经》智慧破局生鲜零售,缔造社区水果新标杆

极客天地

Go版本的JetCache缓存框架终于来啦

daoshenzzg

Go 缓存 开源

从20年架构师的视角解读高性能Java架构核心知识点!

程序员高级码农

Java 程序员

ETLCloud可能遇到的问题有哪些?常见坑位解析

RestCloud

数据处理 ETL 数据集成工具 实时数据集成 离线数据集成

博睿数据×华为, 共筑智慧金融新未来

博睿数据

2025全球人形机器人领域深度报告:人形机器人、具身智能技术、商业化壁垒、产业链公司及投资分析

机器人头条

科技 大模型 人形机器人 具身智能

ES Serverless 8.17王牌发布:向量检索「火力全开」,智能扩缩「秒级响应」!

阿里云大数据AI技术

大数据 运维 数据处理 Server 向量检索

低代码“偷懒”技巧,我建议全公司学!

引迈信息

【6.12 直播】内存泄漏怎么办?时序数据库 IoTDB 官方避坑指南“面对面”告诉你!

Apache IoTDB

一文搞懂K8s中的RBAC认证授权

不在线第一只蜗牛

Kubernetes 容器 云原生

BeeWorks im即时通讯软件:政企通讯首选

BeeWorks

即时通讯 IM 企业级应用

DeFi模式:去中心化金融架构与流动性池设计

区块链软件开发推广运营

交易所开发 dapp开发 链游开发 公链开发 公链开发代币开发

Java集合源码--ArrayList的可视化操作过程

电子尖叫食人鱼

Java 算法

详细教程!Ollama本地部署新版DeepSeek-R1,如何实现远程访问?

贝锐

内网穿透 DeepSeek R1 模型

南京大学金莹教授解读AI通识课程体系,清华指导发布AI教育白皮书

ModelWhale

南京大学 人工智能通识教育 人工智能教育 AI通识教育

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