断言的黄金法则

原文信息: 查看原文查看原文

The Golden Rule of Assertions

- Artem Zakharchenko

如果我告诉你,有一条规则可以可靠地区分好的测试和坏的测试,你会怎么想?

这不是一个把戏,也不是销售噱头。这是我多年来一直在使用的一条规则,无数次帮助我改进我的测试,使它们更加稳定可靠、更有价值。事实上,这条实践中不可或缺的规则如此重要,我在此正式将其称为断言的黄金法则

只有当系统的意图未被满足时,测试才会失败。

我们之前曾谈论过意图以及它们在测试目的中的作用。现在,是时候从采用这种思维模式中获得一些实际价值了。

断言

每个测试的核心都在于它的 断言。这些是比较我们期望的系统状态和实际状态的表达式。就像这样的表达式:

expect(this).toEqual(that)

如果比较失败,断言会抛出一个错误,让我们知道出了什么问题。但是最重要的是 断言何时抛出

让我们通过几个示例来看看断言的黄金法则如何指出你在测试中犯的一些关键错误。

实现细节

考虑以下代码:

import { cookieUtils } from './utils'
export function parseCookieName(cookie: string) {
  const { name } = cookieUtils.parse(cookie, { pick: ['name'] })
  return name
}

parseCookieName 函数背后的意图是接受一个 cookie 字符串并返回其中包含的 cookie 名称。为此,它依赖于一些 cookieUtils 对象来进行实际的解析。

现在,对于这样一个函数的测试可能看起来像这样:

import { parseCookieName } from './parseCookieName'
import { cookieUtils } from './utils'

it('returns the cookie name from the given cookie', () => {
  const cookieName = parseCookieName('sessionId=abc-123')
  expect(cookieUtils.parse).toHaveBeenCalledWith(
    'sessionId=abc-123',
    { pick: ['name'] }
  )
  expect(cookieName).toBe('sessionId')
})

因为我们只期望得到 cookie 名称,所以我们也断言 cookieUtils.parse 是否使用了正确的 pick 参数进行了调用。看起来是合理的。

但是如果 parseCookieName 函数不再需要 cookieUtils 会发生什么呢?也许我们采用了一个成熟的第三方包来进行解析,或者甚至自己实现了一个基本版本。

嗯,测试会失败:

× returns the cookie name from the given cookie
  Error: expected the "cookieUtils.parse" to be called but it never was.

测试失败是件好事。我们编写测试是为了在有问题时它们会失败。但是问题在于:parseCookieName 函数有问题吗?我们甚至都不知道。检查返回的 cookie 名称的断言甚至都没有运行,因为之前的 expect() 调用抛出了一个错误。

测试失败了,但是 parseCookieName 背后的意图并没有必然破坏。事实上,如果我们移除 cookieUtils 的断言,测试会通过!因此,代码一直在实现意图,但测试仍然失败了。

在这种特殊情况下,这是因为我们引入了一个对实现细节的断言——使用了 cookieUtils 对象。我们得到 cookie 名称的方式并不重要。只要我们做到了,函数就能完成它的预期任务。

这就是为什么测试实现细节是不鼓励的。因为如果这些细节改变或被移除,你的测试会失败,即使你测试的代码仍然完全可用。断言的黄金法则帮助你决定什么是需要测试的,什么是可以省略的。

实现可能会改变,但意图保持不变。

测试边界

好吧,我们吸取了教训,不再在测试中包含实现细节了。这个时髦的新规则就是这样了吗?如果它所做的就只是这些,我们现在还需要它吗?

我给你举个例子:

export async function fetchUser(id) {
  const response = await fetch(`https://example.com/user?id=${id}`)
  const user = await response.json()
  return user
}

fetchUser 函数应该根据 id 获取用户。它是如何做到的——使用 fetch 函数——在测试的上下文中并不重要,因此我们相应地编写它:

import { fetchUser } from './fetchUser'

it('fetches the user by id', async () => {
  const user = await fetchUser('abc-123')
  expect(user).toEqual({ name: 'Cody' })
})

现在,如果我们将 fetch 替换为 React Query,例如,只要返回的用户具有正确的值,测试就会通过。没有涉及实现细节,我们应该没问题(你看到了,这篇文章并没有结束,你知道我们并不好)。

如果服务器返回错误会发生什么?或者连接到 https://example.com 超时会怎样?

你猜对了——测试会失败。

× fetches the user by id
  TypeError: Failed to fetch

虽然我们的测试中没有提到任何实现细节,但它仍然隐含地依赖于一个外部因素——对 https://example.com/user端点的 GET 请求。因此,除了验证 fetchUser 函数之外,该测试现在还涉及到服务器的有效性。这将测试的范围从一个简单的函数扩展到了网络连接、服务器运行时、响应时间等等……有太多的移动部分——我们可能无法拥有或控制——使得这个测试永远无法可靠。

尽管 fetchUser 函数依赖于网络调用,但它不能保证服务器的有效性,也不负责它。它关心的只是发出正确的请求并处理响应以返回预期的用户对象。

这些因素都不确定 fetchUser 函数的有效性。为了正确测试其有效性,我们必须将 HTTP 交互从测试方程式中排除。在这种情况下,我们可以使用 API 模拟工具,比如 Mock Service Worker,将该网络请求在测试中变成一个固定、可预测的给定。

请记住,模拟只是建立边界的一种工具。在这个集成测试的上下文中,我们正在排除网络,因为在这个层面我们从中获得的价值很少。我们仍然需要对 fetchUser 函数进行端到端测试,在这种情况下,保持该请求不被模拟是至关重要的。如果您希望我在未来的文章中更详细地介绍模拟,请告诉我!

结论

我想强调的是,这并不是关于模拟网络调用或测试实现细节。这些只是当测试失败的原因不仅限于破坏的意图时的更大问题的症状。当测试不能通过断言的黄金法则时

在实践中,这条规则可以鼓励许多最佳实践,并帮助您在测试变得问题重重之前发现问题。它将重点放在测试设置和范围的重要性上,处理副作用和外部依赖,正确编写断言,甚至错误的实现也是如此。

因此,下次写测试时,我希望你问自己一个问题:这个测试什么时候会失败?

幸运的是,你知道要遵循的确切规则。这种规则将帮助您充分利用您的测试,使它们闪耀如金。

分享于 2024-03-27

访问量 64

预览图片