单元测试

单元测试 #

https://learnku.com/articles/52896

https://www.topgoer.com/%E5%87%BD%E6%95%B0/%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95.html

介绍 #

单元测试可以检查我们的代码能否按照预期进行,代码逻辑是否有问题,以此可以提升代码质量。 简单来说单元测试就是针对某一个函数方法进行测试,我们要先测试正确的传值与获取正确的预期结果,然后再添加更多测试用例,得出多种预期结果。尽可能达到该方法逻辑没有问题,或者问题都能被我们预知到。这就是单元测试的好处。

Go 语言的单元测试默认采用官方自带的测试框架,通过引入 testing 包以及 执行 go test 命令来实现单元测试功能。

在源代码包目录内,所有以 _test.go 为后缀名的源文件会被 go test 认定为单元测试的文件,这些单元测试的文件不会包含在 go build 的源代码构建中,而是单独通过 go test 来编译并执行。

规范 #

Go 单元测试的基本规范如下:

  • 每个测试函数都必须导入 testing 包。测试函数的命名类似func TestName(t *testing.T),入参必须是 *testing.T
  • 测试函数的函数名必须以大写的 Test 开头,后面紧跟的函数名,要么是大写开关,要么就是下划线,比如 func TestName(t *testing.T) 或者 func Test_name(t *testing.T) 都是 ok 的, 但是 func Testname(t *testing.T)不会被检测到
  • 通常情况下,需要将测试文件和源代码放在同一个包内。一般测试文件的命名,都是 {source_filename}_test.go,比如我们的源代码文件是allen.go ,那么就会在 allen.go 的相同目录下,再建立一个 allen_test.go 的单元测试文件去测试 allen.go 文件里的相关方法。

当运行 go test 命令时,go test 会遍历所有的 *_test.go 中符合上述命名规则的函数,然后生成一个临时的 main 包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

使用方法 #

package util

import (
	"testing"
)

func Test_Sum(t *testing.T) {
	if Sum(1, 2, 3) != 6 {
		t.Fatal("sum error")
	}
}

func Test_Abs(t *testing.T) {
	if Abs(5) != 5 {
		t.Fatal("abs error, except:5, result:", Abs(5))
	}
}

go test -v 执行单测并打印详情 #

运行方法:进入到包内,运行命令 go test -v ,参数 -v 可以打印详情。 也可以只运行某个方法的单元测试: go test -v -run=“xxx” ,支持正则表达式。

allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go test -v
=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok  	baseCodeExample/gotest	0.005s

allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go test -v -run="Abs"
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok  	baseCodeExample/gotest	0.006s

go test -v -cover 执行单测并计算覆盖率 #

go test 工具还有个功能是测试单元测试的覆盖率,用法为 go test -v -cover, 示例如下:

allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go test -v -cover
=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
PASS
coverage: 85.7% of statements
ok  	baseCodeExample/gotest	0.005s

从覆盖率来看(coverage: 85.7% of statements),单元测试没有覆盖全部的代码,只有 85.7% ,我们可以通过如下命令将 cover 的详细信息保存到cover.out 中。

go test -cover -coverprofile=cover.out -covermode=count
注:
-cover 允许代码分析
-covermode 代码分析模式(set:是否执行;count:执行次数;atomic:次数,并发执行)
-coverprofile 输出结果文件

然后再通过

go tool cover -func=cover.out

查看每个方法的覆盖率。

allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go tool cover -func=cover.out
baseCodeExample/gotest/compute.go:5:	Sum		100.0%
baseCodeExample/gotest/compute.go:13:	Abs		66.7%
total:					(statements)	85.7%

这里发现是 Abs 方法没有覆盖完全,因为我们的用例只用到了正数的那个分支。 还可以使用 html 的方式查看具体的覆盖情况。

go tool cover -html=cover.out

会默认打开浏览器,将覆盖情况显示到页面中:

可以看出 Abs 方法的负数分支没有覆盖到。将 TestAbs 方法修改如下即可:

func TestAbs(t *testing.T) {
    if Abs(5) != 5 {
        t.Fatal("abs error, except:5, result:", Abs(5))
    }
    if Abs(-4) != 4 {
        t.Fatal("abs error, except:4, result:", Abs(-4))
    }
}

再次运行:

go test -cover -coverprofile=cover2.out -covermode=count
go tool cover -func=cover2.out

运行结果如下:

allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go test -cover -coverprofile=cover2.out -covermode=count
PASS
coverage: 100.0% of statements
ok  	baseCodeExample/gotest	0.006s
allen@MackBook:~/work/goDev/Applications/src/baseCodeExample/gotest$go tool cover -func=cover2.out
baseCodeExample/gotest/compute.go:5:	Sum		100.0%
baseCodeExample/gotest/compute.go:13:	Abs		100.0%
total:					(statements)	100.0%

这个说明已经达到了 100% 的覆盖率了。

Go 单测覆盖度的相关命令汇总如下:

go test -v -cover

go test -cover -coverprofile=cover.out -covermode=count

go tool cover -func=cover.out

testing包常见的方法 #

一、基础测试方法 #

  1. ErrorfLogf

    • 用途:报告错误和记录日志。

    • 示例:

      func TestAdd(t *testing.T) {
          result := Add(1, 2)
          expected := 3
          if result != expected {
              t.Errorf("Add(1, 2) = %d; want %d", result, expected) // 输出错误
          }
          t.Logf("测试通过") // 记录日志(仅在 -v 时显示)
      }
      
  2. Run 方法

    • 用途:创建子测试,支持分组和嵌套测试。

    • 示例(表格驱动测试):

      func TestAdd(t *testing.T) {
          tests := []struct{ a, b, want int }{
              {1, 2, 3}, 
              {-1, 5, 4},
          }
          for _, tt := range tests {
              t.Run(fmt.Sprintf("%d+%d", tt.a, tt.b), func(t *testing.T) {
                  result := Add(tt.a, tt.b)
                  if result != tt.want {
                      t.Error("结果不符预期")
                  }
              })
          }
      }
      
  3. Parallel 方法

    • 用途:标记测试为可并行执行,加速测试运行。

    • 示例:

      func TestConcurrent(t *testing.T) {
          t.Parallel() // 与其他并行测试并发执行
          // 测试逻辑...
      }
      

在Go语言的testing包中,提供了丰富的测试方法,以下是常用方法的示例说明,结合不同场景进行展示:


二、基准测试方法 #

  1. b.N和ReportAllocs

    • 用途:循环执行代码以测量性能,并统计内存分配。

    • 示例:

      func BenchmarkAdd(b *testing.B) {
          b.ReportAllocs() // 报告内存分配信息
          for i := 0; i < b.N; i++ {
              _ = Add(100, 200)
          }
      }
      

三、其他功能方法 #

  1. SkipSkipNow

    • 用途:跳过当前测试(如环境不满足条件时)。

    • 示例:

      func TestRequireDB(t *testing.T) {
          if os.Getenv("DB_ENABLED") == "" {
              t.Skip("跳过数据库测试:未配置环境变量")
          }
          // 数据库相关测试...
      }
      
  2. TestMain 函数

    • 用途:全局初始化/清理逻辑(如数据库连接)。

    • 示例:

      func TestMain(m *testing.M) {
          fmt.Println("初始化资源...")
          code := m.Run() // 执行所有测试
          fmt.Println("清理资源...")
          os.Exit(code)
      }
      

Go 单测常见使用方法 #

测试单个文件 #

通常,一个包里面会有多个方法,多个文件,因此也有多个 test 用例,假如我们只想测试某一个方法的时候,那么我们需要指定某个文件的某个方法

如下:

allen@MackBook:~/work/goDev/Applications/src/gitlab.allen.com/avatar/app_server/service/centralhub$tree .
.
├── msghub.go
├── msghub_test.go
├── pushhub.go
├── rtvhub.go
├── rtvhub_test.go
├── userhub.go
└── userhub_test.go

0 directories, 7 files

总共有7个文件,其中有三个test文件,如果直接运行 go test,就会测试所有test.go文件了。

但是,假如我们只更新了 rtvhub.go 里面的代码,所以我只想要测试 rtvhub.go 里面的某个方法,那么就需要指定文件,具体的方法就是同时指定我们需要测试的test.go 文件和 它的源文件,如下:

go test -v msghub.go  msghub_test.go 

测试单个文件下的单个方法 #

在测试单个文件之下,假如我们单个文件下,有多个方法,我们还想只是测试单个文件下的单个方法,要如何实现?我们需要再在此基础上,用 -run 参数指定具体方法或者使用正则表达式。

假如 test 文件如下:

package centralhub

import (
	"context"
	"testing"
)

func TestSendTimerInviteToServer(t *testing.T) {
	ctx := context.Background()

	err := sendTimerInviteToServer(ctx, 1461410596, 1561445452, 2)
	if err != nil {
		t.Errorf("send to server friendship build failed. %v", err)
	}
}

func TestSendTimerInvite(t *testing.T) {
	ctx := context.Background()
	err := sendTimerInvite(ctx, "test", 1461410596, 1561445452)
	if err != nil {
		t.Errorf("send timeinvite to client failed:%v", err)
	}
}
只测试 TestSendTimerInvite 方法
go test -v msghub.go  msghub_test.go -run TestSendTimerInvite

测试所有正则匹配 SendTimerInvite 的方法 
go test -v msghub.go  msghub_test.go -run "SendTimerInvite"

单独运行某个测试用例

go test -run ^TestGetVer$

测试所有方法 #

直接 go test 就行

竞争检测(race detection) #

go run -race 执行竞争检测 #

当两个goroutine并发访问同一个变量,且至少一个goroutine对变量进行写操作时,就会发生数据竞争(data race)。 为了协助诊断这种bug,Go提供了一个内置的数据竞争检测工具。 通过传入-race选项,go tool就可以启动竞争检测。

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package

示例代码 #

package main

import (
	"fmt"
	"time"
)

func main() {
	var i int = 0
	go func() {
		for {
			i++
			fmt.Println("subroutine: i = ", i)
			time.Sleep(1 * time.Second)
		}
	}()
	for {
		i++
		fmt.Println("mainroutine: i = ", i)
		time.Sleep(1 * time.Second)
	}
}

演示结果 #

$ go run -race testrace.go
mainroutine: i =  1
==================
WARNING: DATA RACE
Read at 0x00c0000c2000 by goroutine 6:
  main.main.func1()
      /Users/wudebao/Documents/workspace/goDev/Applications/src/base-code-example/system/testrace/testrace.go:12 +0x3c

Previous write at 0x00c0000c2000 by main goroutine:
  main.main()
      /Users/wudebao/Documents/workspace/goDev/Applications/src/base-code-example/system/testrace/testrace.go:18 +0x9e

Goroutine 6 (running) created at:
  main.main()
      /Users/wudebao/Documents/workspace/goDev/Applications/src/base-code-example/system/testrace/testrace.go:10 +0x7a
==================
subroutine: i =  2
mainroutine: i =  3
subroutine: i =  4
mainroutine: i =  5
subroutine: i =  6
mainroutine: i =  7
subroutine: i =  8
subroutine: i =  9
mainroutine: i =  10