如何写好单元测试
单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。你或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。
如何写好单元测试呢?
首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock
。
然后,写可测试的代码。高内聚,低耦合
是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。
接下来将介绍如何使用 Go 语言的标准库 testing
进行单元测试。
一个简单的例子
package example
import "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 string
A, 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 example
import "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 TestMul
cal_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 TestMul
cal_test.go:19: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)
FAIL
这样就明确的可以看到是第19排出现的错误。
setup和teardown
如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,一般会写在 setup 和 teardown 函数中。例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 testing
提供了这样的机制:
package example
import (
"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个测试用例,
Test1
和Test2
。 - 如果测试文件中包含函数
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 example
import (
"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,这样可以覆盖较为真实的场景。
httptest
针对 http 开发的场景,使用标准库 net/http/httptest
进行测试更为高效。
上述的测试用例改写如下:
package example
import (
"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.Buffer
for 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