单元测试(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)}
}
子测试是 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 函数中。例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 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)
}
Test1
和 Test2
。TestMain
,那么生成的测试将调用 TestMain(m),而不是直接运行测试。m.Run()
触发所有测试用例的执行,并使用 os.Exit()
处理返回的状态码,如果不为0,说明有用例失败。m.Run()
前后做一些额外的准备(setup)和回收(teardown)工作。假设需要测试某个 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 请求,检查返回值是否正确。http
和 net
库使用 mock,这样可以覆盖较为真实的场景。针对 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),达到了相同的目的。
基准测试用例的定义如下:
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
上一篇:学习笔记20230318
下一篇:黏菌算法优化程序