深入了解Go中的单元测试

450 阅读8分钟

在单元测试中,开发人员测试单个函数、方法、模块和包,以验证其正确性。单元测试有助于在开发周期的早期发现和修复错误,并在重构时防止回归。一个好的单元测试也可以作为一种文档形式,供新加入项目的开发人员使用。

在本教程中,我们将介绍如何使用内置的测试包和几个外部工具在Go中编写单元测试。在本文结束时,你将了解诸如表驱动测试、依赖注入和代码覆盖率等概念。

让我们开始吧!

在Go中编写你的第一个测试

为了理解Go中的测试,我们将编写一个基本程序,计算两个整数的乘积。然后,我们将写一个测试来验证其输出的正确性。

首先,在你的文件系统中创建一个目录,并导航到它。在根目录下,创建一个名为integers.go 的文件,并添加以下代码。

// integers.go
package main

import (
    "fmt"
)

// Multiply returns the product of two integers
func Multiply(a, b int) int {
    return a * b
}

让我们写一个测试,以验证Multiply() 功能是否正确工作。在当前目录下,创建一个名为integers_test.go 的文件,并在其中添加以下代码。

剖析Go测试

在Go中命名测试文件的惯例是以_test.go 为后缀来结束文件名,并将文件放在与测试代码相同的目录中。在上面的例子中,Multiply 函数在integers.go 中,所以它的测试被放在integers_test.go 中。请注意,Go不会在其生产的任何二进制文件中提供测试文件,因为代码运行时不需要这些文件。在Go中,一个测试函数必须始终使用以下签名。

func TestXxx(*testing.T) 

测试的名称以Test 前缀开始,后面是被测试的函数的名称,Xxx 。它需要一个参数,是一个类型为testing.T 的指针。该类型为报告错误、记录中间值和指定辅助方法等任务输出了几种方法。

在我们上一节的例子中,TestMultiply() 函数内部的got 变量被分配给Multiply(2, 3) 函数调用的结果。want 被分配给预期结果6

测试的后半部分检查wantgot 的值是否相等。如果不是,Errorf() 方法被调用,测试失败。

运行Go测试

现在,让我们使用go test 命令,在终端运行我们的测试。只要安装了Go,go test 命令就已经在你的机器上可用。

go test 命令编译在当前目录下找到的源代码、文件和测试,然后运行产生的测试二进制文件。当测试完成后,测试的摘要,无论是PASS 还是FAIL ,都会被打印到控制台,如下面的代码块所示。

$ go test
PASS
ok      github.com/ayoisaiah/random 0.003s

当你像上面那样使用go test ,缓存被禁用,所以测试每次都会被执行。
你也可以通过使用go test . ,选择进入软件包列表模式,它可以缓存成功的测试结果,避免不必要的重新运行。

你可以通过传递包的相对路径来运行特定包中的测试,例如,go test ./package-name 。此外,你可以使用go test ./... 来运行代码库中所有包的测试。

$ go test .
ok      github.com/ayoisaiah/random (cached)

如果你将-v 标志附加到go test ,测试将打印出所有执行的测试函数的名称以及执行这些函数所花费的时间。此外,测试会显示打印到错误日志的输出,例如,当你使用t.Log()t.Logf()

$ go test -v
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.002s

让我们通过将want 改为7 ,使我们的测试失败。再次运行go test ,并检查其输出。

$ go test -v
--- FAIL: TestMultiply (0.00s)
    integers_test.go:10: Expected '7', but got '6'
FAIL
exit status 1
FAIL    github.com/ayoisaiah/random 0.003s

正如你所看到的,测试失败了,传递给t.Errorf() 函数的信息也出现在失败信息中。如果你把want 的值返回到6 ,测试将再次通过。

Go中的表驱动测试

上面的测试例子只包含一个案例。然而,任何合理的综合测试都会有多个测试案例,确保每个单元的代码都能针对一系列的值进行充分的审核。

在Go中,我们使用表驱动的测试,它允许我们在一个片断中定义所有的测试用例,对它们进行迭代,并进行比较以确定测试用例的成功或失败。

type testCase struct {
    arg1 int
    arg2 int
    want int
}

func TestMultiply(t *testing.T) {
    cases := []testCase{
        {2, 3, 6},
        {10, 5, 50},
        {-8, -3, 24},
        {0, 9, 0},
        {-7, 6, -42},
    }

    for _, tc := range cases {
        got := Multiply(tc.arg1, tc.arg2)
        if tc.want != got {
            t.Errorf("Expected '%d', but got '%d'", tc.want, got)
        }
    }
}

在上面的代码片断中,我们使用testCase 结构来定义每个测试用例的输入。
arg1arg2 属性代表Multiply 的参数,而want 是测试用例的预期结果。

cases 片断用于设置Multiply 函数的所有测试用例。注意,为了简单起见,属性名称被省略了。

为了测试每个案例,我们需要遍历cases slice,将每个案例中的arg1arg2 传给Multiply() ,然后确认返回值是否等于want 指定的值。我们可以根据需要使用这种设置来测试许多案例。

如果你再次运行该测试,它将成功通过。

$ go test -v
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
PASS
ok      github.com/ayoisaiah/random     0.002s

测试失败的信号

在上面的例子中,我们使用了t.Errorf() 方法来失败测试。使用t.Errorf() 相当于调用t.Logf() ,它在测试失败时或提供-v 标志时将文本记录到控制台,然后是t.Fail() ,它将当前函数标记为失败而不停止其执行。

使用t.Errorf() ,可以防止在我们停止函数时出现测试失败,使我们能够收集更多的信息来解决问题。此外,在一个表格驱动的测试中,t.Errorf() ,允许我们在不影响其他测试执行的情况下失败一个特定的案例。

如果一个测试函数不能从失败中恢复,你可以通过调用t.Fatal()t.Fatalf() 来立即停止它。这两种方法都将当前函数标记为失败,立即停止其执行。这些方法相当于调用t.Log()t.Logf() ,然后再调用t.FailNow()

使用子测试

使用表格驱动的测试是有效的,然而,有一个主要的缺陷--无法在不运行所有测试案例的情况下有选择地运行单个测试案例。

解决这个问题的一个办法是注释掉所有当下不相关的测试用例,以后再取消注释。然而,这样做是很繁琐的,而且容易出错。在这种情况下,我们将使用一个子测试!

在Go 1.7中,我们可以通过给testing.T 类型添加一个Run() 方法,将每个测试用例分割成一个独特的测试,在一个单独的goroutine中运行。Run() 方法将子测试的名称作为其第一个参数,将子测试的函数作为第二个参数。你可以使用测试名称来识别并单独运行子测试。

为了看到它的作用,让我们更新我们的TestMultiply 测试,如下图所示。

func TestMultiply(t *testing.T) {
    cases := []testCase{
        {2, 3, 6},
        {10, 5, 50},
        {-8, -3, 24},
        {0, 9, 0},
        {-7, 6, -42},
    }

    for _, tc := range cases {
        t.Run(fmt.Sprintf("%d*%d=%d", tc.arg1, tc.arg2, tc.want), func(t *testing.T) {
            got := Multiply(tc.arg1, tc.arg2)
            if tc.want != got {
                t.Errorf("Expected '%d', but got '%d'", tc.want, got)
            }
        })
    }
}

现在,当你用-v 标志运行测试时,每个单独的测试案例将在输出中报告。因为我们从每个测试用例的值中构建了每个测试的名称,所以很容易识别失败的特定测试用例。

为了命名我们的测试案例,我们将在testCase 结构中添加一个name 属性。值得注意的是,TestMultiply 函数在其所有子测试都退出后才结束运行。

$ go test -v
=== RUN   TestMultiply
=== RUN   TestMultiply/2*3=6
=== RUN   TestMultiply/10*5=50
=== RUN   TestMultiply/-8*-3=24
=== RUN   TestMultiply/0*9=0
=== RUN   TestMultiply/-7*6=-42
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/2*3=6 (0.00s)
    --- PASS: TestMultiply/10*5=50 (0.00s)
    --- PASS: TestMultiply/-8*-3=24 (0.00s)
    --- PASS: TestMultiply/0*9=0 (0.00s)
    --- PASS: TestMultiply/-7*6=-42 (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.003s

测量代码覆盖率

代码覆盖率计算的是测试套件运行时成功执行的代码行数,代表测试套件覆盖的代码的百分比。例如,如果你的代码覆盖率为80%,这意味着20%的代码库缺少测试。

Go 的内置代码覆盖率方法

Go提供了一个内置的方法来检查你的代码覆盖率。从Go v1.2开始,开发者可以使用-cover 选项与go test ,生成代码覆盖率报告。

$ go test -cover
PASS
coverage: 100.0% of statements
ok      github.com/ayoisaiah/random 0.002s

我们的代码已经成功实现了100%的测试覆盖率,但是,我们只对一个函数进行了全面的测试。让我们在integers.go 文件中添加一个新的函数,而不给它写一个测试。

// integers.go

// Add returns the summation of two integers
func Add(a, b int) int {
  return a + b
}

当我们用-cover 选项再次运行测试时,我们会发现覆盖率只有50%。

$ go test -cover
PASS
coverage: 50.0% of statements
ok      github.com/ayoisaiah/random 0.002s

检查我们的代码库

虽然我们知道代码库的覆盖率是多少,但我们不知道代码库的哪些部分没有被覆盖。让我们使用--coverprofile 选项将覆盖率报告转换为文件,这样我们就可以更仔细地检查它。

$ go test -coverprofile=coverage.out
PASS
coverage: 50.0% of statements
ok      github.com/ayoisaiah/random 0.002s

在上面的代码块中,测试像以前一样运行,代码覆盖率被打印到控制台。
但是,测试结果也被保存到当前工作目录下的一个新文件中,名为coverage.out 。为了研究这些结果,让我们运行下面的命令,它将覆盖率报告按函数分解。

$ go tool cover -func=coverage.out
github.com/ayoisaiah/random/integers.go:4:    Multiply    100.0%
github.com/ayoisaiah/random/integers.go:9:    Add     0.0%
total:                            (statements)    50.0%

上面的代码块显示,Multiply() 函数被完全覆盖,而Add() 函数的整体覆盖率只有50%。

HTML覆盖率方法

另一种查看结果的方法是通过HTML表示。下面的代码块会自动打开默认的网页浏览器,用绿色显示覆盖的行,红色显示未覆盖的行,灰色显示未计算的语句。

$ go tool cover -html=coverage.out

使用HTML覆盖方法,可以很容易直观地看到你还没有覆盖的内容。如果被测试的包有多个文件,你可以从右上方的输入中选择每个文件来查看它的覆盖率明细。

HTML Coverage Method Visual Output

让我们通过添加一个Add() 函数的测试,使代码覆盖率恢复到100%,如下图所示。

func TestAdd(t *testing.T) {
    cases := []test{
        {1, 1, 2},
        {7, 5, 12},
        {-19, -3, -22},
        {-1, 8, 7},
        {-12, 0, -12},
    }

    for _, tc := range cases {
        got := Add(tc.arg1, tc.arg2)
        if tc.want != got {
            t.Errorf("Expected '%d', but got '%d'", tc.want, got)
        }
    }
}

再次运行测试应该显示代码覆盖率为100%。

$ go test -cover
PASS
coverage: 100.0% of statements
ok      github.com/ayoisaiah/random/integers    0.003s

运行一个特定的测试

假设你有很多测试文件和函数,但你想只隔离一个或几个来执行。我们可以使用-run 选项来做到这一点。例如,如果我们想只运行Add 函数的测试,我们就把测试函数的名称作为参数传给-run

$ go test -v -run=TestAdd
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/ayoisaiah/random/integers    0.003s

从上面的输出可以看出,只有TestAdd 方法被执行。请注意,-run 的参数被解释为正则表达式,所以所有与所提供的regex相匹配的测试都将被运行。

如果你有一组以相同前缀开始的测试函数,如TestAdd_NegativeNumbersTestAdd_PositiveNumbers ,你可以通过将前缀TestAdd 传递给-run 来单独运行它们。

现在,让我们假设我们只想运行TestAddTestMultiply ,但我们还有其他测试函数。我们可以在-run 的参数中使用一个管道字符来分隔它们的名字。

$ go test -v -run='TestAdd|TestMultiply'
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/ayoisaiah/random/integers    0.002s

你也可以通过向-run 传递一个特定的子测试的名称来运行它。例如,我们可以运行TestMultiply() 函数中的任何子测试,如下图所示。

$ go test -v -run='TestMultiply/2*3=6'
=== RUN   TestMultiply
=== RUN   TestMultiply/2*3=6
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/2*3=6 (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.003s

依赖性注入

让我们假设我们有一个向控制台打印一些输出的函数,如下图所示。

// printer.go
func Print(text string) {
    fmt.Println(text)
}

上面的Print() 函数将其字符串参数输出到控制台。为了测试它,我们必须捕获它的输出,并与预期值进行比较。然而,由于我们无法控制fmt.Println() 的实现,所以在我们的案例中,使用这种方法是行不通的。相反,我们可以重构Print() 函数,使其更容易捕获输出。

首先,让我们把对Println() 的调用换成对Fprintln() 的调用,它把一个io.Writer 接口作为它的第一个参数,指定它的输出应该写在哪里。在我们下面的例子中,这个位置被指定为os.Stdout 。现在,我们可以匹配Println 所提供的行为。

func Print(text string) {
    fmt.Fprintln(os.Stdout, text)
}

对于我们的函数来说,我们在哪里打印文本并不重要。因此,我们应该接受一个io.Writer 接口并将其传递给fmt.Fprintln ,而不是硬编码os.Stdout

func Print(text string, w io.Writer) {
    fmt.Fprintln(w, text)
}

现在,我们可以控制Print() 函数的输出被写在哪里,从而使我们的函数的测试变得容易。在下面的测试例子中,我们将使用一个字节的缓冲区来捕获Print() 的输出,然后将其与预期结果进行比较。

// printer_test.go
func TestPrint(t *testing.T) {
    var buf bytes.Buffer

    text := "Hello, World!"

    Print(text, &buf)

    got := strings.TrimSpace(buf.String())

    if got != text {
        t.Errorf("Expected output to be: %s, but got: %s", text, got)
    }
}

当在你的源代码中利用Print() ,你可以很容易地注入一个具体的类型并写到标准输出。

func main() {
    Print("Hello, World!", os.Stdout)
}

虽然上面的例子很琐碎,但它说明了从一个专门的函数到一个通用的函数的一种方法,允许注入不同的依赖关系。

结论

编写单元测试可以确保每个单元的代码都能正常工作,增加你的应用程序作为一个整体按计划运作的机会。

在重构时,有足够的单元测试也很方便,有助于防止回归。内置的测试包和go test 命令为你提供了大量的单元测试能力。你可以通过参考官方文档来了解更多。

谢谢你的阅读,并祝你编码愉快