单元测试最佳实践|如何避免常见陷阱?

语言: CN / TW / HK

单元测试的目的是为了随着时间的变化,系统能够按预期工作。一来系统质量得到了保证,开发人员能够提前发现和解决问题,不用身陷bug的泥潭无法自拔;二来开发人员有更多的时间和精力去完善自己技术、提升自己的生活质量,从而形成一个良性循环。

我写了很多测试,也读了很多。他们中的大多数帮助我及早发现错误,提供代码文档并帮助回归测试。但我也发现一些单元测试没有做到这一点。相反,它们要么非常复杂,以至于无法弄清楚它们在测试什么,要么会随机失败,要么根本不会失败。

本文介绍了导致单元测试无效的五个陷阱,以及如何修复它们。

为每个函数编写一个单元测试

看起来很简单。假设您有一个小函数可以做一件事。假设它被称为calculate_average。它是一个小单元,它是单元测试最佳实践希望您测试的单元。所以你为它写了一个测试,test_calculate_average.

这有什么问题?它测试单个代码单元,但它应该测试该单元的单个行为。通常这也被表述为在测试中只有一个断言。一个更好的测试将是test_calculate_average_return_0_for_empty_list. 一旦您拥有了其中的几个,他们就会免费为您提供详细的文档。

它还改变了您对如何编写测试的思维方式。您必须考虑您期望从函数中获得的不同行为。在不知不觉中,场景越来越多,因为您正在考虑边缘情况,甚至为它们编写测试,所以编写单元测试的收益也逐渐降低。

为每个功能单元编写一个单元测试,而不是代码单元。

测试的重点应该是外部行为,如果我们过渡关注内部行为,当我们对实现逻辑进行了修改,那么原本的单元测试也就无法使用了,也起不到对代码重构保驾护航的作用了,违背了我们写单元测试的初衷,当然如果有一块内部逻辑,非常复杂,你也可以自己进行全覆盖测试,但一般情况下没有必要为了测试而测试。

只为代码覆盖率而编写测试

跟踪测试覆盖率通常是一个好主意。如今,许多测试框架都支持这一点,并且像codecov这样的平台可以很容易地随着时间的推移对其进行跟踪。那么,为什么沉迷于它不是一个好的想法呢?

代码覆盖率只是一种测量工具。100% 的代码覆盖率并不意味着你已经覆盖了所有的边缘情况,它只是意味着所有的代码路径都被执行了。这是一个覆盖率 100% 的快速反例,但让我们探讨当您传入一个空列表时会发生什么?

def average(elements: List[int]):
return sum(elements) / len(elements)

def test_average_returns_average_of_list:
result = average([1,3,5,7])
assert result == 4

代码覆盖率的根本问题是它只衡量覆盖了多少行程序。但所有程序都是状态机;要获得完整覆盖,您必须覆盖所有状态,但这是不可行的。

追求完整的,或者至少是非常高的覆盖率也会导致大量的测试,但并不是所有的测试都那么有用。对于 胶水代码 尤其如此。我见过模拟 Web 框架 (flask) 一半的测试,只是为了测试为端点注册函数是否有效。这是测试一小部分功能的大量工作。如果你弄错了,那就很明显了。一旦你做对了,它在未来不太可能改变。

我没有努力覆盖每一行代码,而是推荐 Martin Fowler 的建议。将测试重点放在有风险的代码上。那是您自己编写的代码,而不是可能会被重构的框架。然而,知道什么是有风险的很困难,因为它需要经验。

您应该将 [您的测试工作] 集中在风险点上。— Martin Fowler,重构

特别是某个代码逻辑导致的线上bug,或者其它同学发现的问题,都可以编写成测试用例,防止此类错误的再次出现。

严重依赖Mock

使用打桩模拟和存根对于单元测试是必不可少的。大多数情况下,您的被测代码与其他模块交互,并且在测试期间,您希望控制它们的行为。这可能导致你过度打桩。

当您必须编写 50 或 100 行模拟来测试单个函数时,那么您在测试什么?您是在测试您的函数,还是在测试您为测试该函数而编写的模拟?

许多Mock模拟也是危险信号。当您需要多个非常复杂的模拟来测试单个函数时,这个函数很可能复杂度过高。因此,您可能希望将其重构为几个功能较少且可以单独测试的函数。

我见过一些非常复杂的模拟。这是一个例子的再现:

# custom_middleware.py ####################################
class CustomHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["CustomField"] = "bla"
return response

# test_custom_middleware.py ###############################
async def endpoint_for_test(_):
return PlainTextResponse("Test")

middleware = [Middleware(CustomHeaderMiddleware)]
routes = [Route("/test", endpoint=endpoint_for_test)]
app = Starlette(routes=routes, middleware=middleware)

@pytest.mark.asyncio
async def test_middleware_sets_field():
client = TestClient(app)
response = client.get("/test")
assert response.headers["CustomField"] == "bla"

这个时候,你不要想办法进行Mock模拟,而是考虑如何进行重构?让其变得更简单,更容易测试。

我们通常通过单元测试去保证代码质量,那么单元测试代码本身的质量又如何保证呢?所以我们的单元测试要写的尽可能简单。

对于对数据一致性要求不高的系统,甚至可以直接对着接口进行测试,这样省去了编写Mock的复杂度。

编写永不失败的单元测试

正常情况下,回归是进行单元测试的原因之一。您编写代码,编写通过的测试并获得收益。万一有人破坏了您代码的功能,单元测试将能够发现问题。然而,另外一种情况,您的测试可能永远不会失败并且您会错过回归。

但是,您如何以永不失败的测试结束呢?下面是一个例子:

def get_film(id: str):
data = {"query": QUERY, "variables": json.dumps({"id": id})}
response = requests.post(URL, data=data)
return response.json()["data"]["film"]

def test_get_film_returns_successfully():
mock_response = {
"data": {
"film": {
"title": "a New Test",
"id": "testId",
"episodeID": 4
}
}
}
with requests_mock.Mocker() as mock:
mock.post(URL, json=mock_response)
result = get_film("foo")
assert result == {
"title": "a New Test",
"id": "testId",
"episodeID": 4
}

现在问问自己:哪些更改会导致此测试失败?最明显的一个是改变Mock模拟响应。但这不算数,您没有更改被测代码。更糟糕的是,我忘记了传递json.dumps参数. 这个错误不会被测试发现。另外有的同学为了保证测试覆盖率,甚至不写断言,直接打印输出,这样的话,可能永远不会出错。

这种问题被称为误报,看似无懈可击的测试用例,其实没什么用处,为了防止这种情况,请考虑是什么导致您的测试失败。更好的是,从失败的测试开始,然后编写代码直到它通过。在不知不觉中,您正在进行测试驱动开发。

使用单元测试保证非确定性行为的正确性

这是一个众所周知的谬论。如果您的测试或被测代码以不确定的方式运行,您将对测试失去信心。每次失败时,你都会问:我的测试失败了,还是会通过重新运行?重新修改运行都会给你的测试用例带来修改的麻烦,你甚至想要放弃单元测试用例。

对于测试来说,不确定性的缺点是显而易见的,那么是什么导致了这种情况呢?

您是否在测试中使用当前时间或日期?如果是,则您的测试每天都在使用不同的数据运行。一旦您从事该行业的时间足够长,您就会遇到这些类型的测试。它们可能仅在该月的最后一天失败,或者仅在午夜之前开始并在之后完成。幸运的是,有一个简单的解决方案:控制时间的流动。例如,Python 具有用于此的freeze-gun模块。

您是否使用随机性来生成示例数据?有一个名为faker的 Python 库,它可以轻松生成真实的数据,如姓名、地址或电话号码。它非常适合填充演示环境或冒烟测试。对于单元测试不是那么有用,通常而言,使用硬编码的单元测试用例最可靠。

如果系统中存在不确定性,那么应该保证固定的逻辑不会出错,对于不确定性的边缘情况应该通过其它方式保证,比如开发、测试人员、寻找更稳定的类库等。

总结

这就是阻止您编写有效单元测试的五个陷阱。既然您了解它们,您可以通过执行以下操作来避免它们:

  • 为功能的每个部分而不是每个函数编写测试

  • 不痴迷于代码覆盖率,而是专注于测试有风险的代码

  • 最小化Mock模拟代码

  • 确保您的测试可能会失败

  • 将不确定性排除在测试之外

这将使您的系统更加稳定,另外经过良好测试的软件让您可以自信地进行更改和快速部署。

引用

https://github.com/google/googletest

https://betterprogramming.pub/advanced-unit-tests-5-pitfalls-and-how-to-avoid-them-eb6e04ec9654

https://developer.ibm.com/articles/au-googletestingframework/

https://www.froglogic.com/blog/code-coverage-of-unit-tests-written-with-google-test/

推荐

A Big Picture of Kubernetes

Kubernetes入门培训(内含PPT)

原创不易,随手关注或者”在看“,诚挚感谢!