在 Go 语言中,单元测试是确保代码质量和稳定性的重要工具。Go 提供了一个强大的 testing 包,使得编写和运行测试变得高效而简便。在这篇文章中,我们将深入探讨 Go 的单元测试功能,包括如何编写测试函数、测试文件的组织方式,以及如何运行测试。我们还将介绍更高级的测试用法,如基准测试(Benchmarks)、示例测试(Examples)、模糊测试(Fuzzing)、跳过测试(Skipping)、子测试(Subtests)及主测试(Main)等。

Golang 单元测试基础

在 Go 中,测试函数以 TestXxx 命名,其中 Xxx 是测试函数的名称,必须以大写字母开头。这些测试函数会被 go test 命令自动执行。以下是一个基本的测试函数示例:

package abs

import "testing"

// TestAbs 测试 Abs 函数的行为
func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

在上面的例子中,TestAbs 是一个测试函数,它测试 Abs 函数的功能。testing.T 是一个用于报告测试结果的类型。通过调用 t.Errorf 方法,我们可以报告测试失败的情况,并提供期望值与实际值的详细信息。

Golang 单元测试文件的组织

为了使测试与生产代码分开,Go 要求将测试代码放在单独的文件中。这些文件的名称必须以 _test.go 结尾。例如,如果我们正在测试一个名为 abs 的包,那么测试文件应该命名为 abs_test.go

同包测试

如果测试文件和被测试的代码在同一个包中,那么测试文件可以访问该包的未导出的标识符。这使得测试可以更细粒度地检查包的内部实现。以下是一个同包测试的例子:

package abs

import "testing"

// TestAbs 测试 Abs 函数的行为
func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

在这个例子中,测试文件与被测试的包处于同一目录下,因此可以直接调用 Abs 函数,即使它是未导出的。

黑盒测试

如果测试文件和被测试的代码在不同的包中,测试文件必须显式导入被测试的包,并且只能访问其导出的标识符。这种测试方式被称为“黑盒测试”,因为测试者不需要了解被测试包的内部实现细节。以下是一个黑盒测试的例子:

package abs_test

import (
    "testing"
    "path_to_pkg/abs"
)

// TestAbs 测试 Abs 函数的行为
func TestAbs(t *testing.T) {
    got := abs.Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

在这个例子中,测试文件位于一个不同的包 abs_test 中,我们通过导入 abs 包来访问其导出的函数。

基准测试(Benchmarks)

基准测试用于测量代码的性能。基准测试函数以 BenchmarkXxx 命名,其中 Xxx 是基准测试函数的名称,必须以大写字母开头。基准测试函数的参数是 *testing.B 类型。基准测试函数的目标是运行代码 b.N 次,以确保性能测量的可靠性。以下是一个基准测试的示例:

package abs

import (
    "math/rand"
    "testing"
)

// BenchmarkRandInt 基准测试 rand.Int 的性能
func BenchmarkRandInt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        rand.Int()
    }
}

在这个例子中,BenchmarkRandInt 测试了 rand.Int 函数的性能。基准测试会多次运行,直到测试时间足够长以提供可靠的结果。测试输出会显示每秒执行的次数以及每次操作的平均时间,例如:

BenchmarkRandInt-8   	68453040	        17.8 ns/op

这表示循环运行了 68,453,040 次,每次操作的时间为 17.8 纳秒。

如果基准测试需要一些昂贵的初始化操作,可以在测试开始后调用 b.ResetTimer 来重置计时器:

package abs

import "testing"

// BenchmarkBigLen 基准测试 Big.Len 的性能
func BenchmarkBigLen(b *testing.B) {
    big := NewBig()  // 初始化
    b.ResetTimer()   // 重置计时器
    for i := 0; i < b.N; i++ {
        big.Len()
    }
}

要在并行环境中测试性能,可以使用 b.RunParallel 辅助函数,并结合 go test -cpu 标志运行基准测试:

package abs

import (
    "bytes"
    "html/template"
    "testing"
)

// BenchmarkTemplateParallel 基准测试并行模板执行的性能
func BenchmarkTemplateParallel(b *testing.B) {
    templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
    b.RunParallel(func(pb *testing.PB) {
        var buf bytes.Buffer
        for pb.Next() {
            buf.Reset()
            templ.Execute(&buf, "World")
        }
    })
}

在这个例子中,BenchmarkTemplateParallel 使用并行运行来测试模板执行的性能。

有关基准测试结果格式的详细说明,请参见 Benchmark Format。要处理基准测试结果,可以使用 benchstat 等工具进行统计比较。

示例测试(Examples)

Go 的测试框架不仅支持功能验证,还允许通过示例函数展示代码的用法。示例函数可以用于展示包、函数、类型或方法的实际使用方式,并通过比较实际输出和预期输出来验证其正确性。

示例函数的定义和命名约定

示例函数的命名遵循一定的规则,确保示例代码能够正确运行和验证输出。以下是不同类型的示例函数及其命名约定:

1. 包的示例函数

用于展示整个包的用法。示例函数的名称为 Example

func Example() {
    // 示例代码
}

2. 函数的示例函数

用于展示特定函数的用法。示例函数的名称为 ExampleF,其中 F 是函数的名称:

func ExampleFunctionName() {
    // 示例代码
}

3. 类型的示例函数

用于展示特定类型的用法。示例函数的名称为 ExampleT,其中 T 是类型的名称:

func ExampleTypeName() {
    // 示例代码
}

4. 类型方法的示例函数

用于展示特定类型的方法的用法。示例函数的名称为 ExampleT_M,其中 T 是类型名称,M 是方法名称:

func ExampleTypeName_MethodName() {
    // 示例代码
}

5. 带后缀的示例函数

如果需要展示多种用法或不同的场景,可以为示例函数添加后缀。后缀必须以小写字母开头,用于区分不同的示例:

  • 包的示例(带后缀)
    func Example_suffix() {
        // 示例代码
    }
    
  • 函数的示例(带后缀)
    func ExampleFunctionName_suffix() {
    // 示例代码
    }
    
  • 类型的示例(带后缀)
    func ExampleTypeName_suffix() {
        // 示例代码
    }
    
  • 类型方法的示例(带后缀)
    func ExampleTypeName_MethodName_suffix() {
        // 示例代码
    }
    

例如,展示一个名为 Calculate 的函数的不同用法可以写成:

func ExampleCalculate_positive() {
    // 示例代码
}

func ExampleCalculate_negative() {
    // 示例代码
}

示例函数的输出

示例函数可以包括一个以 Output: 开头的注释行,用于与实际输出进行比较。标准输出的比较忽略前后的空白字符。如果输出顺序无关紧要,可以使用 Unordered output: 注释:

func ExampleHello() {
    fmt.Println("hello")
    // Output: hello
}

func ExampleSalutations() {
    fmt.Println("hello, and")
    fmt.Println("goodbye")
    // Output:
    // hello, and
    // goodbye
}

func ExamplePerm() {
    for _, value := range Perm(5) {
        fmt.Println(value)
    }
    // Unordered output: 4
    // 2
    // 1
    // 3
    // 0
}

没有输出注释的示例函数会被编译,但不会执行。

模糊测试(Fuzzing)

模糊测试通过用随机生成的输入调用函数,发现单元测试未预见的错误。Go 的 testing 包和 go test 命令支持模糊测试,模糊测试函数的形式为:

func FuzzXxx(*testing.F)

其中 Xxx 是模糊测试函数的名称。以下是一个模糊测试的示例:

// Concat 拼接两个字符串,并在它们之间插入分隔符
func Concat(a, b, sep string) string {
	return strings.TrimSpace(a + sep + b)
}

// FuzzConcat 模糊测试 Concat 函数
func FuzzConcat(f *testing.F) {
	// 添加一些种子输入
	f.Add("hello", "world", ",")
	// f.Add(" ", " ", " ")

	// 模糊测试目标函数
	f.Fuzz(func(t *testing.T, a, b, sep string) {
		result := Concat(a, b, sep)
		expected := a + sep + b

		// 调试用:打印测试输入
		t.Logf("Testing with inputs: %q, %q, %q", a, b, sep)

		// 故意引入错误:当输入以空格结尾时,预期结果与实际结果空字符串不相等
		if result != expected {
			t.Fatalf("Concat(%q, %q, %q) = %q; want %q", a, b, sep, result, expected)
		}
	})
}

在模糊测试中,(*F).Add 方法用于注册初始的种子输入,这些输入会被用于生成测试用例。模糊测试会通过对种子输入进行随机修改,生成新的测试输入来执行模糊测试目标函数。目标函数必须接受 *testing.T 参数,并可接受一个或多个随机生成的输入参数。

在这个示例中,FuzzConcat 函数首先使用 f.Add 方法添加了一些种子输入数据。接着,f.Fuzz 方法定义了模糊测试的核心逻辑,该逻辑会对每个生成的随机输入执行 Concat 操作,并检查返回的结果是否与符合预期。

模糊测试可以使用 -fuzz 标志启用,指定要测试的模糊测试函数的正则表达式。当启用模糊测试时,测试执行会生成新的测试输入,并通过模糊测试覆盖率插桩来发现潜在的错误。如果模糊测试目标函数因某个输入失败,模糊测试引擎会将该输入写入 testdata/fuzz/<Name> 目录中的文件,以便后续的回归测试和错误分析。如果该目录不可写,模糊测试引擎会将文件写入构建缓存目录中的模糊缓存目录。

通过设置 -fuzz 标志来启用模糊测试,并指定要运行的模糊测试函数。例如:

go test -fuzz=FuzzConcat -fuzztime=10s

测试结果输出:

fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
failure while testing seed corpus entry: FuzzConcat/58f8c00c53c2b0ed
fuzz: elapsed: 0s, gathering baseline coverage: 1/5 completed
--- FAIL: FuzzConcat (0.03s)
    --- FAIL: FuzzConcat (0.00s)
        main_test.go:25: Testing with inputs: "0", "", "000 "
        main_test.go:29: Concat("0", "", "000 ") = "0000"; want "0000 "

FAIL
exit status 1
FAIL	github.com/axiaoxin-com/tests	0.370s

testdata/fuzz/<Name> 目录中的文件内容如下:

cat testdata/fuzz/FuzzConcat/58f8c00c53c2b0ed                                      1 ↵
go test fuzz v1
string("0")
string("")
string("000 ")

更多关于模糊测试的信息,可以参考 Go 官方文档

跳过测试(Skipping)

在 Go 测试中,有时你可能需要在运行时跳过某些测试或基准测试。例如,当某些条件不满足时,跳过测试可以避免不必要的测试执行。你可以使用 *testing.T*testing.BSkip 方法来实现这一点。

跳过测试

在测试函数中,你可以使用 t.Skip 方法跳过测试。这通常用于条件不满足的情况下,例如在短模式下跳过耗时测试:

func TestTimeConsuming(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode.")
    }
    // 测试代码...
}

在这个例子中,testing.Short() 用于检查是否启用了短模式,如果是,则调用 t.Skip 跳过测试。

短模式(Short Mode)

短模式是 Go 测试的一种运行模式,它可以通过在执行 go test 时使用 -short 标志来启用。启用短模式通常用于快速测试,只运行较快的测试,以避免耗时的测试。例如,当你希望在进行开发时快速检查代码的基本功能时,短模式非常有用。

go test -short

跳过模糊测试

在模糊测试中,如果输入数据无效,可以使用 t.Skip 方法跳过该输入的测试,但这不应该被认为是测试失败。例如:

func FuzzJSONMarshaling(f *testing.F) {
	f.Fuzz(func(t *testing.T, b []byte) {
		t.Logf("b: %v", string(b))
		var v int
		if err := json.Unmarshal(b, &v); err != nil {
			t.Skip()
		}
		if _, err := json.Marshal(v); err != nil {
			t.Errorf("Marshal: %v", err)
		}
	})
}

在这个例子中,t.Skip() 被用来跳过那些无法解析的 JSON 数据的测试。这种跳过不会被视为测试失败,而是处理了无效输入的情况。

子测试(Subtests)及子基准测试(Sub-benchmarks)

在 Go 语言的测试中,子测试和子基准测试使得测试和基准测试的组织更加灵活。通过使用 T.RunB.Run 方法,可以在一个测试或基准测试中定义多个子测试或子基准测试,无需为每个子测试或子基准测试定义单独的函数。这种方法使得表驱动测试(table-driven testing)和层级测试变得更加容易,同时也提供了共享的设置和清理代码的方式。

子测试

子测试可以通过在测试函数中调用 t.Run 方法来定义。每个子测试都有一个唯一的名称,该名称由顶级测试的名称和传递给 Run 的名称序列组成,名称之间用斜杠分隔,必要时还可以加上序列号以区分。下面是一个子测试的示例:

package abs

import "testing"

// TestFoo 使用子测试演示测试分组
func TestFoo(t *testing.T) {
    // 共享的设置代码
    t.Run("A=1", func(t *testing.T) {
        // 子测试 A=1 的代码
    })
    t.Run("A=2", func(t *testing.T) {
        // 子测试 A=2 的代码
    })
    t.Run("B=1", func(t *testing.T) {
        // 子测试 B=1 的代码
    })
    // 共享的拆解代码
}

表驱动测试 table-driven tests

表驱动测试是一种常见的模式,用于将多个测试用例组织在一个表格中,并通过循环运行这些用例。这种方法可以减少代码重复并提高可维护性。以下是一个使用表驱动测试的子测试示例:

package abs

import "testing"

// TestAbsTableDriven 使用表驱动测试和子测试
func TestAbsTableDriven(t *testing.T) {
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"negative", -1, 1},
        {"zero", 0, 0},
        {"positive", 1, 1},
    }

    for _, tt := range tests {
        tt := tt // capture range variable
        t.Run(tt.name, func(t *testing.T) {
            got := Abs(tt.input)
            if got != tt.want {
                t.Errorf("Abs(%d) = %d; want %d", tt.input, got, tt.want)
            }
        })
    }
}

在这个示例中,我们定义了一个包含多个测试用例的测试表,每个测试用例都有一个名称、输入值和期望值。使用 t.Run 方法,我们可以为每个测试用例创建一个子测试,并验证 Abs 函数的行为。

子基准测试

子基准测试允许在基准测试中定义多个子基准测试。使用 b.Run 方法,可以组织复杂的性能测试用例。以下是一个子基准测试的示例:

package abs

import "testing"

// BenchmarkAbsTableDriven 使用表驱动基准测试和子基准测试
func BenchmarkAbsTableDriven(b *testing.B) {
    tests := []struct {
        name  string
        input int
    }{
        {"negative", -1},
        {"zero", 0},
        {"positive", 1},
    }

    for _, tt := range tests {
        tt := tt // capture range variable
        b.Run(tt.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Abs(tt.input)
            }
        })
    }
}

在这个示例中,我们为每个测试用例创建了一个子基准测试,以评估 Abs 函数的性能。这样可以在不同的输入值下测量函数的基准性能。

运行测试和基准测试

使用 -run-bench 命令行标志可以运行特定的测试或基准测试。例如:

  • go test -run TestAbs 运行名称为 TestAbs 的测试,包括所有子测试。
  • go test -run TestAbs/negative 运行名称为 TestAbs 的测试中的 negative 子测试。
  • go test -bench BenchmarkAbs 运行名称为 BenchmarkAbs 的基准测试,包括所有子基准测试。
  • go test -bench BenchmarkAbs/negative 运行名称为 BenchmarkAbs 的基准测试中的 negative 子基准测试。

这些命令允许你更精确地控制哪些测试或基准测试被运行,以及如何运行它们。

控制并行执行

子测试还可以用于控制并行执行。顶级测试在所有子测试完成之前不会结束。例如:

package abs

import "testing"

// TestGroupedParallel 演示如何在子测试中控制并行执行
func TestGroupedParallel(t *testing.T) {
    tests := []struct {
        name string
    }{
        {"test1"},
        {"test2"},
        {"test3"},
    }

    for _, tc := range tests {
        tc := tc // capture range variable
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            // 具体的测试代码
        })
    }
}

在这个示例中,所有的子测试会并行执行,并且仅与彼此并行,而与其他顶级测试无关。TestGroupedParallel 函数会在所有子测试完成后才返回。

清理操作

Run 方法会等待所有并行子测试完成后再返回,这为清理操作提供了一种方式。可以在测试或基准测试完成后执行清理操作。例如:

package abs

import "testing"

// TestTeardownParallel 演示并行测试后的清理操作
func TestTeardownParallel(t *testing.T) {
    t.Run("group", func(t *testing.T) {
        t.Run("Test1", func(t *testing.T) { t.Parallel() /* test code */ })
        t.Run("Test2", func(t *testing.T) { t.Parallel() /* test code */ })
        t.Run("Test3", func(t *testing.T) { t.Parallel() /* test code */ })
    })
    // 清理代码
}

在这个示例中,TestTeardownParallel 函数会等到所有并行的子测试完成后再执行清理操作。

主测试(Main)

TestMain 是 Go 测试框架中的一个特殊函数,用于在测试开始和结束时执行额外的设置和清理操作。有时在运行测试之前,你需要进行一些初始化工作,比如启动数据库连接,或者在测试结束后执行一些清理操作。TestMain 允许你在测试运行前和运行后执行这些操作。TestMain 运行在主 goroutine 中,这样你可以控制主线程执行的代码。比如,你可以在 TestMain 中执行一些只需在测试开始前运行一次的代码,而不是在每个测试函数中都重复执行。

假设我们有一个测试需要在运行前初始化一些数据,并在所有测试完成后进行清理。以下是如何使用 TestMain

package mypackage

import (
	"testing"
	"flag"
	"fmt"
)

// Setup function for initialization
func setup() {
	fmt.Println("Setting up before tests")
}

// Teardown function for cleanup
func teardown() {
	fmt.Println("Cleaning up after tests")
}

// TestMain is called before any tests run
func TestMain(m *testing.M) {
	// Parse command-line flags if necessary
	flag.Parse()

	// Setup before running tests
	setup()

	// Run the tests
	code := m.Run()

	// Teardown after running tests
	teardown()

	// Exit with the code returned by m.Run()
	os.Exit(code)
}

// Example test function
func TestSomething(t *testing.T) {
	t.Log("Running TestSomething")
}

在这个示例中,TestMain 函数首先调用 setup() 函数进行初始化,然后调用 m.Run() 运行所有测试函数,最后调用 teardown() 函数进行清理。m.Run() 返回一个退出码,os.Exit(code) 确保测试运行后的退出码正确返回。

你并不需要直接执行 TestMain。你只需要运行 go test 命令,Go 测试框架会自动调用 TestMain 函数。如果你在测试文件中定义了 TestMain,它会在运行所有测试之前被调用。

go test

即使你定义了 TestMain,你仍然可以单独执行某个测试函数。使用 -run 标志来指定要执行的测试函数名称。例如,如果你有一个名为 TestSomething 的测试函数,你可以这样运行它:

go test -run TestSomething

这条命令会运行所有包含 TestSomething 的测试函数,而 TestMain 仍然会在测试函数运行前被调用。

结语

通过本文,我们深入讲解了 Go 语言中的单元测试功能,包括如何编写测试函数、组织测试文件,以及如何运用高级测试技术,如基准测试、示例测试、模糊测试、跳过测试、子测试和主测试等。掌握这些技术可以帮助你在开发过程中更有效地验证代码的正确性和性能,提升代码质量并减少潜在的错误。

TestMain 提供了在测试前后执行初始化和清理操作的能力,使得在复杂的测试场景中,能够更好地管理资源和设置。而其他的测试方法,如基准测试和模糊测试,则能够帮助你在性能和健壮性上进行更深入的分析。

无论是日常开发还是进行深度的性能分析,了解和使用这些测试功能都将使你能够编写更可靠、更高效的代码。希望本文的介绍能帮助你更好地掌握 Go 语言的测试工具,提升测试的覆盖率和质量。


也可以看看