11.3. 测试覆盖率

就其性质而言, 测试不可能是完整的. 计算机科学家 Edsger Dijkstra 曾说过: "测试可以显示存在缺陷, 但是并不是说没有BUG." 再多的测试也不能证明一个包没有BUG. 在最好的情况下, 测试可以增强我们的信息, 包在我们测试的环境是可以正常工作的.

由测试驱动触发运行到的被测试函数的代码数目称为测试的覆盖率. 测试覆盖率并不能量化 — 甚至连最简单的动态程序也难以精确测量 — 但是可以启发并帮助我们编写的有效的测试代码.

这些帮助信息中语句的覆盖率是最简单和最广泛使用的. 语句的覆盖率是指在测试中至少被运行一次的代码占总代码数的比例. 在本节中, 我们使用 go test 中集成的测试覆盖率工具, 来度量下面代码的测试覆盖率, 帮助我们识别测试和我们期望间的差距.

The code below is a table-driven test for the expression evaluator we built back in Chapter 7:

下面的代码是一个表格驱动的测试, 用于测试第七章的表达式求值程序:

gopl.io/ch7/eval

func TestCoverage(t *testing.T) {
    var tests = []struct {
        input string
        env   Env
        want  string // expected error from Parse/Check or result from Eval
    }{
        {"x % 2", nil, "unexpected '%'"},
        {"!true", nil, "unexpected '!'"},
        {"log(10)", nil, `unknown function "log"`},
        {"sqrt(1, 2)", nil, "call to sqrt has 2 args, want 1"},
        {"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
        {"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
        {"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
    }

    for _, test := range tests {
        expr, err := Parse(test.input)
        if err == nil {
            err = expr.Check(map[Var]bool{})
        }
        if err != nil {
            if err.Error() != test.want {
                t.Errorf("%s: got %q, want %q", test.input, err, test.want)
            }
            continue
        }
        got := fmt.Sprintf("%.6g", expr.Eval(test.env))
        if got != test.want {
            t.Errorf("%s: %v => %s, want %s",
                test.input, test.env, got, test.want)
        }
    }
}

首先, 我们要确保所有的测试都正常通过:

$ go test -v -run=Coverage gopl.io/ch7/eval
=== RUN TestCoverage
--- PASS: TestCoverage (0.00s)
PASS
ok      gopl.io/ch7/eval         0.011s

下面这个命令可以显示测试覆盖率工具的用法信息:

$ go tool cover
Usage of 'go tool cover':
Given a coverage profile produced by 'go test':
    go test -coverprofile=c.out

Open a web browser displaying annotated source code:
    go tool cover -html=c.out
...

go tool 命令运行Go工具链的底层可执行程序. 这些底层可执行程序放在 $GOROOT/pkg/tool/${GOOS}_${GOARCH} 目录. 因为 go build 的原因, 我们很小直接调用这些底层工具.

现在我们可以用 -coverprofile 标志参数重新运行:

$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval
ok      gopl.io/ch7/eval         0.032s      coverage: 68.5% of statements

这个标志参数通过插入生成钩子代码来统计覆盖率数据. 也就是说, 在运行每个测试前, 它会修改要测试代码的副本, 在每个块都会设置一个布尔标志变量. 当被修改后的被测试代码运行退出时, 将统计日志数据写入 c.out 文件, 并打印一部分执行的语句的一个总结. (如果你需要的是摘要,使用 go test -cover.)

如果使用了 -covermode=count 标志参数, 那么将在每个代码块插入一个计数器而不是布尔标志量. 在统计结果中记录了每个块的执行次数, 这可以用于衡量哪些是被频繁执行的热点代码.

为了收集数据, 我们运行了测试覆盖率工具, 打印了测试日志, 生成一个HTML报告, 然后在浏览器中打开(图11.3).

$ go tool cover -html=c.out

绿色的代码块被测试覆盖到了, 红色的则表示没有被覆盖到. 为了清晰起见, 我们将的背景红色文本的背景设置成了阴影效果. 我们可以马上发现 unary 操作的 Eval 方法并没有被执行到. 如果我们针对这部分未被覆盖的代码添加下面的测试, 然后重新运行上面的命令, 那么我们将会看到那个红色部分的代码也变成绿色了:

{"-x * -x", eval.Env{"x": 2}, "4"}

不过两个 panic 语句依然是红色的. 这是没有问题的, 因为这两个语句并不会被执行到.

实现 100% 的测试覆盖率听起来很好, 但是在具体实践中通常是不可行的, 也不是值得推荐的做法. 因为那只能说明代码被执行过而已, 并不意味着代码是没有BUG的; 因为对于逻辑复杂的语句需要针对不同的输入执行多次. 有一些语句, 例如上面的 panic 语句则永远都不会被执行到. 另外, 还有一些隐晦的错误在现实中很少遇到也很难编写对应的测试代码. 测试从本质上来说是一个比较务实的工作, 编写测试代码和编写应用代码的成本对比是需要考虑的. 测试覆盖率工具可以帮助我们快速识别测试薄弱的地方, 但是设计好的测试用例和编写应用代码一样需要严密的思考.