Go单元测试
创始人
2025-05-30 17:08:38
0

如何写好单元测试

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。你或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。

如何写好单元测试呢?

首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock

然后,写可测试的代码。高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。

接下来将介绍如何使用 Go 语言的标准库 testing 进行单元测试。

一个简单的例子

package exampleimport "testing"func TestAdd(t *testing.T) {if ans := Add(1, 2); ans != 3 {t.Errorf("1 + 2 expected be 3, but %d got", ans)}if ans := Add(-10, -20); ans != -30 {t.Errorf("-10 + -20 expected be -30, but %d got", ans)}
}
  • 测试用例名称一般命名为Test加上待测试方法名
  • 测试用的参数有且仅有一个,在这里是t *testing.T
  • 基准测试的参数是 * testing.B,* TestMain的参数是* testing.M类习惯

子测试

子测试是 Go 语言内置支持的,可以在某个测试用例中,根据测试场景使用 t.Run创建不同的子测试用例:

func TestMul(t *testing.T) {t.Run("pos", func(t *testing.T) {if Mul(2, 3) != 6 {t.Fatal("fail")}})t.Run("neg", func(t *testing.T) {if Mul(2, -3) != -6 {t.Fatal("fail")}})
}
  • 之前的例子测试失败时使用 t.Error/t.Errorf,这个例子中使用 t.Fatal/t.Fatalf,区别在于前者遇错不停,还会继续执行其他的测试用例,后者遇错即停。

对于多个子测试的场景,更推荐如下的写法(table-driven tests):

func TestMul(t *testing.T) {cases := []struct {Name           stringA, B, Expected int}{{"pos", 2, 3, 6},{"neg", 2, -3, -6},{"zero", 2, 0, 0},}for _, c := range cases {t.Run(c.Name, func(t *testing.T) {if ans := Mul(c.A, c.B); ans != c.Expected {t.Fatalf("%d * %d expected %d, but %d got",c.A, c.B, c.Expected, ans)}})}
}

帮助函数

对一些重复的逻辑,抽取出来作为公共的帮助函数(helpers),可以增加测试代码的可读性和可维护性。 借助帮助函数,可以让测试用例的主逻辑看起来更清晰。

例如,我们可以将创建子测试的逻辑抽取出来:

package exampleimport "testing"type calcCase struct {A, B, Expected int
}func createMulTestCase(t *testing.T, c *calcCase) {if ans := Mul(c.A, c.B); ans != c.Expected {t.Fatalf("%d * %d expected %d, but %d got",c.A, c.B, c.Expected, ans)}
}func TestMul(t *testing.T) {createMulTestCase(t, &calcCase{2, 3, 6})createMulTestCase(t, &calcCase{A:        2,B:        0,Expected: 1,})
}

我们故意创造了一个错误的条件,接下来我们来看一个这个错误的条件:

=== RUN   TestMulcal_test.go:11: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)FAIL

可以看到,错误发生在第11行,也就是帮助函数 createMulTestCase 内部。18, 19, 20行都调用了该方法,我们第一时间并不能够确定是哪一行发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

修改 createMulTestCase,调用 t.Helper()

func createMulTestCase(t *testing.T, c *calcCase) {t.Helper()if ans := Mul(c.A, c.B); ans != c.Expected {t.Fatalf("%d * %d expected %d, but %d got",c.A, c.B, c.Expected, ans)}
}

再次运行go test就可以看到错误的信息了。

=== RUN   TestMulcal_test.go:19: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)FAIL

这样就明确的可以看到是第19排出现的错误。

setup和teardown

如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,一般会写在 setup 和 teardown 函数中。例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 testing 提供了这样的机制:

package exampleimport ("fmt""os""testing"
)func setup() {fmt.Println("Before all tests")
}func teardown() {fmt.Println("After all tests")
}func Test1(t *testing.T) {fmt.Println("I' m test1")
}func Test2(t *testing.T) {fmt.Println("I' m test2")
}func TestMain(m *testing.M) {setup()code := m.Run()teardown()os.Exit(code)
}
  • 在这个测试文件中,包含有2个测试用例,Test1Test2
  • 如果测试文件中包含函数 TestMain,那么生成的测试将调用 TestMain(m),而不是直接运行测试。
  • 调用 m.Run() 触发所有测试用例的执行,并使用 os.Exit() 处理返回的状态码,如果不为0,说明有用例失败。
  • 因此可以在调用 m.Run() 前后做一些额外的准备(setup)和回收(teardown)工作。

网络测试

TCP/HTTP

假设需要测试某个 API 接口的 handler 能够正常工作,例如 helloHandler

func helloHandler(w http.ResponseWriter, r *http.Request) {w.Write([]byte("hello world"))
}

那我们可以创建真实的网络连接进行测试:

package exampleimport ("io/ioutil""net""net/http""testing"
)func helloHandler(w http.ResponseWriter, r *http.Request) {w.Write([]byte("hello world"))
}func handleError(t *testing.T, err error) {t.Helper()if err != nil {t.Fatal("failed", err)}
}func TestConn(t *testing.T) {ln, err := net.Listen("tcp", "127.0.0.1:0")handleError(t, err)defer ln.Close()http.HandleFunc("/hello", helloHandler)go http.Serve(ln, nil)resp, err := http.Get("http://" + ln.Addr().String() + "/hello")handleError(t, err)defer resp.Body.Close()body, err := ioutil.ReadAll(resp.Body)handleError(t, err)if string(body) != "hello world" {t.Fatal("expected hello world, but got", string(body))}
}
  • net.Listen("tcp", "127.0.0.1:0"):监听一个未被占用的端口,并返回 Listener。
  • 调用 http.Serve(ln, nil) 启动 http 服务。
  • 使用 http.Get 发起一个 Get 请求,检查返回值是否正确。
  • 尽量不对 httpnet 库使用 mock,这样可以覆盖较为真实的场景。

httptest

针对 http 开发的场景,使用标准库 net/http/httptest 进行测试更为高效。

上述的测试用例改写如下:

package exampleimport ("io/ioutil""net/http""net/http/httptest""testing"
)func helloHandler(w http.ResponseWriter, r *http.Request) {w.Write([]byte("hello world"))
}func TestConn(t *testing.T) {req := httptest.NewRequest("get", "http://localhost:5555/metrics", nil)w := httptest.NewRecorder()helloHandler(w, req)bytes, _ := ioutil.ReadAll(w.Result().Body)if string(bytes) != "hello world" {t.Fatal("expected hello world, but got", string(bytes))}
}

使用 httptest 模拟请求对象(req)和响应对象(w),达到了相同的目的。

Benchmark基准测试

基准测试用例的定义如下:

func BenchmarkName(b *testing.B) {// ...
}
  • 函数名必须以 Benchmark 开头,后面一般跟待测试的函数名
  • 参数为 b *testing.B
  • 执行基准测试时,需要添加 -bench 参数。
func BenchmarkMul(b *testing.B) {for i := 0; i < b.N; i++ {fmt.Sprintf("hello")}
}

测试结果如下:

goos: windows
goarch: amd64
pkg: ginTest/example
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
BenchmarkMul
BenchmarkMul-8          28505390                37.13 ns/op
PASS

这是一份基准报告,基准报告每一列对应的含义如下:

type BenchmarkResult struct {N         int           // 迭代次数T         time.Duration // 基准测试花费的时间Bytes     int64         // 一次迭代处理的字节数MemAllocs uint64        // 总的分配内存的次数MemBytes  uint64        // 总的分配内存的字节数
}

如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer() 先重置定时器,例如:

func BenchmarkMul(b *testing.B) {// ...耗时操作b.ResetTimer()for i := 0; i < b.N; i++ {fmt.Sprintf("hello")}
}

使用 RunParallel 测试并发性能

import ("bytes""html/template""testing"
)func BenchmarkParallel(b *testing.B) {templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))b.RunParallel(func(pb *testing.PB) {var buf bytes.Bufferfor pb.Next() {// 所有goroutine一起,循环一共执行b.N次buf.Reset()templ.Execute(&buf, "World")}})
}

测试结果如下:

goos: windows
goarch: amd64
pkg: ginTest/example
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
BenchmarkParallel
BenchmarkParallel-8      3969212               306.5 ns/op
PASS

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
有效的括号 一、题目 给定一个只包括 '(',')','{','}'...
【Ctfer训练计划】——(三... 作者名:Demo不是emo  主页面链接:主页传送门 创作初心ÿ...