QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

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

评论

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

数据库发展史2--数据仓库

数据库 数据仓库 叶正盛 玖章

送你5个MindSpore算子使用经验

华为云开发者联盟

人工智能 算子 企业号九月金秋榜

奇点云数据云平台发布DataSimba R3.8长期支持版

奇点云

奇点云

【死磕JVM】用Arthas排查JVM内存 真爽!我从小用到大

Java快了!

活动报名| MongoDB 核心功能及其原理分析

MongoDB中文社区

mongodb

PhotoView——支持图片缩放、平移、旋转的一个优雅的三方组件

OpenHarmony开发者

Open Harmony

FreeRTOS记录(十、FreeRTOS实现带 I2C 通讯的 ModbusRTU 协议从机实例)

矜辰所致

FreeRTOS 9月月更 ModbusRTU

【微信小程序】常用组件及基本使用详解

陈橘又青

小程序开发 9月月更

压测平台在全链路大促压测中的实践

得物技术

中间件 全链路压测 QPS 企业号九月金秋榜

2022-09微软漏洞通告

火绒安全

microsoft 安全 终端安全 安全漏洞

TiDB部署----openEuler2203/2003 单机部署TiDB 6.1.1

TiDB 社区干货传送门

安装 & 部署

Redis复制的实现

急需上岸的小谢

9月月更

荣耀帐号服务,让用户获取变得更简单

荣耀开发者服务平台

手机 物联网 安卓 移动开发 honor

一文带你了解隐私 Layer1

TinTinLand

区块链 隐私

Vite+Vue3+Vue-Router+Vuex+CSS预处理器(less/sass) 配置指南 —— 全网最详细系列

海底烧烤店ai

前端 Vue3 9月月更 项目搭建

资源使用率提高25%,成本降低90%,云函数是怎么做到的?

最新动态

阿里云视觉智能开放平台离线人脸识别SDK开启邀测啦

夏夜许游

人工智能 AI 人脸识别 离线包

编译器优化:何为别名分析

华为云开发者联盟

开发 编译器 企业号九月金秋榜

Pipy + Sentinel 实现 Redis 的高可用

Flomesh

Service Mesh 服务网格

一步步搞懂MySQL元数据锁(MDL)

京东科技开发者

MySQL 数据库 元数据 数据库锁 mdl

TiDB生命周期

TiDB 社区干货传送门

实践案例 集群管理 管理与运维 数据库架构设计

实操指南:如何为 SAST 工具设置误报基准?

SEAL安全

应用安全 静态应用安全测试 SAST 应用安全测试 软件供应链安全

论监控中事件管理的艺术

穿过生命散发芬芳

事件管理 9月月更

PSYNC 命令的实现

急需上岸的小谢

9月月更

mysql 查询执行过程

急需上岸的小谢

9月月更

力扣151 - 反转字符串中的单词【双指针与字符串的火花】

Fire_Shield

双指针 LeetCode 9月月更

一招教你如何高效批量导入与更新数据

华为云开发者联盟

数据库 sql 后端 企业号九月金秋榜

TiDB分布式事务—写写冲突

TiDB 社区干货传送门

故障排查/诊断

依据TIdb执行计划的sql调优案例分享

TiDB 社区干货传送门

性能调优 管理与运维 HTAP 场景实践 大数据场景实践

MODBUS RTU 485 协议简要说明

矜辰所致

Modbus RS485 9月月更

跨链自动化中心 OAK Network,构建安全高效 Web3 基础设施

One Block Community

Substrate defi 跨链 区块链、

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