写点什么

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

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

评论

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

微信业务架构图 & 学生管理系统架构设计

阿卷

架构实战营

音视频开发学习:HLS 协议详解

赖猫

c++ 音视频 ffmpeg HLS 音视频开发

【网络安全】详细记录一道简单面试题的思路和方法

H

网络安全

冬奥探秘:那些隐匿在冬奥中的“绿科技”

脑极体

构建多架构镜像的最佳实践

xcbeyond

Docker arm docker image xcbeyond 1月月更

在线XML转CSV工具

入门小站

工具

微信业务架构分析 & 学生管理系统架构选型

AragornYang

架构训练营 架构实战营

PDF 文件如何转成 markdown 格式

汪子熙

markdown PDF pdf.js 1月日更 1月月更

用Java实现线段树

CRMEB

JavaScript 基本数据类型转换

编程三昧

JavaScript 前端 1月月更

低代码实现探索(二十九)混合式低代码

零道云-混合式低代码平台

大白话讲解JDK源码系列:从头到尾再讲一遍ThreadLocal

慕枫技术笔记

后端 1月月更

Go len() 函数是如何计算长度的?

宇宙之一粟

Go Go 语言 1月月更

测试工程师的职场发展二三谈

老张

自动化测试 解决方案 职场发展

用明道云落地高校业务之优秀网站评选

明道云

C/C++开发方向如何选择?坚持C++还有意义吗?

赖猫

c++ Linux 服务器

kali权限提升之本地提权

喀拉峻

网络安全 信息安全 提权

第七周作业

lv

ShardingSphere JDBC 分库实现多数据库源

Java 数据库 分库分表 Apache ShardingSphere

零代码平台——业务人员的知识变现工具

明道云

明道云帮助外贸行业实现数字化管理

明道云

使用 React 和 Next.js 构建博客

devpoint

React nextjs 1月月更

22 Prometheus之Docker监控简述

穿过生命散发芬芳

Prometheus 1月月更

《腾讯云原生在线技术工坊》实践体会

穿过生命散发芬芳

腾讯云 云原生 1月月更 实践体会

(1-19/19)市场和销售分别该怎么干

mtfelix

300天创作 2022Y300P

🏆【Alibaba中间件技术系列】「RocketMQ技术专题」系统服务底层原理以及高性能存储设计分析

洛神灬殇

RocketMQ 阿里巴巴‘ Alibaba技术 Apache RocketMQ 1月日更

电商秒杀系统架构设计

AHUI

「架构实战营」

ReactNative进阶(二十八):ES6 Symbol 用法

No Silver Bullet

React Native symbol 1月月更

Linux之cal命令

入门小站

(1-18/18)推播式营销vs.集客式营销

mtfelix

300天创作 2022Y300P

ReactNative进阶(三十):Component、PureComponent 解析

No Silver Bullet

​React Native 1月月更 Component

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