在单元测试中,对象之间的依赖往往交织到一起,需要拆成各个单元才能逐个击破,这也是单元测试的目的。如何将这些交织到一起的对象拆开,需要一些工具,这些工具业内人们称其为“测试替身”。
本文作者介绍了单元测试中的4个“测试替身”工具,即Stubs、Mocks,、Spies 和 Dummies。
Stubs 为被测试对象提供数据,没有任何行为,往往是测试对象依赖关系的上游。
Spies 被依赖对象的代理,行为往往由被代理的真实对象提供,代理的目的是为了断言程序运行的正确性。
Mocks 模拟一个具有特性行为的对象,在测试开始前根据期望提供需要的结果。被测试对象往往调用这个对象的方法时,根据条件得到不同的输入,从而满足测试对象的不同场景。例如,mock 数据库的存储层,返回正常数据、空或者丢出异常等情况。
Dummy 被用来仅仅作为填充参数列表的对象,实际上不会用到它们,对测试结果也没有任何影响。
以下为作者观点。
你可能讨厌或喜欢单元测试,这取决于你,但事实是,如果你不理解它们背后的概念,你写测试的效率可能就会弄得一团糟。
要成为写单元测试的高手,第一个核心步骤是了解其重点。单元测试不是集成测试,它们必须测试单一的代码单元。
让我们来看看在写单元测试时要用到的4个工具。我指的不是IDE或任何插件或扩展,我指的是概念性的工具:stubs、mocks、spies、 dummies。
什么是Stubs?
我经常看到开发人员通过启动一个“测试数据库”来编写与数据库交互的代码的测试,其中测试可以触发“写入”并通过查询数据库进行验证,我认为这是错误的。
Stubs可以帮助你处理这些情况,即你的代码与第三方服务进行交互。无论是数据库、API还是硬盘上的文件,stubs都提供了使用更简单版本的服务的代码。
这个Stub会返回一个已知的、可控的值。例如,如果你正在测试一个向数据库写值的函数,你应该编写一个Stub,避免与数据库的交互,但返回一个成功的结果。
通过这个,你就可以测试当写入操作工作时发生了什么。然后你可以编写另一个Stub(在另一个测试中),返回一个失败的结果,这样你就可以测试你的逻辑中发生处理错误的部分。
你可以在一个特定的对象中Stub一个函数或一个方法(只要语言允许)。
因此,让我们快速看一个例子:
/// the function to testfunction saveUser(usrData, dbConn) { let q = createQueryFromUser(usrData)let result = dbConn.query(q) return result;}//the stubmakeStub(dbConn, 'query', () => {return true;})//the testit("should return TRUE when the query succeeds", () => {let result = saveUser({name: "Fernando",password: "1234"}, dbConn)result.should.be.true})
上面的例子有几个地方需要解读,同时注意到,虽然这个例子是用伪JavaScript写的,但其概念可以推导到所有语言。
首先是要测试的函数,现在它是一个接收数据的简单函数,一个数据库连接对象,并依靠一个伪createQueryFromUser函数来创建实际的SQL查询。来自dbConn对象的query方法是与数据库交互的方法,也是我们有兴趣Stub的方法,因为我们不希望query真正启动。
这里是Stub发挥作用的地方,makeStub函数负责用我们传递的匿名函数(这是一个伪函数,每次只返回TRUE)神奇地覆盖数据库连接的方法query。
最后,实际的单元测试是利用Stub(因为它之前就被定义了)。这个测试确保我们的函数在进展顺利时返回正确的布尔值(boolean value)。
上面只是一个例子,告诉你可以从Stubs中受益。说实话,在任何时候,如果你有一个具有动态结果的函数,你就必须找到一种方法来确保每次执行测试时都有相同的结果。所以,Stubs可以帮到你。
什么是Mocks?
Mocks就像Stubs的孪生兄弟,它们看起来很像,人们经常把它们混淆,其实它们两个完全不同。
当Stubs允许你替换或重新定义一个函数或方法时,Mocks允许你在真实的对象/函数上设置预期行为。因此,从技术上讲,你并没有替换对象或函数,你只是告诉它在某些非常特殊的情况下该做什么,除此之外,对象仍然照常工作。
让我们看一个例子来理解这个定义:想象一下,要测试一个过道补货功能。它从库存中提取物品,并把它们放在正确的过道上。这里测试的关键是,每次我们补充一个过道时,也需要从库存中取出相同数量的元素。
var inventory = createMock(Inventory("groceries"))//set expectationsinventory.expect("getItems", 10).returns(TRUE).expect("removeFromInventory", 10).returns(TRUE)var aisle = Aisle("groceries")aisle.replenish(10, inventory) //executes the normal flowassertion(aisle.isFull(), "equals to", TRUE)
请记住,在某些情况下,mocks的预期行为会被你所使用的框架自动检查。这就是为什么没有真正的断言来处理期望值的原因,如果它们没有被满足,模拟就会抛出一个异常,测试就不会通过。
在这个特殊的例子中,预期getItems方法将被调用,其属性为10,它将返回TRUE,它也将调用removeFromInventory函数,其属性也是10。最后返回的结果是TRUE。
当然,我们可以用Stubs来完成这个任务,但这不是重点,在许多情况下,这些工具可以用于相同或类似的用例。
Spies到底是什么?
顾名思义,Spies可以让我们了解被测试代码内部发生了什么,即使我们并没有真正访问到它。我知道,这听起来很诡异,但它有它的用途。
换句话说,Spies是收集执行信息的Stubs,因此他们最终可以告诉你调用了什么、何时调用了哪些参数。
想想上面mocks的例子,我们必须事先设定期望值(预期),以确保我们想要的东西都会被执行。我们可以通过 “监视 “库存来检查同样的事情,并询问这些方法是否真的被调用了,用了哪些参数。
我们来看看另一个例子,一个文件读取器函数,一旦它完成了文件处理,也应该关闭文件处理程序。
const filename = "yourfile.txt"let myspy = new Spy(IOModule, "closeFile") //create a spy for the method closeFile in the module dedicated to I/function readConfigFile(fname) { const reader = new FileReader(filename, IOModule) let content = reader.read() loadConfig(content) IOModule.closeFile(reader);}//The testit("should call the 'closeFile' method after reading the content of the file", () => {readConfigFile(filename)assertion(myspy.called, "equals to", TRUE)})
要测试的函数叫做readConfigFile,它的目的是读取一个文件,并通过调用loadConfig方法将其内容加载为配置。作为测试的一部分,我们有兴趣了解该函数是否真的关闭了文件处理程序。
请记住,这个测试与我上面所说的相反,因为它实际上是在打开和读取文件,这是我们单元测试不应该有的第三方依赖。为了使这个测试完全 “合规”,当我们有兴趣测试成功的读取和失败的读取时,我们还必须为I0Module和控件添加一个stub。
注意:与stubs不同的是,Spies包装目标方法/函数,而不是替换它,因此目标的原始代码也将被执行。
什么是 dummies?
最后,我想介绍的最后一个工具是众多周知的无用的 “dummies”。顾名思义,除了在需要的时候出现之外,没有其他真正的用途。它们的目的是在语法需要时出现在那里。
例如,想象一下必须调用一个需要3个参数的函数,其中第一个参数是另一个函数(外部依赖)。考虑到该函数当前的stub,你知道其他两个属性不会被使用,然而,解释器/编译器正在抱怨你缺少该函数的最后两个属性,所以你需要添加它们。
你怎么能做到这一点呢?
你猜对了,通过dummies。你只需添加2个什么都不做但被编译器接受的dummy对象。
Dummies在强类型语言中使用时更有意义,因为这些类型的检查在那里更常见。例如,看看下面这个TypeScript的例子:
type UserData = {name: string;password: string}//The function to be testedfunction saveUser(usrData: UserData, dbConn: DataBase, validators:DataValidators) { if(!validators.validateUserData(usrData)) {return false;}let query = createQueryFromData(usrData);let result = dbConn.query(query);return result;}// The test itself//the stubconst stubbedValidators: DataValidators = {validateUserData: (data: UserData) => false;}//the dummiesconst userData: UserData = {name: "", password: ""}const dbConn: DataBase = {}//the testit("should return false if the user data is not valid", () => {let result = saveUser(userData, dbConn, stubbedValidators);result.should.be.false;})
该代码定义了一个新的saveUser函数,该函数也需要一个validators依赖。我们还添加了一个验证步骤,以确保我们试图保存的数据是 “有效的”(不管这意味着什么)。
但我们测试的目的是确保如果数据无效,我们将返回false。这意味着我们没有真正执行任何验证,事实上,我们需要stub那个验证器来控制结果,否则如果明天我们的验证例程发生变化,我们现在可能会传递一个有效的数据样本,测试就会失败。
现在的问题是,通过查看我们的业务逻辑,如果数据是无效的,我们并没有真正使用数据库连接,也没有实际的用户数据。我们需要它们在那里,但我们并不真正需要它们。所以他们实际上已经变成了dummies。
这就是为什么我只是传递假的空对象(A.K.A dummies)作为函数的前两个属性。
Stubs, Mocks, Spies和Dummies是你在测试中所做的一切的面包和黄油,你越是使用它们,就越是感觉熟悉,你就越容易理解如何处理一个新的测试。
例子是否足够清楚?你对这些例子还有疑问吗?请留下评论,我们一起讨论!
资源分享
下方这份完整的软件测试视频学习教程已经上传CSDN官方认证的二维码,朋友们如果需要可以自行免费领取 【保证100%免费】