Go语言单元测试和仿冒
Go语言提供了一个轻量级的测试框架,此框架由testing包和 go test -run命令组成。
要编写测试用例,你需要创建一个以 _test.go结尾的源文件。该文件中包含一个或多个如下签名的函数:
1 2 |
// Test后面的第一个字母不能是小写 func TestXxx(t *testing.T) |
如果想让测试失败,调用testing.T的方法:
1 2 3 4 5 6 7 8 |
if err != nil { // 导致测试失败 t.Errorf("Test failed: %s", err.Error()) return // 或者直接panic t.Fatalf("Test failed: %s", err.Error()) } |
testing包还支持性能基准测试。要执行基准测试,调用命令 go test -bench。
基准测试方法的签名如下:
1 2 3 4 5 6 |
func BenchmarkXxx(*testing.B){ // 目标逻辑必须运行b.N次,N根据实际情况调整,使测试结果尽量可靠 for i := 0; i < b.N; i++ { fmt.Sprintf("hello") } } |
如果要基准测试并发执行的性能,可以使用 go test -cpu标记,并且调用助手函数:
1 2 3 4 5 6 7 8 9 10 11 12 |
func BenchmarkTemplateParallel(b *testing.B) { templ := template.Must(template.New("test").Parse("Hello, {{.}}!")) // 调用该助手函数触发并行测试 b.RunParallel(func(pb *testing.PB) { var buf bytes.Buffer // 返回是否还有更多的迭代需要执行 for pb.Next() { buf.Reset() templ.Execute(&buf, "World") } }) } |
testing包还支持执行并验证样例代码。在被验证方法中,你可以通过注释声明期望的标准输出。 如果被验证方法的标准输出和注释匹配(不考虑首尾空白符),则验证通过。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func ExampleHello() { fmt.Println("hello") // 期望输出必须以Output:开头 // Output: hello } func ExampleSalutations() { fmt.Println("hello, and") fmt.Println("goodbye") // 可以放在多行注释中 // Output: // hello, and // goodbye } func ExamplePerm() { for _, value := range Perm(4) { fmt.Println(value) } // 验证时不考虑输出行的顺序 // Unordered output: 4 // 2 // 1 // 3 // 0 } |
没有期望输出注释的Example方法,虽然编译,单是不会被执行。
样例方法的命名约定:
1 2 3 4 |
func Example() { ... } func ExampleF() { ... } // 函数F的样例代码 func ExampleT() { ... } // 类型T的样例代码 func ExampleT_M() { ... } // 类型T的M方法的样例代码 |
在运行时,你可以跳过一部分单元测试、性能基准测试。
1 2 3 4 5 6 7 |
func TestTimeConsuming(t *testing.T) { // 在短测试模式下跳过此测试 if testing.Short() { t.Skip("skipping test in short mode.") } ... } |
单元测试/基准测试支持“子测试”,你不需要定义额外的函数就可以实现表驱动(table-driven)的基准测试或层次化的单元测试。使用子测试还可以用来共享setup/teardown代码。
1 2 3 4 5 6 7 8 |
func TestFoo(t *testing.T) { // setup code here // t.Run运行t的子测试 t.Run("A=1", func(t *testing.T) { ... }) t.Run("A=2", func(t *testing.T) { ... }) t.Run("B=1", func(t *testing.T) { ... }) // teardown code here } |
每个子测试必须具有唯一性的名称。传递给go test的名字是:顶级测试的名称/子测试的名称,以及一个可选的后缀序列号(去歧义)。
你可以通过命令行执行需要执行哪些测试、哪些子测试:
1 2 3 4 |
go test -run '' # 运行所有测试 go test -run Foo # 运行所有名称以Foo开头的顶级测试 go test -run Foo/A= # 运行所有名称以Foo开头的顶级测试的匹配"A="的子测试(也就是子测试名称为A,不限制序列号) go test -run /A=1 # 运行所有名称以Foo开头的顶级测试的匹配"A=1"的子测试 |
你可以用如下的方法并行执行子测试:
1 2 3 4 5 6 7 8 9 10 11 12 |
// t是父测试 func TestGroupedParallel(t *testing.T) { for _, tc := range tests { tc := tc // 捕获变量供闭包使用 // t.Run以子测试(of t)的形式运行tc t.Run(tc.Name, func(t *testing.T) { // 提示当前测试应当和其它并行(也就是同样调用t.Parallel的)测试并行的运行 t.Parallel() ... }) } } |
所有子测试都完毕之后,父测试才会完成。
如果有以下需求:
- 在测试之前之后执行setup/teardown逻辑
- 控制什么代码在主线程中执行
你可以考虑使用: func TestMain(m *testing.M)
如果测试源文件中包含如上签名的方法,那么go test不会直接运行测试,而是调用TestMain方法。
TestMain会在主线程中执行,你可以在调用m.Run运行具体测试之前、之后提供任何setup/teardown代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func TestMain(m *testing.M) { // setup // 如果传入参数,必须手工调用: flag.Parse() // test exitCode := m.Run() // teardown ... // exit os.Exit(exitCode) } |
一个行为驱动测试框架,参考:Ginkgo学习笔记
gomock是一个通用的仿冒框架,可以和testing包很好的集成。
我们通过mockgen这个命令行工具来生成仿冒代码:
1 |
go install github.com/golang/mock/mockgen@v1.6.0 |
mockgen提供两种不同的操作模式:
- source模式,该模式下,你可以从既有源文件来生成仿冒接口:
1mockgen -source=foo.go - 反射模式,该模式下,会基于反射机制来自动识别需要仿冒的接口:
123# 导入路径,.表示当前目录对应导入路径# 符号列表mockgen database/sql/driver Conn,Driver
-source 包含需要被仿冒的接口的源文件
-destination 生成的仿冒源码存放到的目标文件
-package 生成的仿冒源码使用的包名
-imports 生成的仿冒源码需要明确使用的imports列表,格式foo=bar/baz,foo=bar/baz其中foo是导入包在仿冒源文件中的标识符,bar/baz是被导入的包的路径
-aux_files 辅助文件列表,辅助文件可以是主源文件中的内嵌接口的定义所在的文件,格式foo=bar/baz.go,foo是辅助文件所在包在仿冒源文件中的标识符
-build_flags 反射模式下传递给go build的标记
-mock_names 为每个生成的Mock指定名字,例如Repository=MockSensorRepository,Endpoint=MockSensorEndpoint,键是被仿冒接口的名字
-self_package 生成的代码的完整导入路径,用于防止循环导入
-copyright_file 版权头文件片段
被测试接口:
1 2 3 4 5 6 7 |
type Foo interface { Bar(x int) int } func SUT(f Foo) { // ... } |
测试用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func TestFoo(t *testing.T) { ctrl := gomock.NewController(t) // Go 1.14+, mockgen 1.5.0+ 不再需要显式调用 defer ctrl.Finish() m := NewMockFoo(ctrl) // 断言第一次(也是唯一一次)Bar调用的入参是99,其它任何调用都失败 m. EXPECT(). Bar(gomock.Eq(99)). Return(101) m. EXPECT(). // 期望每一次 Bar(gomock.Eq(99)). // 入参99时 DoAndReturn(func(_ int) int { // 休眠1秒然后会返回101 time.Sleep(1*time.Second) return 101 }). AnyTimes() SUT(m) } |
这是一个HTTP流量仿冒和测试工具,特性包括:
- 简单易用的链式调用API
- 声明式的仿冒DSL
- 内置助手用于简化XML/JSON响应仿冒
- 完整的基于正则式的HTTP请求匹配
- 基于请求方法、URL参数、头、体进行请求匹配
- 可扩展、可拔插的请求匹配规则
- 支持在仿冒/真实网络模式之间切换
- 可以和任何net/http兼容的客户端协作
- 网络延迟模拟
- 无外部依赖
gock的工作原理:
- 通过http.DefaultTransport或自定义的http.Transport来拦截HTTP出站请求
- 以FIFO声明顺序,将出站请求和HTTP仿冒期望(mock expectations)池中的仿冒进行匹配
- 如果至少匹配一个仿冒,则此仿冒负责产生HTTP响应
- 如果没有匹配的仿冒,则默认报错,除非真实网络模式被开启 —— 导致执行真实的HTTP请求
1 |
go get -u gopkg.in/h2non/gock.v1 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
package test import ( "github.com/nbio/st" "gopkg.in/h2non/gock.v1" "io/ioutil" "net/http" "testing" ) func TestSimple(t *testing.T) { // 清理 defer gock.Off() // 仿冒对http://foo.com/bar的GET请求,返回200状态码和JSON响应 gock.New("http://foo.com"). Get("/bar"). Reply(200). JSON(map[string]string{"foo": "bar"}) // 下面的测试代码被拦截 res, err := http.Get("http://foo.com/bar") st.Expect(t, err, nil) st.Expect(t, res.StatusCode, 200) body, _ := ioutil.ReadAll(res.Body) st.Expect(t, string(body)[:13], `{"foo":"bar"}`) // 期望没有未决的mock st.Expect(t, gock.IsDone(), true) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
package test import ( "github.com/nbio/st" "gopkg.in/h2non/gock.v1" "io/ioutil" "net/http" "testing" ) func TestMatchHeaders(t *testing.T) { defer gock.Off() gock.New("http://foo.com"). // 匹配请求头,使用正则式 MatchHeader("Authorization", "^foo bar$"). MatchHeader("API", "1.[0-9]+"). // 要求请求头Accept存在 HeaderPresent("Accept"). Reply(200). // 以字符串形式指定响应体 BodyString("foo foo") req, err := http.NewRequest("GET", "http://foo.com", nil) req.Header.Set("Authorization", "foo bar") req.Header.Set("API", "1.0") req.Header.Set("Accept", "text/plain") ... } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
package test import ( "bytes" "github.com/nbio/st" "gopkg.in/h2non/gock.v1" "io/ioutil" "net/http" "testing" ) func TestMockSimple(t *testing.T) { defer gock.Off() gock.New("http://foo.com"). Post("/bar"). // 以JSON方式匹配请求体 MatchType("json"). // 请求体必须包含foo字段,值为bar JSON(map[string]string{"foo": "bar"}). Reply(201). JSON(map[string]string{"bar": "foo"}) body := bytes.NewBuffer([]byte(`{"foo":"bar"}`)) res, err := http.Post("http://foo.com/bar", "application/json", body) } |
1 2 3 |
req, err := http.NewRequest("GET", "http://foo.com", nil) client := &http.Client{Transport: &http.Transport{}} gock.InterceptClient(client) |
1 2 3 4 |
defer gock.DisableNetworking() gock.EnableNetworking() gock.New("http://httpbin.org")... |
1 |
gock.Observe(gock.DumpRequest) |
编写实际测试逻辑之前,先把仿冒做好:
1 2 3 4 5 6 7 8 9 10 |
func TestFoo(t *testing.T) { defer gock.Off() // 再测试完成之后,刷空未决仿冒 gock.New("http://server.com"). Get("/bar"). Reply(200). JSON(map[string]string{"foo": "bar"}) // 在这里编写测试代码 } |
如果你的测试代码是并发的,无比预先准备好仿冒。gock不是线程安全的。
如果你需要编写一系列仿冒,那么,先编写具体化的、精确匹配请求的仿冒,然后再编写一般化的、通配的仿冒。
这样可以保证具体化的仿冒优先被测试是否匹配请求。
你仅仅需要在测试开始之前,拦截客户端一次:
1 |
gock.InterceptClient(client) |
在运行完测试场景之后,应当取消对客户端的拦截:
1 2 3 4 |
function TestGock (t *testing.T) { defer gock.Off() defer gock.RestoreClient(client) } |
如果你使用的是http.DefaultClient或者http.DefaultTransport,不需要取消拦截。
手工编写Mock的困难在于如何精确的模拟依赖的行为。如果依赖已经开发完毕,而你需要实现可重复的、基于仿冒的单元测试,可以考虑将依赖的行为“录制”下来,并依此实现Mock。
govcr就是一个能实现HTTP交互录制/回放的开源项目,它同时支持回放成功、失败的HTTP事务。它本质上是 http.Client的包装器。
1 2 3 4 5 6 7 8 |
go get github.com/seborama/govcr # 或者,明确指定兼容性版本,例如v4.x go get gopkg.in/seborama/govcr.v4 # 导入路径 import "gopkg.in/seborama/govcr.v4" |
术语 | 说明 |
VCR |
磁带录像机(Video Cassette Recorder),表示govcr提供的录制回放引擎,以及它产生的所有数据 VCR可以进行HTTP录制和回放,重复的请求回基于先前录制的信息 —— 位于磁盘中cassette文件中的track —— 直接返回,新请求则真正转发给真实服务器 |
cassette | 一系列track的集合,默认保存在./govcr-fixtures目录下,形式为JSON文件,扩展名.cassette |
Long Play cassette | 以GZIP压缩的cassette,只需要以.gz后缀声明cassette名称即可启用 |
track |
一个录制的HTTP请求,包括请求数据、响应数据,发生的错误 如果存在多个匹配请求的Track,则根据它们录制的顺序,依次进行回放 |
PCB |
印刷电路板(Printed Circuit Board),能对VCR的某方面行为进行定制,例如:
|
此结构用于配置govcr记录器:
1 2 3 4 5 6 7 8 9 10 |
vcr := govcr.NewVCR("MyCassette", &govcr.VCRConfig{ // 录像存储路径 CassettePath: "./govcr-fixtures", // 禁用录制(但是如果有匹配的track仍然会回放) DisableRecording: true, // 禁用日志 Logging: false, // 不录制TLS数据 RemoveTLS: true, }) |
某些情况下,请求无法匹配到已经录制的track。例如请求中包含一个时间戳参数,后者动态变化的标识符。另外一些情况下,响应需要进行转换。应对这些场景,你需要使用过滤器:
- RequestFilter处理当前真实请求、Track上的请求,例如删除某个请求头,从而影响它们的匹配
- ResponseFilter可以在返回给Client之前对响应进行预处理
这些转换操作不会持久化,也就是它不会影响录制的Track。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
package main import ( "fmt" "github.com/seborama/govcr" ) const example1CassetteName = "MyCassette1" func Example1() { vcr := govcr.NewVCR(example1CassetteName, nil) vcr.Client.Get("http://example.com/foo") fmt.Printf("%+v\n", vcr.Stats()) } |
某些情况下,你的应用程序会创建自己的http.Client包装器,或者初始化自己的http.Transport(例如使用HTTPS的时候),你可以传递自己的http.Client对象给VCR,VCR会包装它,你需要使用包装后的http.Client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
package main import ( "crypto/tls" "fmt" "net/http" "time" "github.com/seborama/govcr" ) const example2CassetteName = "MyCassette2" type myApp struct { httpClient *http.Client } func (app myApp) Get(url string) { app.httpClient.Get(url) } func Example2() { // 创建自定义的Transport tr := http.DefaultTransport.(*http.Transport) tr.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, // 禁用TLS安全检查 } myapp := &myApp{ // 使用自定义传输 httpClient: &http.Client{ Transport: tr, Timeout: 15 * time.Second, }, } // 将Client传递给VCR vcr := govcr.NewVCR(example2CassetteName, &govcr.VCRConfig{ Client: myapp.httpClient, }) // 使用注入后的HttpClient myapp.httpClient = vcr.Client myapp.Get("https://example.com/foo") fmt.Printf("%+v\n", vcr.Stats()) } |
下面是请求过滤器的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
package main import ( "fmt" "strings" "time" "net/http" "github.com/seborama/govcr" ) const example4CassetteName = "MyCassette4" func Example4() { vcr := govcr.NewVCR(example4CassetteName, &govcr.VCRConfig{ RequestFilters: govcr.RequestFilters{ // 删除当前请求、Track请求的指定请求头后再进行匹配 govcr.RequestDeleteHeaderKeys("X-Custom-My-Date"), }, Logging: true, }) req, err := http.NewRequest("POST", "http://example.com/foo", nil) if err != nil { fmt.Println(err) } req.Header.Add("X-Custom-My-Date", time.Now().String()) vcr.Client.Do(req) fmt.Printf("%+v\n", vcr.Stats()) } |
下面的例子同时使用请求过滤器、响应过滤器:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func Example5() { vcr := govcr.NewVCR(example5CassetteName, &govcr.VCRConfig{ RequestFilters: govcr.RequestFilters{ govcr.RequestDeleteHeaderKeys("X-Transaction-Id"), }, ResponseFilters: govcr.ResponseFilters{ // 使用请求头中的X-Transaction-Id覆盖响应头中的X-Transaction-Id govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), }, Logging: true, }) } |
下面展示过滤器的高级用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
func Example6() { cfg := govcr.VCRConfig{ Logging: true, } // 请求过滤器:将URL中的/order/{random} 重写为 /order/1234 replacePath := govcr.RequestFilter(func(req govcr.Request) govcr.Request { // 重写 req.URL.Path = "/order/1234" return req }) // 条件性过滤器,仅当URL路径匹配example.com/order时才启用此过滤器 replacePath = replacePath.OnPath(`example\.com\/order\/`) // 添加过滤器到VCRConfig cfg.RequestFilters.Add(replacePath) cfg.RequestFilters.Add(govcr.RequestDeleteHeaderKeys("X-Transaction-Id")) // 响应过滤器 cfg.ResponseFilters.Add( // 覆盖响应头 govcr.ResponseTransferHeaderKeys("X-Transaction-Id"), // 修改状态码 func(resp govcr.Response) govcr.Response { if resp.StatusCode == http.StatusNotFound { resp.StatusCode = http.StatusAccepted } return resp }, // 如果HTTP方法为GET,则添加响应头 govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { resp.Header.Add("method-was-get", "true") return resp }).OnMethod(http.MethodGet), // 如果HTTP方法为POST,则添加响应头 govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { resp.Header.Add("method-was-post", "true") return resp }).OnMethod(http.MethodPost), // 使用关联的请求的信息 govcr.ResponseFilter(func(resp govcr.Response) govcr.Response { url := resp.Request().URL resp.Header.Add("get-url", url.String()) return resp }).OnMethod(http.MethodGet), ) } |
此包提供通用的断言、仿冒功能。
该子包提供断言功能。示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
package yours import ( "testing" "github.com/stretchr/testify/assert" ) func TestAssertions(t *testing.T) { // 断言相等 assert.Equal(t, 123, 123, "they should be equal") // 断言不等 assert.NotEqual(t, 123, 456, "they should not be equal") // 断言为空 assert.Nil(t, object) // 断言不为空 if assert.NotNil(t, object) { // 进一步断言 assert.Equal(t, "Something", object.Value) } // assertThat i := 0 assert.Condition(t, func() bool { return i == 0 }) // 断言函数调用有错误 actualObj, err := SomeFunction() if assert.Error(t, err) { // ... } // 断言字符串、列表、映射包含指定的元素或子串 assert.Contains(t, "Hello World", "World") assert.Contains(t, ["Hello", "World"], "World") assert.Contains(t, {"Hello": "World"}, "Hello") // 断言为空,也就是为nil、0、false,或者切片、通道长度为0 assert.Empty(t, obj) } |
该包提供仿冒功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
package testify import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "testing" ) // 仿冒对象 type MyMockedObject struct { mock.Mock } // DoSomething是模拟的对象所具有的接口 func (m *MyMockedObject) DoSomething(number int) (bool, error) { // 告知仿冒对象的方法被调用的事实 // 获取入参所关联的Arguments args := m.Called(number) // 返回Arguments的第1、2个元素 return args.Bool(0), args.Error(1) } func TestSomething(t *testing.T) { // 实例化仿冒 testObj := new(MyMockedObject) // 设置期望,如果入参123,则Argument的第1、2元素分别置为true nil testObj.On("DoSomething", 123).Return(true, nil) // 设置期望,对于任何入参,Argument的第1、2元素分别置为true nil testObj.On("DoSomething", mock.Anything).Return(true, nil) // 调用仿冒方法 b, e := testObj.DoSomething(123) assert.Equal(t, b, true) assert.Empty(t, e) // 断言期望 testObj.AssertExpectations(t) } |
该包提供很多面向对象语言的单元测试工具(例如Junit)提供的功能。使用此包,你可以在结构中设计自己的测试套装(testing suite),编写setup/teardown方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
package testify import ( "github.com/stretchr/testify/suite" "testing" ) // 定义测试套装 type ExampleTestSuite struct { suite.Suite } // 套装运行前执行 func (suite *ExampleTestSuite) SetupSuite() { } // 每个测试运行前执行 func (suite *ExampleTestSuite) SetupTest() { } // 套件结构所有Test开头的方法,都是需要执行的测试 func (suite *ExampleTestSuite) TestExample() { } // 每个测试运行后执行 func (suite *ExampleTestSuite) TearDownTest() { } // 套装运行后执行 func (suite *ExampleTestSuite) TearDownSuite() { } // 为了支持go test,需要编写一个常规的Go测试方法 func TestExampleTestSuite(t *testing.T) { // 并在其方法体调用: suite.Run(t, new(ExampleTestSuite)) } |
Leave a Reply