深入理解与使用go之测试–实现
引子
测试是一种重要的开发实践,用于确保代码的正确性和稳定性,通过编写全面的单元测试和集成测试,你可以增强代码的可靠性、可维护性和可扩展性,以及更好地理解代码的行为和边界条件。测试是项目生命周期的一个关键方面, 与其他一些语言相比,Go具有很强的编写测试原语。那么有以下几个问题
- go中的测试有几种
- 单元测试和集成测试是怎么区分的
- 你会区分环境(测试/生产)进行测试么
- 你的基准测试一定是正确的么
- 有哪些测试包可以提升我们的工作效率
写过代码的人都知道,我们最强大的测试方法是啥?
func add(a, b int) int {
return a + b
}
测试
func main() {
fmt.Println(add(3,4))
}
输出结果
7
得嘞您呐,齐活!本文结束
是不是一把打印
走天下,但是随着项目越来越庞大,开发人员越来越多,你负责的模块你是打印成功了,如果别人不小心改动你的依赖关系模块,别人不会再来逐一打印你的结果,那怎么办?
带着上面的这些问题,我们来讨论讨论今天要说的测试。
测试
测试的分类
老规矩,讲测试之前,我们先要搞清楚,我们go里面的测试有哪些分类
- 以
TestFuncName
命名的函数名,且文件以_test
结尾的测试- 单元测试
- 集成测试
- 数据竞争测试
- 代码覆盖测试
- 以
BenchmarkFuncName
命名的函数名,且文件以_test
结尾的测试- 基准测试
有了这些分类,我们来一个一个看
TestFuncName 测试
单元测试
单元测试是针对代码中最小的可测试单元(函数、方法、类型等)的测试。它们旨在验证单个单元的行为是否正确。单元测试通常是独立于其他代码的,并且可以快速运行
我们举上面的栗子 add_test.go
func TestAdd(t *testing.T) {
r := Add(3, 4)
if r != 7 {
t.Fail()
}
}
进入目录,执行
# go test . -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
集成测试
比如,我们有加法,有减法,有乘法等等项目
func Add(a, b int) int {
return a + b
}
func Sub(a, b int) int {
return a - b
}
func Multi(a, b int) int {
return a * b
}
分布在三个不同的模块里,一般有两种办法
-
单独构建一个集成测试文件夹
├── tests │ ├── add_test.go │ ├── sub_test.go │ └── multi_test.go
进入文件夹
go test ./... -v
-
每个模块各自写集成测试文件
├── aa │ ├── add.go │ ├── add_test.go ├── bb │ ├── sub.go │ ├── sub_test.go ├── cc │ ├── multi.go │ ├── multi_test.go
在项目根目录下执行
go test ./... -v
这里是不是一脸懵,放单独文件夹还好理解,放各自模块那不跟单元测试混淆了么,怎么办呢,有三种方式
-
添加测试构建标签
我们可以在测试文件
add_test.go
的顶部添加//go:build integration
并且空一行,下一行即为正常包名//go:build integration package aa
注意:从1.17开始
//+build
就被//go:build
替代,go1.18gofmt
目前为了过渡两种都支持然后,我们在执行的时候,指定标签
go test -v -tags=integration ./...
-
使用环境变量
func TestOther(t *testing.T) { if os.Getenv("INTEGRATION") != "true" { t.Skip("skipping integration test") } // ... }
然后执行
export INTEGRATION=true && go test -v ./...
注意:使用环境变量测试通常可用于区分测试与生产环境的测试,比如有些资源操作,我们可以跳过生产测试
func TestInsert(t *testing.T) { if os.Getenv("ENV") == "prod" { t.Skip("ptod skipping insert test") } // ... } func TestDelete(t *testing.T) { if os.Getenv("ENV") == "prod" { t.Skip("prod skipping delete test") } // ... }
-
使用short模式
func TestOther(t *testing.T) { if testing.Short() { t.Skip("skipping other test") } // ... }
然后打印:
go test -v -short ./...
注意:短模式的一个重要用途就是有些执行时间很长的测试,如果我们每次测试都跑,其实是很浪费时间的,初始测试的时候跑成功了,在日常的频繁测试中,我们可以排除此类测试
func TestLongTimeFunc(t *testing.T) { if testing.Short() { t.Skip("skipping long time func test") } // ... }
-
函数名包含指定字符串
func TestIntegrationAdd(t *testing.T) { // ... }
执行
go test -v -run Integration ./...
数据竞争测试
我们在很多并发的编程中,需要做数据竞争测试,这些对于我们开发时更早发现并发写等造成脏数据并及时修复提供了便捷
假设我们有下面的方法
package ti
func Add(a, b int) int {
i := 0
go func() { i++ }()
return a + b + i
}
然后我们测试如下
//go:build unit
package ti
import "testing"
func TestAdd(t *testing.T) {
r := Add(3, 4)
if r != 8 {
t.Fail()
}
}
执行 -race
是检测数据竞争的标记 开启即会进行检查
go test -tags=unit -race ./...
结果如下
==================
WARNING: DATA RACE
Write at 0x00c00001c258 by goroutine 7:
hello-world/ti.Add.func1()
/data/www/hello-world/ti/add.go:4 +0x44
Previous read at 0x00c00001c258 by goroutine 6:
hello-world/ti.Add()
/data/www/hello-world/ti/add.go:5 +0xd8
----
==================
--- FAIL: TestAdd (0.00s)
FAIL
FAIL hello-world/ti 0.364s
FAIL
很明显,不同的goroutine 6和7产生了数据竞争,需要注意的是
- 启用数据竞争检测,内存使用可能增长5到10倍
- 启用数据竞争检测,执行时间可能增长2到20倍
- 检测只能检测到我们的代码可以捕获潜在的数据竞争,比如我们加锁或者其他同步策略
- 尽量避免在生产环境去做竞争检测
代码覆盖度测试
我们以上面的 testAdd 为例,进入该目录执行
go test -tags=unit -coverprofile=coverage.out ./...
在执行
go tool cover -html=coverage.out
就会跳转到浏览器
看着很明显,覆盖度是 66.7%, 其中绿色的是被覆盖的,红色的则没有
自从go1.20开始,集成了内建的覆盖度测试,上面的测试只能,一个一个的进行包内测试,不是很方便,我们试用一下新功能
假设我们几个文件
├── add
│ └── add.go
└── sub
| └── sub.go
├── go.mod
├── main.go
add.go
package add
func Add(a, b int) int {
return a + b
}
func AddSqrt(a, b int) int {
return a*a + b*b
}
sub.go
package sub
func Sub(a, b int) int {
return a - b
}
main.go
import (
"fmt"
"hello.com/hello-cover/add"
"hello.com/hello-cover/sub"
)
func main() {
res := add.Add(1, 1)
fmt.Println(res)
res = sub.Sub(10, 9)
fmt.Println(res)
}
然后执行
# 构建
go build -cover
# 设置覆盖度数据保存目录 并执行
export GOCOVERDIR=./coverdata && ./hello-cover
# 打印展示 coverdata 是上一条命令的目录
go tool covdata percent -i=coverdata -o=covdata.txt
我们看看命令执行屏幕最终输出结果
hello.com/hello-cover coverage: 100.0% of statements
hello.com/hello-cover/add coverage: 50.0% of statements
hello.com/hello-cover/sub coverage: 100.0% of statements
其中 add包 只覆盖了 50%,符合我们的预期,因为有一个函数我们没有调用
BenchmarkFuncName 测试
基准测试
- 基准测试示例
基准测试一般用于测试性能
func BenchmarkAdd(b *testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++ {
ra := rand.Intn(1000000)
rb := rand.Intn(1000000)
got := Add(ra, rb)
if got != ra+rb {
b.Fail()
}
}
}
执行
go test -bench=.
基于测试环境,操作系统,进程调度的制约,可能有时候测试不太准确,我们可以增加测试持续时长 和测试次数
go test -bench=. -benchtime=10s -count=10
我们需要注意的是,这里有些额外的操作可能影响到我们耗时的统计结果,比如循环前的 随机测试,我们需要
- 重置计时器 :
ResetTimer()
rand.Seed(time.Now().UnixNano())
// 重置计时起点
b.ResetTimer()
循环过程中,我们生成随机数其实也不应该加入到耗时中,因为我们测试的是Add的性能
- 设置起始计时点:
StartTimer()
- 设置结束计时点:
StopTimer()
for i := 0; i < b.N; i++ {
ra := rand.Intn(1000000)
rb := rand.Intn(1000000)
// 开始计时
b.StartTimer()
got := Add(ra, rb)
if got != ra+rb {
b.Fail()
}
// 结束计时
b.StopTimer()
}
- 编译优化问题
我们考虑测试下面的函数
func helloCalc(x uint64) uint64 {
x -= (x >> 3) & 1000
x = (x + (x >> 5)) & 1000
return (x * 1000) >> 56
}
func BenchmarkHelloCalc(b *testing.B) {
for i := 0; i < b.N; i++ {
helloCalc(uint64(i))
}
}
执行
go test -bench=.
结果直接打满
BenchmarkHelloCalc-8 1000000000 0.3337 ns/op
我们看看问题在哪,我们尝试打印其中几个传递的参数结果
fmt.Println(helloCalc(uint64(1))) // 0
fmt.Println(helloCalc(uint64(2))) // 0
fmt.Println(helloCalc(uint64(3))) // 0
fmt.Println(helloCalc(uint64(4))) // 0
无一例外,都是0,编译器对函数进行了优化,函数内一块地方没有接收且计算结果始终为0,类似做了空操作
func BenchmarkHelloCalc(b *testing.B) {
for i := 0; i < b.N; i++ {
// empty
}
}
怎么判断是否有优化,可以参考执行
- 禁用优化
-gcflags="-N"
- 打印编译汇编代码
-gcflags="-S"
这两个参数来进行对比,这里不是我们讨论的重点,我们看看怎么防止优化代码的测试不准
var G uint64
func BenchmarkHelloCalc(b *testing.B) {
var a uint64
for i := 0; i < b.N; i++ {
a = helloCalc(uint64(i))
}
G = a
}
我们定义了个变量,每次结果进行赋值, 然后重新测试
BenchmarkHelloCalc-8 941377164 1.271 ns/op
实用测试
测试表
如果我们有多个测试用例,每一个写一个测试函数,那得多费劲,而且函数都调同一个,代码都不重用, 我们可以使用slice 构造测试表
func TestAdd(t *testing.T) {
type Params struct {
A int
B int
}
tests := map[string]struct {
input Params
expected int
}{
"t1": {input: Params{1, 2}, expected: 3},
"t2": {input: Params{3, 2}, expected: 5},
"t3": {input: Params{5, 2}, expected: 7},
"t4": {input: Params{6, 2}, expected: 8},
"t5": {input: Params{8, 2}, expected: 10},
}
for tname, tt := range tests {
tt := tt // 避免变量重置
t.Run(tname, func(t *testing.T) {
got := Add(tt.input.A, tt.input.B)
if got != tt.expected {
t.Errorf("got: %d, expected: %d", got, tt.expected)
}
})
}
}
并行测试
比如,我们有个接口是秒杀接口,需要并发测试下并发请求下的数据是否正常读写,我们可以使用
- 允许测试函数在不同的 Goroutine 中并行运行:
Parallel()
func TestConcurrentRequests(t *testing.T) {
// 创建一个 httptest.Server 作为模拟的 HTTP 服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 模拟处理请求的逻辑
// ...
// 返回响应
fmt.Fprintln(w, "秒杀成功!")
}))
defer server.Close()
// 定义并发请求数量
numRequests := 10
// 使用 WaitGroup 来等待所有测试 Goroutine 完成
var wg sync.WaitGroup
wg.Add(numRequests)
for i := 0; i < numRequests; i++ {
t.Run(fmt.Sprintf("Request %d", i), func(t *testing.T) {
// 标记该测试函数可以并行运行
t.Parallel()
// 发起请求
resp, err := http.Get(server.URL)
if err != nil {
t.Errorf("Request failed: %v", err)
}
defer resp.Body.Close()
// 处理响应
// ...
// 标记当前 Goroutine 已完成
wg.Done()
})
}
// 等待所有测试 Goroutine 完成
wg.Wait()
}
默认情况下,可以同时运行的最大并行测试数量等于GOMAXPROCS值,我们可以使用-parallel标志更改此值
go test -parallel 8 .
随机测试
有些情况下,我们在执行所有测试文件是按照固定的顺序执行a_test.go/b_test.go/c_test.go
如果顺序随机打乱 就会测试失败,这样我们就能更快的发现,在没有顺序依赖情况下的bug
- 开启
-shuffle=on
乱序执行测试文件
go test -shuffle=on -v .
打乱随机测试可以帮助我们发现隐藏的依赖项,这可能意味着在以相同顺序运行测试时测试错误甚至不可见的错误
扩展
三方测试包
现在比较流行测测试包是
go get github.com/stretchr/testify
参考文档:https://pkg.go.dev/github.com/stretchr/testify
我们抽取gin框架中的测试文件 gin_test.go
看看怎么用的,用了哪些方法
assert.Empty(t, router.Handlers)
assert.Len(t, list, 7)
assert.NotPanics(t, func() {
// code here
})
assert.Equal(t, expectedTrustedCIDRs, r.trustedCIDRs)
assert.NoError(t, err)
assert.Error(t, err)
assert.Nil(t, err)
assert.NotNil(t, router.trees.get("GET"))
assert.Regexp(t, wantRoute.Handler, gotRoute.Handler)
httptest
参考文档:https://studygolang.com/static/pkgdoc/pkg/net_http_httptest.htm
下面是一个简单示例
func TestHTTPHandler(t *testing.T) {
// 创建一个 httptest.Server 作为模拟的 HTTP 服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 处理请求并返回响应
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello, World!"))
}))
defer server.Close()
// 创建一个 HTTP 请求到模拟的服务器
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// 发送请求并获取响应
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
// 验证响应状态码是否为 200 OK
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 OK, got: %v", resp.Status)
}
// 验证响应内容是否正确
expectedBody := "Hello, World!"
actualBody := make([]byte, len(expectedBody))
_, err = resp.Body.Read(actualBody)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
if string(actualBody) != expectedBody {
t.Errorf("Expected body '%s', got: '%s'", expectedBody, string(actualBody))
}
}
iotest
参考文档:https://studygolang.com/static/pkgdoc/pkg/testing_iotest.htm
下面是一个简单示例:
func TestWriteErrorHandling(t *testing.T) {
// 模拟一个写入器,总是返回特定的错误
writer := iotest.ErrWriter(errors.New("custom error"))
// 写入数据
_, err := writer.Write([]byte("data"))
// 验证错误是否符合预期
if err == nil || err.Error() != "custom error" {
t.Errorf("Expected custom error, got: %v", err)
}
}
gin框架test
参考文档 : https://gin-gonic.com/zh-cn/docs/testing/
结合了 testify 与 httptest包
func TestPingRoute(t *testing.T) {
router := setupRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/ping", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "pong", w.Body.String())
}