如何优雅的做单元测试

Saner-Lee · · 335513 次点击 · 开始浏览    置顶
这是一个创建于 的主题,其中的信息可能已经有所发展或是发生改变。

# 单元测试 单测中最多的还是函数级别的测试,用于验证功能逻辑的正确性。但是很多时候我们需要走一些完整的流程测试,如下: - 接口测试 - `client side` - `server side` - 数据库测试 ## 接口测试 ### client side 作为客户端,基本都需要与后端服务进行交互,这时候需要关注两点: - 后台服务返回正常数据,客户端本身是否存在逻辑问题 - 后台服务异常返回,客户端本身能否处理各种异常 总结一下,核心点在于在单测中具有修改`server`的能力!!!这个问题有两个解决方案: 1. 本地构造`fake server` 2. 根据异常场景构建多个后端测试`server` 构建多个用于测试的`server`,成本较高,资源不容易管理。幸运的是`go`本身具有构造[`fake server`](https://golang.org/pkg/net/http/httptest/#example_Server)的能力。 注意接口设计!!!传送门中的例子仅仅是为了演示用法,在项目开发中,请时刻将依赖注入刻在心中。下面是一个构造的例子: ```go package thttp import ( "fmt" "net/http" ) func BaiduRPC(url string, header http.Header) error { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return err } req.Header = header resp, err := http.DefaultClient.Do(req) if err != nil { return err } if resp.StatusCode != 200 { return fmt.Errorf("request failed") } return nil } ``` ```go package thttp import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestBaiduRPC(t *testing.T) { users := map[string]struct{}{ "litianxiang01": struct{}{}, } fs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header == nil { r.Header = http.Header{} } uid := strings.ToLower(r.Header.Get("uid")) if len(uid) == 0 { w.WriteHeader(http.StatusBadRequest) return } _, exist := users[uid] if exist { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusNotFound) } })) cases := []struct { in string want bool }{ {"litianxiang01", true}, {"default", false}, {"", false}, } backend := fs.URL for _, item := range cases { header := make(http.Header) header.Add("uid", item.in) out := BaiduRPC(backend, header) assert.Equalf(t, item.want, out == nil, "%s is unexpected", item.in) } } ``` 上述例子中的输入和输出都是预期的,因此三个case都会通过测试,显示如下: ```shell === RUN TestBaiduRPC --- PASS: TestBaiduRPC (0.00s) PASS ok testify/demo/test/thttp 0.005s ``` 如果把`test`文件中`cases`里的`default`的第二个参数修改为true,这时候后端的处理就不符合预期了(后端有bug了),这时`testify`框架也会提供友好的输出: ```shell === RUN TestBaiduRPC client_test.go:50: Error Trace: client_test.go:50 Error: Not equal: expected: true actual : false Test: TestBaiduRPC Messages: default is unexpected --- FAIL: TestBaiduRPC (0.00s) ``` ### server side 作为服务端,无论如何都需要处理客户端发送的请求,这时候需要关注两点: - 客户端按要求发送数据,服务端本身是否存在逻辑问题 - 客户端发送非法数据,服务端本身能否处理各种异常 总结一下,核心点在于构造输入,而与是否存在真实的客户端没有关系! 问题的解决方案就是构造各种异常输入,但是手动构造存在几个问题: - 对标准库了解不足,构造请求参数存在遗漏 - 熟悉标准库,但是构造麻烦 如何避免上述问题的发生?那么用[它](https://golang.org/pkg/net/http/httptest/#example_ResponseRecorder)吧!用[它](https://golang.org/pkg/net/http/httptest/#example_ResponseRecorder)吧!用[它](https://golang.org/pkg/net/http/httptest/#example_ResponseRecorder)吧! 下面是对`client side`章节提供的例子的一次改写,改写为`server side`,如下: ```go package thttp import ( "net/http" "strings" ) var ( users = map[string]struct{}{ "litianxiang01": struct{}{}, } ) func API(w http.ResponseWriter, r *http.Request) { if r.Header == nil { r.Header = http.Header{} } uid := strings.ToLower(r.Header.Get("uid")) if len(uid) == 0 { w.WriteHeader(http.StatusBadRequest) return } _, exist := users[uid] if exist { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusNotFound) } } ``` ```go package thttp import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" ) func TestAPI(t *testing.T) { cases := []struct { in string want bool }{ {"litianxiang01", true}, {"default", false}, {"", false}, } for _, item := range cases { header := make(http.Header) header.Add("uid", item.in) r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header = header w := httptest.NewRecorder() API(w, r) assert.Equalf(t, item.want, w.Code == http.StatusOK, "%s is unexpected", item.in) } } ``` ## 数据库测试 > 本章节内容不仅仅是面向数据库,而仅仅是借助数据库来说明如何mock一些依赖的第三方中间组件。 在软件的生命周期中有一些原则需要遵守: - 面向接口编程 - 依赖注入 ### 面向接口编程 在学习的过程你可能会经常看到: - 面向过程 - 面向对象 - 面向接口 面向对象和面向过程属于同一个维度,两者的本质在于如何分析问题。而面向接口属于更上层的概念,它是指导如何应对变化的一种思想。 对于单元测试来说,变化指的就是替换,而替换恰恰是`mock`的本质,所以面向接口编程是为了将来可以使用一个假的替换一个真的来达到同样的功能效果。 既然需要使用假的替代真的,那么就需要有一个假的。你可以使用硬编码一个实现了接口的实例来达到效果,不过这样做有点“过于简单”,社区常见的一种方式是使用代码生成工具,推荐一个官方的`mock`工具[`mockgen`](https://github.com/golang/mock),配合[`golang/mock`](https://github.com/golang/mock),配合[`golang/mock`](https://github.com/golang/mock)使用。 通过一个简单的例子来大概的介绍一下使用方式,源文件: ```go package db type DB interface { Get(string) ([]byte, error) Put(string, []byte) error Del(string) error } func GetFromDB(db DB, key string) ([]byte, error) { return db.Get(key) } ``` 通过`mockgen`生成代码(`mockgen -source=db.go -destination=mock_db_test.go -package=db_test`) ```go // Code generated by MockGen. DO NOT EDIT. // Source: db.go // Package db_test is a generated GoMock package. package db_test import ( reflect "reflect" gomock "github.com/golang/mock/gomock" ) // MockDB is a mock of DB interface. type MockDB struct { ctrl *gomock.Controller recorder *MockDBMockRecorder } // MockDBMockRecorder is the mock recorder for MockDB. type MockDBMockRecorder struct { mock *MockDB } // NewMockDB creates a new mock instance. func NewMockDB(ctrl *gomock.Controller) *MockDB { mock := &MockDB{ctrl: ctrl} mock.recorder = &MockDBMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDB) EXPECT() *MockDBMockRecorder { return m.recorder } // Del mocks base method. func (m *MockDB) Del(arg0 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Del", arg0) ret0, _ := ret[0].(error) return ret0 } // Del indicates an expected call of Del. func (mr *MockDBMockRecorder) Del(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Del", reflect.TypeOf((*MockDB)(nil).Del), arg0) } // Get mocks base method. func (m *MockDB) Get(arg0 string) ([]byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBMockRecorder) Get(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDB)(nil).Get), arg0) } // Put mocks base method. func (m *MockDB) Put(arg0 string, arg1 []byte) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Put", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // Put indicates an expected call of Put. func (mr *MockDBMockRecorder) Put(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockDB)(nil).Put), arg0, arg1) } ``` 编写单元测试: ```go package db_test import ( "demo/db" "fmt" "testing" "github.com/golang/mock/gomock" ) func TestGet(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() dbi := NewMockDB(ctrl) opt1 := dbi.EXPECT().Get(gomock.Eq("key1")).Return(nil, fmt.Errorf("not found")) opt2 := dbi.EXPECT().Get(gomock.Eq("key2")).Return([]byte("vales"), nil) gomock.InOrder(opt1, opt2) db.GetFromDB(dbi, "key1") db.GetFromDB(dbi, "key2") } ``` ### 依赖注入 依赖注入,控制反转...这些词你可能经常听到,但是可能对它只是有所耳闻,没关系,下面通过例子来阐述为什么会有这玩意,然后你就知道这玩意是做什么的了。 回到之前数据库`mock`的章节中,当时通过接口抽象一个简单的`kv`模型,真正的读取方法是`func Get(db DB, key string) (value []byte, err error)`,乍一看这个函数原型,设计的真恶心,我读取数据居然还需要穿一个`db`实例进去,设计成`func Get(key string) (value []byte, err error)`不是对上层更友好吗?是的!确实是对上层友好。但是: > 被mock通常是一些infra structure,对它们进行单测毫无意义,单测的对象应该是依赖infra structure的对象。 比如作为一个`api server`,可以将业务分层: - controller:接口层 - service:业务层 - db:数据层 `controller`属于`server side`的范畴,在最开始就进行了阐述。`db`是组件,不掺杂逻辑,正常情况下`crud`语句是对的就没有错误。因此侧重点在于测试`service`。 对于一些简单的业务逻辑,人们很容易采用面向过程的方式,直接将需求按步骤翻译成代码,如下: ```go // controller func Exist(w http.ResponseWriter, r *http.Request) { // 获取输入 // 验证参数的合理性 // 访问service // 处理返回的结果 } // service func Exist(w http.ResponseWriter, r *http.Request) { // 获取输入 // 访问db // 处理返回结果 } // db var db *sql.DB func init() { // 初始化,获取实例 d, err := dial() if err != nil { panic(err) } // 赋值db db = d } func Get(key string) ([]byte, error) { row, err := db.QueryRow("select name from users where id = ?", key) if err != nil { return nil, err } var name string err = row.Scan(&name) if err != nil { return nil, err } if err := row.Err(); err != nil { return nil, err } return []byte(name), nil } ``` 写代码的时候很迅速,不需要考虑,但是它存在两个问题: - `service`无法对`db`层`mock` - 业务与`db`强耦合 为了解决以上两个问题,推荐方式如下: ```go package service import "demo/db" type UserService struct { db db.DB } // 这就是控制反转,实现控制反转的方案是依赖注入 func NewUserService(databse db.DB) *UserService { return &UserService{ db: databse, } } func (us *UserService) Exist(id string) (ok bool, err error) { v, err := us.db.Get(id) if err != nil { return } if len(v) == 0 { return false, nil } return true, nil } ``` - 为什么叫控制反转: - before:`service`层没有`db`初始化的控制权 - after:`service`层拥有`db`初始化的控制权 - 依赖注入如何实现: - before:在`db`层硬编码依赖的实现 - after:在`service`层初始化依赖的抽象,在需要的地方注入依赖 这样既能很大程度的保证切换底层存储引擎时代码的兼容性,也方便了对业务逻辑进行单测。 ## 集成测试 很多时候你无法`mock`中间件,比如: - 老旧代码,重构成本高 - 验证使用中间件的方式(命令是否正确) - 端到端的测试 有没有什么比较好的解决方案呢?当然是有的!使用容器来搭建搭建你需要的测试环境。[使用 docker-compose.yml 定义多容器应用程序](https://docs.microsoft.com/zh-cn/dotnet/architecture/microservices/multi-container-microservice-net-applications/multi-container-applications-docker-compose)。 ## go test ### mode > Go test runs in two different modes: > > The first, called local directory mode, occurs when go test is invoked with no package arguments (for example, 'go test' or 'go test -v'). In this mode, go test compiles the package sources and tests found in the current directory and then runs the resulting test binary. In this mode, caching (discussed below) is disabled. After the package test finishes, go test prints a summary line showing the test status ('ok' or 'FAIL'), package name, and elapsed time. > > The second, called package list mode, occurs when go test is invoked with explicit package arguments (for example 'go test math', 'go test ./...', and even 'go test .'). In this mode, go test compiles and tests each of the packages listed on the command line. If a package test passes, go test prints only the final 'ok' summary line. If a package test fails, go test prints the full test output. If invoked with the -bench or -v flag, go test prints the full output even for passing package tests, in order to display the requested benchmark results or verbose logging. After the package tests for all of the listed packages finish, and their output is printed, go test prints a final 'FAIL' status if any package test has failed. > > In package list mode only, go test caches successful package test results to avoid unnecessary repeated running of tests. When the result of a test can be recovered from the cache, go test will redisplay the previous output instead of running the test binary again. When this happens, go test prints '(cached)' in place of the elapsed time in the summary line. > > The rule for a match in the cache is that the run involves the same test binary and the flags on the command line come entirely from a restricted set of 'cacheable' test flags, defined as -cpu, -list, -parallel, -run, -short, and -v. If a run of go test has any test or non-test flags outside this set, the result is not cached. To disable test caching, use any test flag or argument other than the cacheable flags. The idiomatic way to disable test caching explicitly is to use -count=1. Tests that open files within the package's source root (usually $GOPATH) or that consult environment variables only match future runs in which the files and environment variables are unchanged. A cached test result is treated as executing in no time at all, so a successful package test result will be cached and reused regardless of -timeout setting. `go test`有两种运行模式: 第一种叫做`local directory mode`,它指的是当你没有传递任何`package`的参数给到`go test`命令时,比如`go test`或者`go test -v`。在这种模式下,`go test`编译当前目录下的源文件和单测文件生成用于测试的二进制程序并运行它。此时是没有任何缓存能力的,在所有的单测结束后,`go test`打印一行总结性的内容,比如测试状态,包名和花费的时间。 第二种叫做`package list mode`,它指的是当你传递一个明确的`package`参数给到`go test`命令时,比如`go test math`,`go test ./...`甚至是`go test .`。在这种模式下,`go test`编译并且测试命令行中指定的每一个包。如果一个包测试通过,只会在最后打印`ok`之类总结性的内容。如果一个包测试失败,那么会答应所有测试的输出。 在`package list mode`下,会出现`cache`,保存测试成功的`TestCase`,再次测试时,如果是之前成功的`TestCase`,那么将不会再次执行,而是会返回`cache`的结果!!! 有些时候,`cache`并不符合预期,比如你对之前成功的`TestCase`做了修改,但是重新运行时没有重新执行,而是直接根据`cache`结果返回了成功,这种场景有很大的风险,因为你可能是修改的逻辑,又或者会引发运行时异常,因此需要掌握避免`cache`的方法: 1. 在执行`go test`时添加`--count=1`的参数来显示的禁用缓存 2. 在测试前执行`go clean testcache`用于删除缓存 3. 设置`GOCACHE`环境变量的值,这个值的含义是缓存的路径,当设置为`off`时表示禁用缓存 最推荐的是方式一,配合运行指定的测试集可以将发挥最好的性能得到最好的结果。 ### TestMain `TestMain`是单元测试中的主函数,有了主函数就相当于有了控制权,可以决定单测的整个流程。 为什么提供这样的能力?因为很多时候会对单测有一些特殊的要求,比如: - 资源初始化 - 资源回收 - 指定测试的顺序 #### setup 在单测开始前做一些初始化操作。 #### teardown 在单测结束后做一些清理的操作。 #### example ```go package service_test import ( "os" "testing" ) func TestUserExist(t *testing.T) { // setup code t.Run("exist_1", func(t *testing.T) {}) t.Run("exist_2", func(t *testing.T) {}) t.Run("exist_3", func(t *testing.T) {}) // teardown code } func TestMain(m *testing.M) { // setup code code := m.Run() // teardown code os.Exit(code) } ``` ### 表驱动测试 其实就是某个场景下所有的`case`的“输入输出”写入到一个表中,然后迭代表,对每个输入进行处理,对比是否符合输出。 ### 指定测试集 很多时候不需要测试全部的`TestCase`,比如: - 测试新加的一个功能接口 - 验证某一个函数级别的`bugfix`是否生效 - 运行某一类单元测试(比如不同的类命名有规则) 总而言之就是不需要全部测试,针对于这样的需求,可以在运行单元测试的同时使用特定的`flag`来完成,`go test -run regexp pkg` ## 附录 ### 依赖注入 由于单元测试需要依赖依赖注入的能力,而在一个复杂的项目中,可能一个实例有很多的依赖,这时候写初始化代码也会成为开发者的负担,为此`go`官方也提供了响应的依赖注入框架解决,通过代码生成的方式去省去写初始化代码的成本。这里推荐[wire](https://github.com/google/wire)。

有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

335513 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传