编译:TesterHome
原文标题:Unit Testing Deep Dive: What are Stubs, Mocks, Spies and Dummies?
作者:Fernando Doglio(IT 技术专家)

编者注:在单元测试中,对象之间的依赖往往交织到一起,需要拆成各个单元才能逐个击破,这也是单元测试的目的。如何将这些交织到一起的对象拆开,需要一些工具,这些工具业内人们称其为 “测试替身”。

本文作者介绍了单元测试中的 4 个 “测试替身” 工具,即Stubs、Mocks,、Spies 和 Dummies

以下为作者观点。

你可能讨厌或喜欢单元测试,这取决于你,但事实是,如果你不理解它们背后的概念,你写测试的效率可能就会弄得一团糟。
要成为写单元测试的高手,第一个核心步骤是了解其重点。单元测试不是集成测试,它们必须测试单一的代码单元。
让我们来看看在写单元测试时要用到的 4 个工具。我指的不是 IDE 或任何插件或扩展,我指的是概念性的工具:stubs、mocks、spies、 dummies。

什么是 Stubs

我经常看到开发人员通过启动一个 “测试数据库” 来编写与数据库交互的代码的测试,其中测试可以触发 “写入” 并通过查询数据库进行验证,我认为这是错误的。

Stubs 可以帮助你处理这些情况,即你的代码与第三方服务进行交互。无论是数据库、API 还是硬盘上的文件,stubs 都提供了使用更简单版本的服务的代码。

这个 Stub 会返回一个已知的、可控的值。例如,如果你正在测试一个向数据库写值的函数,你应该编写一个 Stub,避免与数据库的交互,但返回一个成功的结果。

通过这个,你就可以测试当写入操作工作时发生了什么。然后你可以编写另一个 Stub(在另一个测试中),返回一个失败的结果,这样你就可以测试你的逻辑中发生处理错误的部分。

你可以在一个特定的对象中 Stub 一个函数或一个方法(只要语言允许)。

因此,让我们快速看一个例子:

/// the function to test
function saveUser(usrData, dbConn) {

  let q = createQueryFromUser(usrData)
  let result = dbConn.query(q)

  return result;
}


//the stub
makeStub(dbConn, 'query', () => {
  return true;
})

//the test
it("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 expectations
inventory.expect("getItems", 10).returns(TRUE).expect("removeFromInventory", 10).returns(TRUE)

var aisle = Aisle("groceries")
aisle.replenish(10, inventory) //executes the normal flow
assertion(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 test

it("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 tested
function 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 stub
const stubbedValidators: DataValidators = {
  validateUserData: (data: UserData) => false;
}

//the dummies
const userData: UserData = {name: "", password: ""}
const dbConn: DataBase = {}

//the test
it("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 是你在测试中所做的一切的面包和黄油,你越是使用它们,就越是感觉熟悉,你就越容易理解如何处理一个新的测试。


↙↙↙阅读原文可查看相关链接,并与作者交流