基本的测试

在线运行open in new window启动 AI 助手open in new window

// sayhello.go
func sayHello(name string) string {
	return fmt.Sprintf("Hi %s", name)
}

// sayhello_test.go
// go test
// go test -v
func Test_sayHello(t *testing.T) {
	expected := "Hi zhangsan"
	if expected != sayHello("zhangsan") {
		t.Error()
		// t.Errorf("not expected: %s", expected)
		// t.Fail()
		// t.FailNow()
		// t.Fatal()
		// t.Fatalf("not expected: %s", expected)
	}
}

这个程序定义了一个名为"sayHello"的函数,它接收一个字符串参数"name",并返回一个格式为"Hi name"的字符串。该程序还包含一个单元测试文件"sayhello_test.go",用于测试"sayHello"函数的功能。

在测试函数"Test_sayHello"中,我们首先定义了一个预期输出字符串"expected",然后调用"sayHello"函数并将返回结果与"expected"进行比较。如果比较结果不符合预期,测试函数将会调用t.Error()函数表示测试失败,也可以使用其他可选的测试失败方式,例如t.Errorf()、t.Fail()、t.FailNow()、t.Fatal()或t.Fatalf(),这些函数会在不同的条件下以不同的方式标记测试失败,以保证代码的质量和可靠性。

引入 testify 库

实际项目中为更好的组织、诊断测试以及 mock 对象的行为,通常会引入 testify 库:

go get -u github.com/stretchr/testify

重构一下 sayhello_test.go 如下:

在线运行open in new window启动 AI 助手open in new window

// sayhello.go
func sayHello(name string) string {
	return fmt.Sprintf("Hi %s", name)
}

// sayhello_test.go
// go test
// go test -v
func Test_sayHello(t *testing.T) {
	expected := "Hi zhangsan"
	assert.Equal(t, expected, sayHello("zhangsan"))
	// assert.Equal(t, 7, len(sayHello("lisi")))
	assert.Len(t, sayHello("lisi"), 7)
}

由于每次都写 assert.Func 都会把参数 t 穿进去,比较麻烦,因为可以用 t 构造一个 assert 对象出来:

在线运行open in new window启动 AI 助手open in new window

// sayhello_test.go
// go test
// go test -v
func Test_sayHello(t *testing.T) {
	checker := assert.New(t)
	expected := "Hi zhangsan"
	checker.Equal(expected, sayHello("zhangsan"))
	// checker.Equal(7, len(sayHello("lisi")))
	checker.Len(sayHello("lisi"), 7)
}

表格驱动和子测试

表格驱动测试不是一个新的工具或者语法,是一种编写干净清晰的测试的视角。

在线运行open in new window启动 AI 助手open in new window

// 求解斐波那契数列
func fib(n int) int {
	if n <= 1 {
		return n
	}
	return fib(n-1) + fib(n-2)
}

// func Test_fib(t *testing.T) {
// 	checker := assert.New(t)
// 	checker.Equal(1, fib(1))
// 	checker.Equal(1, fib(2))
// 	checker.Equal(2, fib(3))
// 	checker.Equal(3, fib(4))
// 	checker.Equal(5, fib(5))
// }

func Test_fib_refactor(t *testing.T) {
	checker := assert.New(t)
	testcases := []struct {
		expected int
		input    int
	}{
		{1, 1}, {1, 2}, {2, 3}, {3, 4}, {5, 5},
	}

	/*
	   === RUN   Test_fib_refactor
	   === RUN   Test_fib_refactor/expect_fib(1)_to_1
	   === RUN   Test_fib_refactor/expect_fib(2)_to_1
	   === RUN   Test_fib_refactor/expect_fib(3)_to_2
	   === RUN   Test_fib_refactor/expect_fib(4)_to_3
	   === RUN   Test_fib_refactor/expect_fib(5)_to_5
	*/
	for _, tc := range testcases {
		checker.Equal(tc.expected, fib(tc.input))
	}

	/*
	   --- PASS: Test_fib_refactor (0.00s)
	       --- PASS: Test_fib_refactor/expect_fib(1)_to_1 (0.00s)
	       --- PASS: Test_fib_refactor/expect_fib(2)_to_1 (0.00s)
	       --- PASS: Test_fib_refactor/expect_fib(3)_to_2 (0.00s)
	       --- PASS: Test_fib_refactor/expect_fib(4)_to_3 (0.00s)
	       --- PASS: Test_fib_refactor/expect_fib(5)_to_5 (0.00s)

	*/
	for _, tc := range testcases {
		t.Run(fmt.Sprintf("expect fib(%d) to %d", tc.input, tc.expected), func(t *testing.T) {
			asserter := assert.New(t)
			asserter.Equal(tc.expected, fib(tc.input))
		})
	}
}

在函数 Test_fib_refactor 中,测试用例被组织成了一个结构体数组 testcases,每个测试用例包含了期望的结果和输入参数。通过遍历 testcases 中的测试用例,程序会依次测试每个输入参数的结果是否和期望结果一致。程序还使用了 t.Run 函数来提高测试的可读性和可维护性,将每个测试用例的期望输出结果以及输入参数打印在测试结果中。

fuzz 模糊测试

在线查看open in new window启动 AI 助手open in new window

// reverse.go
func reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

// reverse_test.go

// 使用种子数据测试
// go test -v

// 独立运行
// go test -run=Fuzz_reverse

// 生成随机测试数据
// go test -fuzz=Fuzz
func FuzzR_reverse(f *testing.F) {
	testcases := []string{"Hello world", "我是中文"}
	for _, tc := range testcases {
    // 添加种子数据
		f.Add(tc)
	}

  // 待测试的函数有多个参数依次再次申明
	f.Fuzz(func(t *testing.T, input string) {
		checker := assert.New(t)
		rev := reverse(input)
		dblRev := reverse(rev)
		checker.Equal(dblRev, input)
	})
}

模糊测试是单元测试和性能测试的补充,程序包括一个用于反转字符串的函数 reverse 和一个用于对其进行单元测试的测试文件 reverse_test.go。

reverse 函数通过将字符串转换为字节数组,从字符串的两端开始交换字符来实现反转操作。在单元测试文件 reverse_test.go 中,定义了一个名为 FuzzR_reverse 的函数,用于生成随机的输入字符串并对 reverse 函数进行模糊测试。

该函数使用 testing.F 类型的参数,并通过 f.Add 将一些种子数据添加到测试中。随后,使用 f.Fuzz 方法来模糊测试 reverse 函数。该方法将随机生成的字符串作为输入,使用 assert 包中的方法进行测试,并将结果与期望值进行比较。

在命令行中,可以使用不同的参数来运行测试。例如,使用 go test -v 可以显示每个测试用例的详细输出,而使用 go test -fuzz=Fuzz 可以生成随机测试数据并对其进行模糊测试。

跳过测试

在线查看open in new window启动 AI 助手open in new window

func add(a, b int) int {
	return a + b
}

// 很耗时的测试,加 short 参数跳过
// go test -short
func Test_add(t *testing.T) {
	checker := assert.New(t)

	// 调用 os.Getenv()、os.LookupEnv() 判断环境,执行 t.SkipNow/Skip/Skipf 也是常用的做法
	/*
		if ci, ok := os.LookupEnv("CI"); ok {
			t.Skipf("add func skiped on %s env", ci)
		}
	*/

	if testing.Short() {
		// t.SkipNow()
		t.Skip("add func skiped")
	}

	// spends many time, such as loop、network、write disk、sync cloud etc
	time.Sleep(time.Hour)

	/*
		OUTPUT
		=== RUN   Test_spendManyTime
		[T+0000ms]
		--- PASS: Test_spendManyTime (3600.00s)
		PASS
	*/

	checker.Equal(2, add(1, 1))
}

有时候需要跳过很耗时的测试,或者在某种特定环境中跳过某些测试,程序包含一个名为 Test_add 的测试函数,用于检查 add 函数是否能够正确计算两个整数的和。

测试函数使用了 Go 语言标准库的 testing 包进行测试。它首先使用 assert.New 函数创建一个断言对象来进行测试断言。然后,它检查 -short 标志是否设置,调用内置的 testing.Short() 函数来判断是否设置了 -short 标志。如果设置了该标志,测试函数将使用 t.Skip 函数跳过测试并返回一个消息,表示测试被跳过。

测试函数还包含了一段注释代码,用于检查名为 CI 的环境变量的值,如果环境变量被设置了,那么测试将会被跳过。这段代码可以用于在运行测试时跳过测试,例如在持续集成环境中运行测试。在检查是否需要跳过测试后,测试函数调用了 time.Sleep 函数来模拟一个耗时很长的操作,该操作需要花费一个小时才能完成。这样做是为了演示如何测试执行长时间操作的函数。

最后,测试函数调用 checker.Equal 函数来比较调用 add 函数的结果和期望的两个整数之和的结果是否相等。checker.Equal 函数是 assert 对象提供的一个方便函数,用于检查两个值是否相等,如果它们不相等,则会报告一个错误。

性能测试

在线查看open in new window启动 AI 助手open in new window

func fib(n int) int {
	if n == 0 || n == 1 {
		return n
	}
	return fib(n-2) + fib(n-1)
}

// 注意 go test 默认不运行 benchmark 测试,需要传入 -bench 参数
// go test -bench .
// go test -bench='fib$' .

// 指定运行的时间
// go test -bench='fib$' -benchtime=10s .

// 指定运行次数
// go test -bench='fib$' -benchtime=30x .

// 指定运行的轮数
// go test -bench='fib$' -benchtime=10s -count=5 .
func Benchmark_fib(b *testing.B) {
	// b.N 是测试运行的次数,go test 会自动侦测需要运行的时间,保证有足够的次数
	for n := 0; n < b.N; n++ {
		fib(20)
	}
}

程序定义了一个名为 fib 的函数,用于计算斐波那契数列中第 n 项的值。如果 n 等于 0 或 1,则返回 n,否则递归地计算 fib(n-2) 和 fib(n-1) 的和并返回结果。

代码中还包含一个名为 Benchmark_fib 的基准测试函数,用于测试计算斐波那契数列的效率。测试函数使用了 Go 语言标准库的 testing 包进行基准测试。它使用了 b.N 变量来控制测试运行的次数,这个变量由 testing 包自动设置,并根据需要调整以确保测试有足够的时间来运行。

在测试函数中,循环运行 fib(20) 函数,以测试计算斐波那契数列的效率。测试函数不会直接输出任何结果,而是使用 go test 命令来运行测试,并输出测试结果。

通过运行 go test -bench 命令,可以运行基准测试,并输出测试结果。默认情况下,go test 不会运行基准测试,因此需要传入 -bench 参数来指定运行基准测试。可以使用 .(点) 来指定运行所有基准测试函数,也可以使用正则表达式来指定只运行名称匹配的基准测试函数。例如,go test -bench='fib$' 命令将只运行名称以 fib 结尾的基准测试函数。

可以使用 -benchtime 参数来指定基准测试运行的时间。例如,go test -bench='fib$' -benchtime=10s 命令将运行名称以 fib 结尾的基准测试函数,每次运行持续 10 秒钟。可以使用 -benchtime 参数的值来控制测试的精度。

可以使用 -count 参数来指定运行基准测试的次数。例如,go test -bench='fib$' -benchtime=30x 命令将运行名称以 fib 结尾的基准测试函数,每次运行持续足够的时间来运行 30 次。可以使用 -count 参数的值来控制测试的稳定性。

TestMain 函数

在线查看open in new window启动 AI 助手open in new window

// 在下面所有测试方法执行开始前先执行
func TestMain(m *testing.M) {
	// 初始化资源
	fmt.Println("initial...")

	// 运行 go 的测试,相当于调用 main 方法
	result := m.Run()

	// 清理资源
	fmt.Println("dispose...")

	//退出程序
	os.Exit(result)
}

/*
initial...
=== RUN   Test_someFunc1
这里是正儿八经的测试...1
--- PASS: Test_someFunc1 (0.00s)
=== RUN   Test_someFunc2
这里是正儿八经的测试...2
--- PASS: Test_someFunc2 (0.00s)
dispose...
*/

// 单元测试
func Test_someFunc1(t *testing.T) {
	fmt.Println("这里是正儿八经的测试...1")
}

func Test_someFunc2(t *testing.T) {
	fmt.Println("这里是正儿八经的测试...2")
}

代码展示了如何使用 TestMain 函数在测试执行前和执行后初始化和清理资源。 TestMain 函数必须是签名为 func TestMain(m *testing.M) 的函数,其中 m *testing.M 是 testing 包提供的管理测试执行的对象。

在这个例子中,TestMain 函数首先执行初始化操作,打印 "initial..." 字符串。接下来,m.Run() 函数运行所有的测试方法。最后,TestMain 函数执行清理操作,打印 "dispose..." 字符串。注意,如果测试执行失败,则清理操作将不会执行。

在 Test_someFunc1 和 Test_someFunc2 中,我们只是打印了一些字符串作为测试。这两个测试方法会在初始化和清理资源之间执行,因为它们是测试集合中的两个单元测试。

这个程序展示了如何在单元测试中使用 TestMain 函数来管理测试执行前和执行后的资源初始化和清理。

手动实现 setup/tearDown

在线查看open in new window启动 AI 助手open in new window

func setupTest() (*sql.DB, func()) {
	fmt.Println("initial resources firstly...")
  // such as:
  // create db resource
  // create httptest.Recrorder/Request
  // create gin test context
  // create some seeds data
	db, _ := sql.Open("mysql", "root:@tcp(127.0.0.1:3306)/test")
	return db, func() {
		fmt.Println("clean resources finally...")
		db.Close()
	}
}

func Test_setupTearDown(t *testing.T) {
	checker := assert.New(t)

	// setup depends
	db, tearDown := setupTest()

	// clean resources
	defer tearDown()

	var count int
	err := db.QueryRow("SELECT 1 AS count").Scan(&count)

	checker.NoError(err)
	checker.Equal(1, count)

	fmt.Println("tests passed...")

	/*
		=== RUN   Test_setupTearDown
		initial resources firstly...
		tests passed...
		clean resources finally...
		--- PASS: Test_setupTearDown (0.00s)
	*/
}

程序包含一个名为setupTest()的函数,用于初始化测试所需的资源,具体来说,该函数打开一个名为test的mysql数据库,并返回一个数据库连接对象以及一个用于清理资源的闭包函数。闭包函数包含了关闭数据库连接的逻辑。

接下来,有一个名为Test_setupTearDown()的测试函数。该函数利用setupTest()函数来初始化测试所需的资源。在测试完成后,使用defer关键字来延迟执行返回的tearDown()闭包函数,以确保资源被正确释放。在测试函数中,使用assert库中的NoError()和Equal()方法检查查询数据库的结果是否符合预期,以确保测试通过。最后,通过打印一条"tests passed"的成功信息来标识测试执行成功。

使用 testify suite 包装测试对象

在线运行open in new window启动 AI 助手open in new window

// 要测试的对象
type person struct {
	RealName string
	Age      int
}

// 要测试的行为
func (p *person) Grow() {
	p.Age += 1
}

type personSuite struct {
	suite.Suite

	// 把要测试的对象放这里来
	p *person

	// 把测试对象的 depends 放这里来,一般是 mock 的对象
	// mocked objects
}

// 在每个 test 函数之前运行
func (suite *personSuite) SetupTest() {
	fmt.Println("setup test called")
	suite.p = &person{Age: 20}
}

func (suite *personSuite) TestGrow() {
	// 调用被测试对象的行为
	suite.p.Grow()
	// 验证结果
	suite.Equal(21, suite.p.Age)
}

// 为了让 go test 调用到,需要一个普通的测试函数来作为入口
func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

程序定义了一个 person 结构体,包含真实姓名和年龄两个属性,以及 Grow() 方法,用于增加该人物对象的年龄。还定义了一个 personSuite 结构体,嵌入了 suite.Suite,用于存储要测试的对象以及测试对象的依赖关系。

personSuite 结构体实现了 SetupTest() 方法,用于在每个测试函数之前进行一些初始化工作。定义了一个 TestGrow() 测试函数,调用被测试对象的 Grow() 方法并验证其结果是否正确。

最后定义了一个普通的测试函数 TestPersonSuite(),用于作为入口调用 suite.Run() 函数来运行所有测试函数。

使用 testify 实现 setup/tearDown

在线运行open in new window启动 AI 助手open in new window

SetupSuite -> SetupTest -> TearDownTest -> TearDownSuite

// 要测试的对象
type person struct {
	RealName string
	Age      int
}

// 要测试的行为
func (p *person) Grow() {
	p.Age += 1
}

type testCase struct {
	Name string
	Run  func()
}

type personSuite struct {
	suite.Suite
	p *person
}

// 只运行一次,在整个 suite 之前
func (suite *personSuite) SetupSuite() {
	log.Println("before suite called")
}

// 每一个 test 都会调用
func (suite *personSuite) SetupTest() {
	log.Println("setup test called")
	suite.p = &person{Age: 20}
}

func (suite *personSuite) TearDownTest() {
	log.Println("teardown test called")
}

// 只运行一次,在整个 suite 之后
func (suite *personSuite) TearDownSuite() {
	log.Println("teardown suite called")
}

func (suite *personSuite) TestGrow() {
	suite.p.Grow()
	suite.Equal(21, suite.p.Age)

	/*
		2009/11/10 23:00:00 before suite called
		[T+0001ms]
		=== RUN   TestPersonSuite/TestGrow
		[T+0001ms]
		2009/11/10 23:00:00 setup test called
		2009/11/10 23:00:00 teardown test called
		2009/11/10 23:00:00 teardown suite called
		[T+0001ms]
	*/
}

func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

在 personSuite 结构体类型中,SetupSuite() 方法在整个 suite 运行之前运行一次,TearDownSuite() 方法在整个 suite 运行之后运行一次,SetupTest() 方法在每个 test 运行之前运行一次,TearDownTest() 方法在每个 test 运行之后运行一次。

TestGrow() 方法作为测试用例,调用 person 结构体类型的 Grow() 方法,并通过 suite.Equal() 方法来判断结果是否符合预期,TestPersonSuite() 函数是入口函数,通过 suite.Run() 函数来启动测试。

表格驱动测试中手动调用 setupTest

在线运行open in new window启动 AI 助手open in new window

// 要测试的对象
type person struct {
	RealName string
	Age      int
}

func (p *person) Say() string {
	return "hello, " + p.RealName
}

type testCase struct {
	Name string
	Run  func()
}

type personSuite struct {
	suite.Suite
	p *person
}

// 每一个 test 都会调用
func (suite *personSuite) SetupTest() {
	log.Println("setup test called")
	suite.p = &person{Age: 20}
}

func (suite *personSuite) TearDownTest() {
	log.Println("teardown test called")
}

func (suite *personSuite) TestSay() {
	testCases := []testCase{
		{
			Name: "hi zhangsan",
			Run: func() {
				suite.p.RealName = "zhangsan"
				suite.Equal("hello, zhangsan", suite.p.Say())
				suite.Equal(20, suite.p.Age)
			},
		},
		{
			Name: "hi lisi",
			Run: func() {
				suite.p.RealName = "lisi"
				suite.Equal("hello, lisi", suite.p.Say())
				suite.Equal(20, suite.p.Age)
			},
		},
	}

	for _, testCase := range testCases {
		// 这里写了会被调用 3 次(两个 it),不写只会被调用 1 次
		suite.SetupTest()

		suite.Run(testCase.Name, testCase.Run)

		// 这里写了会被调用 3 次(两个 it),不写只会被调用 1 次
		suite.TearDownTest()
	}

	/*
		=== RUN   TestPersonSuite
		=== RUN   TestPersonSuite/TestSay
		[T+0000ms]
		2009/11/10 23:00:00 setup test called
		2009/11/10 23:00:00 setup test called
		[T+0001ms]
		=== RUN   TestPersonSuite/TestSay/hi_zhangsan
		[T+0001ms]
		2009/11/10 23:00:00 teardown test called
		2009/11/10 23:00:00 setup test called
		[T+0001ms]
		=== RUN   TestPersonSuite/TestSay/hi_lisi
		[T+0001ms]
		2009/11/10 23:00:00 teardown test called
		2009/11/10 23:00:00 teardown test called
		[T+0001ms]
		--- PASS: TestPersonSuite (0.00s)
			--- PASS: TestPersonSuite/TestSay (0.00s)
				--- PASS: TestPersonSuite/TestSay/hi_zhangsan (0.00s)
				--- PASS: TestPersonSuite/TestSay/hi_lisi (0.00s)
		PASS
	*/
}

func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

程序在 TestSay 中定义了两个子测试用例 hi zhangsan 和 hi lisi。我们通过 for 循环遍历这两个子测试用例,并在每个子测试用例之前和之后执行 SetupTest() 和 TearDownTest() 函数。这样做的目的是在每个子测试用例之前和之后重新初始化 person 对象,以确保每个测试用例都是独立的。

在运行测试用例时,可以看到输出了三次 setup test called 和三次 teardown test called,这是因为在 for 循环中调用了 SetupTest() 和 TearDownTest() 函数。

可以看到,在运行 hi_zhangsan 子测试用例之前,先调用了 teardown test called 函数清理上一个子测试用例的状态,然后再调用 setup test called 函数重新初始化 person 对象。

慎用 testify 的 beforeTest/afterTest

在线运行open in new window启动 AI 助手open in new window

suite 的 BeforeTest 运行在 setupTest 之后,AfterTest 运行在 TearDown 之前,但是通常不太需要这样的钩子行为,而且用在表格驱动测试中,钩子行为显得有点混乱,不推荐使用。

// 要测试的对象
type person struct {
	RealName string
	Age      int
}

// 要测试的行为
func (p *person) Grow() {
	p.Age += 1
}

func (p *person) Say() string {
	return "hello, " + p.RealName
}

type testCase struct {
	Name string
	Run  func()
}

type personSuite struct {
	suite.Suite
	p *person
}

func (suite *personSuite) SetupSuite() {
	log.Println("before suite called")
}

func (suite *personSuite) SetupTest() {
	log.Println("setup test called")
	suite.p = &person{Age: 20}
}

func (suite *personSuite) TearDownTest() {
	log.Println("teardown test called")
}

func (suite *personSuite) TearDownSuite() {
	log.Println("teardown suite called")
}

func (suite *personSuite) BeforeTest(suiteName, testName string) {
	// before test =>  personSuite TestGrow
	log.Println("before test =>", suiteName, testName)
}

func (suite *personSuite) AfterTest(suiteName, testName string) {
	// after test =>  personSuite TestGrow
	log.Println("after test =>", suiteName, testName)
}

func (suite *personSuite) TestGrow() {
	suite.p.Grow()
	suite.Equal(21, suite.p.Age)

	/*
		=== RUN   TestPersonSuite
		[T+0000ms]
		2009/11/10 23:00:00 before suite called
		[T+0001ms]
		=== RUN   TestPersonSuite/TestGrow
		[T+0001ms]
		2009/11/10 23:00:00 setup test called
		2009/11/10 23:00:00 before test => personSuite TestGrow
		2009/11/10 23:00:00 after test => personSuite TestGrow
		2009/11/10 23:00:00 teardown test called
		2009/11/10 23:00:00 teardown suite called
		[T+0001ms]
		--- PASS: TestPersonSuite (0.00s)
		    --- PASS: TestPersonSuite/TestGrow (0.00s)
		PASS
	*/
}

func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

在 SetupTest() 中,我们定义了在每个测试之前需要运行的代码,以确保每个测试开始时都有一个干净的状态。在 TearDownTest() 中,我们定义了在每个测试完成之后需要运行的代码。在 TearDownSuite() 中,我们定义了在所有测试完成之后需要运行的代码。

在 BeforeTest() 中,我们定义了在每个测试之前需要运行的代码。在这个例子中,我们同样使用 log.Println() 打印了当前测试套件名称和测试名称。在 AfterTest() 中,我们定义了在每个测试完成之后需要运行的代码。同样用 log.Println() 打印了当前测试套件名称和测试名称。

在 TestGrow() 中,我们定义了一个测试函数,用于测试 person.Grow() 方法。在测试函数中,我们调用 suite.p.Grow() 方法,将 person.Age 增加了 1,然后使用 suite.Equal() 函数检查是否正确地将 person.Age 增加了 1。

使用 testify 适度的 mock 对象

在线运行open in new window启动 AI 助手open in new window

type base interface {
	GetBase(int) (int, error)
}

// 实现一个被依赖的对象
type mockBase struct {
	mock.Mock
}

type person struct {
	// person 依赖 base 模块(注意是接口对象)
	Base     base
	RealName string
	Age      int
}

func (p *person) Salary() (int, error) {
	base, err := p.Base.GetBase(p.Age)
	return 100 + base, err
}

// 真正的 base 模块还没实现
// 或者为了单测,不打算受到 base 模块实现的影响

// 实现了 base 接口
func (m *mockBase) GetBase(age int) (int, error) {
	// 记录调用的次数
	args := m.Called(age)
	// 提供期望的结果
	return args.Int(0), args.Error(1)
}

type personSuite struct {
	suite.Suite
	mockedBase *mockBase
	p          *person
}

func (suite *personSuite) SetupTest() {
	suite.mockedBase = &mockBase{}
	suite.p = &person{RealName: "zhangsan", Age: 20, Base: suite.mockedBase}
}

func (suite *personSuite) TestGrow() {
	suite.mockedBase.On("GetBase", suite.p.Age).Return(5, nil)
	// 不想限定参数,可以使用 mock.Anything
	// suite.mockedBase.On("GetBase", mock.Anything).Return(5, nil)
	salary, err := suite.p.Salary()
	suite.NoError(err)
	suite.Equal(105, salary)
}

func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

程序定义了一个接口 base,并在 person 中将 base 作为一个依赖。person 的 Salary 方法依赖于 base 模块中的 GetBase 方法,将 GetBase 方法的返回值加上 100 后,作为 Salary 方法的返回值。

为了独立测试 person 的 Salary 方法,实现了 mockBase 结构体,它实现了 base 接口中的 GetBase 方法,并且可以记录 GetBase 方法的调用次数和返回期望的结果。

在 personSuite 中,通过 SetupTest 方法初始化了一个 mockBase 实例和一个 person 实例,并将 mockBase 作为 person 的依赖传入。

在 TestGrow 方法中,使用 mockedBase.On 方法来设置 GetBase 方法的期望调用,并返回预期的结果。然后调用 person 的 Salary 方法,检查返回值是否符合预期。

对网络请求进行 mock 操作

在线运行open in new window启动 AI 助手open in new window

有时候不想 mock 整个对象,或者被依赖的对象不是接口定义的,涉及到外部网络的时候可以使用 gock 这个库来 mock http 请求:

type person struct {
	RealName string
	Age      int
}

func (p *person) getBase() (int, error) {
	// 根本不存在的一个 server 端点
	URL := fmt.Sprintf("http://config.api.com/base/config?age=%d", p.Age)
	res, err := http.Get(URL)
	if err != nil {
		return 0, err
	}

	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return 0, err
	}

	config := struct {
		Base int `json:"base`
	}{}

	err = json.Unmarshal(body, &config)
	return config.Base, err
}

func (p *person) Salary() (int, error) {
	base, err := p.getBase()
	return 100 + base, err
}

type personSuite struct {
	suite.Suite
	p *person
}

func (suite *personSuite) SetupTest() {
	suite.p = &person{RealName: "zhangsan", Age: 20}
}

func (suite *personSuite) TestSalary() {
	defer gock.Off()

	// 拦截外部请求,让其直接返回期望的结果
	gock.New(fmt.Sprintf("http://config.api.com/base/config?age=%d", suite.p.Age)).
		Reply(200).
		JSON(map[string]int{"base": 5})

	salary, err := suite.p.Salary()
	suite.NoError(err)
	suite.Equal(105, salary)

	// 验证没有执行的 mock 操作了
	suite.True(gock.IsDone())
}

func TestPersonSuite(t *testing.T) {
	suite.Run(t, new(personSuite))
}

程序主要测试了一个 person 结构体的 Salary() 方法。在 Salary() 方法中,会调用 getBase() 方法获取基础工资,而 getBase() 方法会通过 HTTP 请求从 http://config.api.com/base/config 接口获取基础工资。为了在测试时避免依赖外部接口,代码使用了 gock 包模拟了这个 HTTP 请求,将其直接返回期望的结果,从而避免了真实的网络请求。这个测试用例的目的是验证 Salary() 方法能否正确计算出工资,以及在模拟 HTTP 请求的情况下,是否能正常工作。

禁止并行测试

go test 默认在每个 pkg 是串行的除非 test 文件使用了 t.Paranell(),但是在各个 pkg 之间是并行的,有时候需要禁用:

go test -v -p 1

启用并行测试

程序在测试函数中使用 t.Parallel 函数,以便并行运行测试子集,提高测试效率。

在线运行open in new window启动 AI 助手open in new window

func sum(x, y int) int {
	return x + y
}

func Test_sum(t *testing.T) {
	checker := assert.New(t)

	// 并行
	t.Parallel()

	var testcases = []struct {
		x        int
		y        int
		expected int
	}{
		{1, 1, 2},
		{2, 2, 4},
		{2, 5, 7},
	}

	for _, tc := range testcases {
		t.Run(fmt.Sprintf("sum(%d+%d)", tc.x, tc.y), func(t *testing.T) {
			t.Parallel()
			got := sum(tc.x, tc.y)
			checker.Equal(tc.expected, got)
		})
	}
}

查看测试覆盖率

# 显示覆盖率报告
go test -cover

# 生成覆盖率文件
go test -cover -coverprofile=coverage.out

# 查看每个函数的覆盖率
go tool cover -func=coverage.out

# 生成 html 网页
go tool cover -html=coverage.out
Last Updated:
Contributors: Bob Wang