你为所有内容编写了测试;也许你的项目仓库中甚至有一个徽章,表明 100% 的测试覆盖率。但是这些测试能帮助你做什么?你如何知道?
单元测试的成本对于开发者来说是显而易见的。测试必须编写。有时它们不能按预期工作:会出现误报或不稳定的测试,在没有任何代码更改的情况下,成功和失败交替出现。你可以通过单元测试找到的小错误很有价值,但通常它们会在开发者机器上悄悄发生,并在提交到版本控制之前修复。但真正令人担忧的错误大多是隐藏的。而最糟糕的是,缺失的警报是完全不可见的:在你未能捕获的错误进入用户手中之前,你不会看到它们——有时甚至在那之后也看不到。
有一种类型的测试可以使不可见的错误可见:突变测试。
突变测试以算法方式修改源代码,并检查是否有任何“突变体”在每个测试中幸存下来。任何在单元测试中幸存下来的突变体都是一个问题:这意味着对代码的修改(可能引入了一个错误)没有被标准测试套件捕获。
Python 中的一个突变测试框架是 mutmut
。
假设你需要编写代码来计算模拟时钟上时针和分针之间的夹角,精确到最接近的度数。代码可能看起来像这样
def hours_hand(hour, minutes):
base = (hour % 12 ) * (360 // 12)
correction = int((minutes / 60) * (360 // 12))
return base + correction
def minutes_hand(hour, minutes):
return minutes * (360 // 60)
def between(hour, minutes):
return abs(hours_hand(hour, minutes) - minutes_hand(hour, minutes))
首先,编写一个简单的单元测试
import angle
def test_twelve():
assert angle.between(12, 00) == 0
这足够了吗?代码没有 if
语句,所以如果你检查覆盖率
$ coverage run `which pytest`
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
rootdir: /home/moshez/src/mut-mut-test
collected 1 item
tests/test_angle.py . [100%]
============================== 1 passed in 0.01s ===============================
完美!测试通过了,覆盖率达到 100%。你是测试专家。但是当你使用突变测试时,覆盖率看起来如何呢?
$ mutmut run --paths-to-mutate angle.py
<snip>
Legend for output:
? Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
? Suspicious. Tests took a long time, but not long enough to be fatal.
? Survived. This means your tests needs to be expanded.
? Skipped. Skipped.
<snip>
⠋ 21/21 ? 5 ⏰ 0 ? 0 ? 16 ? 0
哦,不。在 21 个突变体中,有 16 个幸存下来。只有五个案例通过了突变测试。但这意味着什么?
对于每个突变测试,mutmut
修改了你的源代码部分,以模拟潜在的错误。一个修改的例子是将 >
比较更改为 >=
,看看会发生什么。如果对于这个边界条件没有单元测试,则此突变将“幸存”:这是一个潜在的错误,没有任何测试会检测到它。
是时候编写更好的单元测试了。很容易通过 results
检查进行了哪些更改
$ mutmut results
<snip>
Survived ? (16)
---- angle.py (16) ----
4-7, 9-14, 16-21
$ mutmut apply 4
$ git diff
diff --git a/angle.py b/angle.py
index b5dca41..3939353 100644
--- a/angle.py
+++ b/angle.py
@@ -1,6 +1,6 @@
def hours_hand(hour, minutes):
hour = hour % 12
- base = hour * (360 // 12)
+ base = hour / (360 // 12)
correction = int((minutes / 60) * (360 // 12))
return base + correction
这是一个 mutmut
执行的典型突变示例;它分析源代码并将运算符更改为不同的运算符:加法变为减法,或者在本例中,乘法变为除法。一般来说,单元测试应该捕获运算符更改时的错误;否则,它们就不能有效地测试行为。按照这个逻辑,mutmut
会仔细检查源代码,以再次检查你的测试。
你可以使用 mutmut apply
来应用失败的突变体。哇,事实证明你几乎没有检查 hour
参数是否被正确使用。修复它
$ git diff
diff --git a/tests/test_angle.py b/tests/test_angle.py
index f51d43a..1a2e4df 100644
--- a/tests/test_angle.py
+++ b/tests/test_angle.py
@@ -2,3 +2,6 @@ import angle
def test_twelve():
assert angle.between(12, 00) == 0
+
+def test_three():
+ assert angle.between(3, 00) == 90
之前,你只测试了 12。添加对 3 的测试是否足以改进?
$ mutmut run --paths-to-mutate angle.py
<snip>
⠋ 21/21 ? 7 ⏰ 0 ? 0 ? 14 ? 0
这个新测试成功杀死了两个突变体——比以前好,但仍然有很长的路要走。我不会详细分析剩下的 14 个案例来修复,因为我认为模式很清晰。(你能将它们减少到零吗?)
突变测试是另一种工具,与覆盖率测量一起,可以让你了解你的测试套件有多全面。使用它可以证明测试需要改进:任何一个幸存的突变体都是人类在编写代码时可能会犯的错误,以及潜入你的程序中的潜在错误。继续测试,祝你狩猎愉快。
5 条评论