编辑
2024-05-20
还没写好
00
请注意,本文编写于 526 天前,最后修改于 526 天前,其中某些信息可能已经过时。

目录

1. 文档概要
2. 什么是单元测试
3. 什么是好的单元测试
3. 编写可测试的代码
3. MySQL 数据库测试示例
4. Redis 测试示例
5. 网络请求测试示例
6. Interface 接口模拟(Mock)
7. 函数 patch 示例
9. controller 层(业务接口)测试示例
10 测试覆盖率
10 业务接口测试覆盖率

1. 文档概要

此文档仅供交易组编写 Golang 单元测试提供参考示例

2. 什么是单元测试

就像细胞是构成我们身体的基本单位,一个软件程序也是由很多单元组件构成的。单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。 Go 语言中的测试依赖 go test 命令。编写测试代码和编写普通的 Go 代码过程是类似的,并不需要学习新的语法、规则或工具。 go test 命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以 _test.go 为后缀名的源代码文件都是 go test 测试的一部分,不会被 go build 编译到最终的可执行文件中。 go test 命令会遍历所有的 *_test.go 文件中符合上述命名规则的函数,然后生成一个临时的 main 包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

3. 什么是好的单元测试

一个好单元测试应该具备以下属性:

  • 易写 开发人员通常编写大量的单元测试,来覆盖应用的可能出现的不同行为和不同方面。这就要求单测是不需要花费程序员大量精力就可以轻松编写的。
  • 可读性 单元测试的目的应该是明确的。单元测试描述的是我们应用中某个行为的影响,因此一个好的单元测试应该很容易让人理解正在测试的是哪种场景。如果单测失败,也很容易知道问题点在哪里。一个好的单元测试,让我们可以在不 debug 的情况下修复错误!
  • 可靠 单测只有在系统有 bug 的情况下才会失败。虽然这看起来很明显,但是有些程序经常即使没有bug,也出现测试失败的情况。例如,测试可能在某个运行时通过,但是在运行整个测试套件时失败,或者是在开发环境单测通过,但是在集成环境中单测失败。这些情况表明单测存在设计缺陷。好的单元测试是可以重复执行的,并且不受环境或运行顺序等外部因素的影响。
  • 运行快 开发人员编写单元测试是为了重复执行,以检测新代码是否引入了新的 bug。如果单元测试很慢,开发人员很可能不会在他们的机器上运行单元测试。一个慢的单测,可能不会造成很大的影响,但是一千个慢的单测,那将会浪费很多时间了。慢的单测可能还表明了被测系统和单测本身可能和外部系统产生了交互,单测可能依赖外部因素。
  • 真单元,非集成 单元测试和集成测试有不同的目的,我们要区分开。单元测试和被测系统都不应访问网络资源、数据库和文件系统等,避免受到外部因素的影响。

3. 编写可测试的代码

编写可测试的代码可能比编写单元测试本身更加重要,可测试的代码简单来说就是指我们可以很容易的为其编写单元测试代码。编写单元测试的过程也是一个不断思考的过程,思考我们的代码是否正确的被设计和实现。 Google 测试大牛 - Misko Hevery 的文章:Writing Testable Code 中提到一个非常实用的观点:在开发时,多想想如何使得自己的代码更方便去测试。如果考虑到这些,那么通常你的代码设计也不会太差。 编写可测试代码需要一定的纪律性、专注力和额外的努力。我们尽管在软件开发中会存在复杂的心理活动,但是也应该时刻小心,避免鲁莽地从头就开始盲目的堆砌代码。 如果我们保证了软件的开发质量,那么我们最终会得到干净的、易于维护的、低耦合的和可重用的 API, 当开发人员理解这些 API 的时候,以致于不会让他们头疼。毕竟,可测试性代码的最终优势不止在于其本身的可测试,更给代码带来了易理解、易维护和易扩展的优点。 参考:编写可测试的代码

3. MySQL 数据库测试示例

sqlmock 是一个实现 sql/driver 的 mock 库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候 mock sql 语句的执行结果。 安装 go get github.com/DATA-DOG/go-sqlmock 示例如下:

go
package access //go:generate mockery --name Accessor --filename access.go --with-expecter type Accessor interface { dbaccess.Accessor SearchTagsBySubAccountID(ctx context.Context, tx *gorm.DB, subAccountID string) (SubAccountIDWithTagInfos, error) } type access struct { dbaccess.Accessor } func (a access) SearchTagsBySubAccountID(ctx context.Context, tx *gorm.DB, subAccountID string) (SubAccountIDWithTagInfos, error) { if tx == nil { tx = mysql.WithContext(ctx, a.DB()) } var results []SubAccountIDWithTagInfo sql := `select t3.subAccountId as subAccountId, t1.id as tagId, t1.name as tagName from tradedb.t_follow_invest_label_basicinfo t1         inner join tradedb.t_follow_invest_client_label t2         inner join tradedb.t_follow_invest_client_basicinfo t3                    on t1.id = t2.labelId and t2.clientId = t3.clientId where t3.subAccountId = ?` return results, tx.Raw(sql, subAccountID).Scan(&results).Error } func New(db *gorm.DB) Accessor { return access{dbaccess.NewAccessor(db)} }
go
package access_test func TestAccess_SearchTagsBySubAccountID(t *testing.T) { Convey("access", t, func() { Convey("SearchTagsBySubAccountID", func() { // init // open database stub db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) SoMsg("new sqlmock failed ", err, ShouldBeNil) defer mock.ExpectationsWereMet() gormDB, err := mysql.WrapSQLDBWithMode(db, &mysql.Options{SkipInitializeWithVersion: true}, "debug") SoMsg("new gorm db failed ", err, ShouldBeNil) Convey("success case", func() { // define behavior eq := mock.ExpectQuery(`select t3.subAccountId as subAccountId, t1.id as tagId, t1.name as tagName from tradedb.t_follow_invest_label_basicinfo t1         inner join tradedb.t_follow_invest_client_label t2         inner join tradedb.t_follow_invest_client_basicinfo t3                    on t1.id = t2.labelId and t2.clientId = t3.clientId where t3.subAccountId = ?`) eq.WithArgs(sqlmock.AnyArg()) eq.WillReturnRows(sqlmock.NewRows([]string{"subAccountId", "tagId", "tagName"}). AddRow("001", 1, "美股达人"). AddRow("002", 2, "港股达人")) dbSearchResult, err := access.New(gormDB).SearchTagsBySubAccountID(context.Background(), nil, "") So(err, ShouldBeNil) So(dbSearchResult, ShouldNotBeNil) So(2, ShouldEqual, len(dbSearchResult)) Printf("db search result is %v\n", dbSearchResult) Printf("db search result tagIDs is %v\n", dbSearchResult.TagIDs()) }) Convey("RecordNotFound case", func() { // define behavior eq := mock.ExpectQuery(`select t3.subAccountId as subAccountId, t1.id as tagId, t1.name as tagName from tradedb.t_follow_invest_label_basicinfo t1         inner join tradedb.t_follow_invest_client_label t2         inner join tradedb.t_follow_invest_client_basicinfo t3                    on t1.id = t2.labelId and t2.clientId = t3.clientId where t3.subAccountId = ?`) eq.WithArgs(sqlmock.AnyArg()) eq.WillReturnError(gorm.ErrRecordNotFound) dbSearchResult, err := access.New(gormDB).SearchTagsBySubAccountID(context.Background(), nil, "") So(err, ShouldNotBeNil) So(err, ShouldEqual, gorm.ErrRecordNotFound) So(dbSearchResult, ShouldBeNil) }) }) }) }

4. Redis 测试示例

待补充

5. 网络请求测试示例

使用 gock 可以对外部 API 资源进行模拟,即模拟指定参数返回约定好的响应内容。 安装 go get -u github.com/h2non/gock 代码示例:

go
package requester var ErrNotOK = stderr.New("status not ok") func IsStatusNotOK(err error) bool { return errors.Is(err, ErrNotOK) } //go:generate mockery --name Requester --filename requester.go --with-expecter type Requester interface { Get(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) Post(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) } type client struct { client *resty.Client host   string } func New(c *resty.Client, host string) Requester { return &client{client: c, host: host} } func (r client) Get(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) { path, err := JoinPath(r.host, path) if err != nil { return nil, errors.Wrapf(err, "join host(%v) with path(%v) failed", r.host, path) } return r.check(r.new(ctx, ops...).Get, path) } func (r client) Post(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) { path, err := JoinPath(r.host, path) if err != nil { return nil, errors.Wrapf(err, "join host(%v) with path(%v) failed", r.host, path) } return r.check(r.new(ctx, ops...).Post, path) }
go
package requester func TestClient_Get(t *testing.T) { t.Run("match", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) // define behavior body := `{ "jsonrpc": "2.0", "result": [ { "uin": 1056338, "loginTime": "2023-02-04 14:54:20" } ], "id": "643c075a-6a7e-4985-a8d6-7edf357f9594" }` gock.New(host).Get(constant.UCQueryV1LoginHistory).MatchParam("subAccountId", "001").Reply(http.StatusOK).BodyString(body) resp, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory, WithQueryParams(map[string]string{"subAccountId": "001"})) require.Nil(t, err) require.Equal(t, resp.StatusCode(), http.StatusOK) require.Equal(t, resp.Body(), []byte(body)) }) t.Run("status not 200 but 404", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) // define behavior gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusNotFound) resp, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, IsStatusNotOK(err)) require.Equal(t, resp.StatusCode(), http.StatusNotFound) }) t.Run("status not 200 but 502", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) // define behavior gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusBadGateway) resp, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, IsStatusNotOK(err)) require.Equal(t, resp.StatusCode(), http.StatusBadGateway) }) t.Run("context timeout", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) // define behavior gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusOK).Delay(time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Second/2) defer cancel() _, err := r.Get(ctx, constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, restyclient.IsTimeout(err)) }) t.Run("client timeout", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) c.GetClient().Timeout = time.Second / 2 // intercept http client to gock gock.InterceptClient(c.GetClient()) // define behavior gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusOK).Delay(time.Second) _, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, restyclient.IsTimeout(err)) }) t.Run("network close", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusOK).SetError(net.ErrClosed) _, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, errors.Is(err, net.ErrClosed)) }) t.Run("EOF", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusOK).SetError(io.EOF) _, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.True(t, errors.Is(err, io.EOF)) }) t.Run("path err", func(t *testing.T) { host := "://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) // intercept http client to gock gock.InterceptClient(c.GetClient()) gock.New(host).Get(constant.UCQueryV1LoginHistory).Reply(http.StatusOK) _, err := r.Get(context.Background(), constant.UCQueryV1LoginHistory) require.NotNil(t, err) require.IsType(t, &url.Error{}, errors.Cause(err)) }) t.Run("context with requestID", func(t *testing.T) { host := "http://service-ucquery" defer gock.Off() // get resty client c := restyclient.GetRestyClient() // new requester r := New(c, host) requestID := uuid.NewV4().String() // intercept http client to gock gock.InterceptClient(c.GetClient()) gock.New(host).Get(constant.UCQueryV1LoginHistory).MatchHeader(httpconstant.HeaderXRequestID, requestID).Reply(http.StatusOK) ctx := context.Background() ctx = contexthelper.Store(ctx, log.RequestIdKey, requestID) _, err := r.Get(ctx, constant.UCQueryV1LoginHistory) require.Nil(t, err) }) }

6. Interface 接口模拟(Mock)

testify 可以说是最流行的 Go 语言测试库了。 testify 提供了很多方便的函数帮助我们做 assert 和错误信息输出。同时对 mock 也支持的很好; Mock 简单来说就是构造一个仿对象,仿对象提供和原对象一样的接口,在测试中用仿对象来替换原对象。 mockery 提供了使用 stretr/testify/mock 包轻松生成 Golang 接口模拟的能力。 安装 go install github.com/vektra/mockery 示例代码:

  1. 定于接口,同时声明 mock 对象生成规则:支持 expect 语法,检索 Requester 接口生成对应的 mock 对象于mocks/requestr.go 中
go
//go:generate mockery --name Requester --filename requester.go --with-expecter type Requester interface { Get(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) Post(ctx context.Context, path string, ops ...Ops) (*resty.Response, error) }
  1. 使用 go generate 生成 mock 对象

go generate ./...

go
package mocks // Requester is an autogenerated mock type for the Requester type type Requester struct { mock.Mock } func (_m *Requester) EXPECT() *Requester_Expecter { return &Requester_Expecter{mock: &_m.Mock} } func (_e *Requester_Expecter) Get(ctx interface{}, path interface{}, ops ...interface{}) *Requester_Get_Call { return &Requester_Get_Call{Call: _e.mock.On("Get",append([]interface{}{ctx, path}, ops...)...)} } func (_e *Requester_Expecter) Post(ctx interface{}, path interface{}, ops ...interface{}) *Requester_Post_Call { return &Requester_Post_Call{Call: _e.mock.On("Post",append([]interface{}{ctx, path}, ops...)...)} } //...
  1. 这里以 UCQuery 服务的交互 Client 为例,其依赖 requester 接口
go
package ucquery //go:generate mockery --name Client --filename ucquery.go --with-expecter type Client interface { SearchUserInfo(ctx context.Context, req *SearchUserInfoReqParams, resp *SearchUserInfoRespData) error SearchUserLastLoginTime(ctx context.Context, req *SearchUsersLastLoginTimeReqParams, resp *SearchUsersLastLoginTimeRespData) error } type client struct { requester requester.Requester config    *viper.Viper } func (p client) SearchUserInfo(ctx context.Context, req *SearchUserInfoReqParams, resp *SearchUserInfoRespData) error { queryString, err := qs.Marshal(req) if err != nil { return errors.Wrapf(err, "marshal query param to str failed,params: %v", req) } body := &mapping.BaseResp{Result: resp} _, err = p.requester.Get(ctx, constant.UCQueryV1AccountInfo, requester.WithQueryString(queryString), requester.WithResult(body)) if err != nil { return err } return nil } func New(r requester.Requester, config *viper.Viper) Client { return &client{requester: r, config: config} }
  1. 编写测试代码,注入 requester 的 mock 对象
go
package ucquery func TestClient_SearchUserInfo(t *testing.T) { t.Run("match", func(t *testing.T) { ctx := context.Background() // mock requester r := requester.NewRequester(t) r.EXPECT().Get(ctx, constant.UCQueryV1AccountInfo, mock.Anything, mock.Anything).Return(nil, nil) // params //searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format(time.DateTime)} searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format("2006-01-02 15:04:05")} searchReq := &SearchUserInfoReqParams{SubAccountID: "001"} // pass mock request to new client ucqueryClient := New(r, viper.Sub("dependence")) err := ucqueryClient.SearchUserInfo(ctx, searchReq, searchResp) require.Nil(t, err) }) t.Run("timeout", func(t *testing.T) { ctx := context.Background() // mock requester r := requester.NewRequester(t) r.EXPECT().Get(ctx, constant.UCQueryV1AccountInfo, mock.Anything, mock.Anything).Return(nil,errors.Wrap(context.DeadlineExceeded, "request timeout")) // params //searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format(time.DateTime)} searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format("2006-01-02 15:04:05")} searchReq := &SearchUserInfoReqParams{SubAccountID: "001"} // pass mock request to new client ucqueryClient := New(r, viper.Sub("dependence")) err := ucqueryClient.SearchUserInfo(ctx, searchReq, searchResp) require.NotNil(t, err) require.True(t, restyclient.IsTimeout(err)) }) t.Run("qs marshal failed", func(t *testing.T) { ctx := context.Background() // mock requester r := requester.NewRequester(t) // params //searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format(time.DateTime)} searchResp := &SearchUserInfoRespData{LastLoginTime: time.Now().Format("2006-01-02 15:04:05")} // pass mock request to new client ucqueryClient := New(r, viper.Sub("dependence")) err := ucqueryClient.SearchUserInfo(ctx, nil, searchResp) require.NotNil(t, err) }) }

7. 函数 patch 示例

有些时候我们需要对非接口进行模拟,改变默认行为,譬如全局对象,全局函数,未定义接口的对象等,这个时候可以考虑使用 gomonkey 安装 go get github.com/agiledragon/gomonkey/v2 这里以 service 层某个实现函数依赖 clientinfoupdater.Updater 为例

type Updater struct { dbAccess        access.Accessor  // 数据库交互 ucqueryClient   ucquery.Client   // UC 请求客户端 productProvider product.Provider // 产品信息查询聚合实体 changeFlowBeforeUpdateContent map[string]interface{} // 记录变更内容 changeFlowAfterUpdateContent  map[string]interface{} // 记录变更内容 } func NewUpdater(dbAccess access.Accessor, ucqueryClient ucquery.Client, productProvider product.Provider) *Updater { return &Updater{dbAccess: dbAccess, ucqueryClient: ucqueryClient, productProvider: productProvider, changeFlowBeforeUpdateContent: map[string]interface{}{}, changeFlowAfterUpdateContent: map[string]interface{}{}} }

实际的service 业务代码

func (s *service) ClientInfoUpdate(ctx context.Context, req *mapping.ClientInfoUpdateReq) error { updater := clientinfoupdater.NewUpdater(s.dbAccess, s.ucqueryClient, s.productProvider) // 根据请求内容更新用户基本信息 if err := updater.UpdateClientInfoByReq(ctx, req); err != nil { return errors.WithMessagef(err, "update client info failed") } // 根据请求内容更新标签列表 if err := updater.UpdateClientLabelsByReq(ctx, req); err != nil { return errors.WithMessagef(err, "update client labels failed") } // 根据请求内容更组合及新持仓情况,同步调仓 if err := updater.UpdateClientPortfolioInfoByReq(ctx, req); err != nil { return errors.WithMessagef(err, "update client portfolio info failed") } // 补充 change flow if err := updater.AddChangeFlow(ctx, req); err != nil { return errors.WithMessagef(err, "add client portfolio info update change flow failed") } return nil } func New(db *gorm.DB, client *resty.Client, config *viper.Viper, ucqueryClient ucquery.Client) Service { return &service{dbAccess: access.New(db), config: config, ucqueryClient: ucqueryClient, productProvider: product.NewProvider(config, client, db)} }

测试代码示例:

func TestService_ClientInfoUpdate(t *testing.T) { t.Run("update success", func(t *testing.T) { updater := &clientinfo.Updater{}        // 使用 gomonkey 对 updater 的若干函数进行 patch,定义函数返回结果均为 nik patches := gomonkey. ApplyMethodReturn(updater, "UpdateClientInfoByReq", nil). ApplyMethodReturn(updater, "UpdateClientLabelsByReq", nil). ApplyMethodReturn(updater, "UpdateClientPortfolioInfoByReq", nil). ApplyMethodReturn(updater, "AddChangeFlow", nil)        // 使用 gomonkey 对 clientinfo.NewUpdater 全局函数进行打桩,声明其返回对象为 patch 之后的 updater patches.ApplyFuncReturn(clientinfo.NewUpdater, updater) defer patches.Reset()        // 正常创建 service,无需注入 ser := New(nil, nil, nil, nil) err := ser.ClientInfoUpdate(context.Background(), &mapping.ClientInfoUpdateReq{ Params: mapping.ClientInfoUpdateReqParams{ SubAccountId: "001", ClientInfo: mapping.ClientInfoReqParams{ ClientType: 1, InvestExp:  1, Brief:      stringrand.String(100), }, }, }) require.Nil(t, err) }) t.Run("UpdateClientInfoByReq failed on build db query conditions failed", func(t *testing.T) { updater := &clientinfo.Updater{} innerErr := errors.Errorf("build db query conditions by request params failed") updateClientInfoByReqReturnErr := codeerror.New(code.ErrInvalidParams, innerErr) patches := gomonkey. ApplyMethodReturn(updater, "UpdateClientInfoByReq", updateClientInfoByReqReturnErr). ApplyMethodReturn(updater, "UpdateClientLabelsByReq", nil). ApplyMethodReturn(updater, "UpdateClientPortfolioInfoByReq", nil). ApplyMethodReturn(updater, "AddChangeFlow", nil) patches.ApplyFuncReturn(clientinfo.NewUpdater, updater) defer patches.Reset() ser := New(nil, nil, nil, nil) err := ser.ClientInfoUpdate(context.Background(), &mapping.ClientInfoUpdateReq{ Params: mapping.ClientInfoUpdateReqParams{ SubAccountId: "001", ClientInfo: mapping.ClientInfoReqParams{ ClientType: 1, InvestExp:  1, Brief:      stringrand.String(100), }, }, }) require.NotNil(t, err) //require.True(t, errors.Is(err, innerErr)) require.ErrorIs(t, err, innerErr) require.Contains(t, err.Error(), "update client info failed") }) }

使用 gomonkey 需要注意执行测试时附带 -gcflags=all=-l go test -gcflags=all=-l

9. controller 层(业务接口)测试示例

controller 代码示例:

func (c controller) ClientInfoUpdate(ctx *fiber.Ctx) error { err := c.srv.ClientInfoUpdate(ctx.UserContext(), ctx.Locals(requestparams.Key).(*mapping.ClientInfoUpdateReq)) if err != nil { var serviceCodeError codeerror.CodeError if errors.As(err, &serviceCodeError) { return handleServiceCodeErr(ctx, serviceCodeError) } log.FromContext(ctx.UserContext()).Error("control handle err", zap.NamedError(log.ErrorDetailKey, err)) return code.Response(ctx, code.NewDefaultError(code.ErrUnknow), nil) } return code.OK(ctx, nil) } func New(db *gorm.DB, client *resty.Client, config *viper.Viper, ucqueryClient ucquery.Client) Controller { return &controller{srv: service.New(db, client, config, ucqueryClient)} }

测试示例

package controller func TestController_ClientInfoUpdate(t *testing.T) { t.Run("update failed not with middleware", func(t *testing.T) { t.Run("service update client info failed", func(t *testing.T) { t.Run("database err", func(t *testing.T) { // mock service serviceImpl := servicemocks.NewService(t) serviceImpl.On("ClientInfoUpdate", mock.Anything, mock.Anything).After(time.Second).Return( codeerror.New(code.ErrDatabase, errors.Wrap(gorm.ErrInvalidValue, "update client info failed"))) // stub service.New patches := gomonkey.ApplyFuncReturn(service.New, serviceImpl) defer patches.Reset() // controller controllerImpl := New(nil, nil, nil, nil) // fiber fiberApp := fiber.New() fiberCtx := fiberApp.AcquireCtx(&fasthttp.RequestCtx{}) defer fiberApp.ReleaseCtx(fiberCtx) fiberCtx.Locals(requestparams.Key, &mapping.ClientInfoUpdateReq{Params: mapping.ClientInfoUpdateReqParams{SubAccountId: "001"}}) require.Nil(t, controllerImpl.ClientInfoUpdate(fiberCtx)) require.Equal(t, fiberCtx.GetRespHeader(constant.HeaderRetCode), fmt.Sprint(code.ErrDatabase)) }) t.Run("update client info failed with mock service updater", func(t *testing.T) { // mock service dependents updater := &clientinfoupdater.Updater{} innerErr := errors.Errorf("build db query conditions by request params failed") updateClientInfoByReqReturnErr := codeerror.New(code.ErrInvalidParams, innerErr) patches := gomonkey. ApplyMethodReturn(updater, "UpdateClientInfoByReq", updateClientInfoByReqReturnErr). ApplyMethodReturn(updater, "UpdateClientLabelsByReq", nil). ApplyMethodReturn(updater, "UpdateClientPortfolioInfoByReq", nil). ApplyMethodReturn(updater, "AddChangeFlow", nil) patches.ApplyFuncReturn(clientinfoupdater.NewUpdater, updater) defer patches.Reset() // controller controllerImpl := New(nil, nil, nil, nil) // fiber fiberApp := fiber.New() fiberCtx := fiberApp.AcquireCtx(&fasthttp.RequestCtx{}) defer fiberApp.ReleaseCtx(fiberCtx) fiberCtx.Locals(requestparams.Key, &mapping.ClientInfoUpdateReq{Params: mapping.ClientInfoUpdateReqParams{SubAccountId: "001"}}) require.Nil(t, controllerImpl.ClientInfoUpdate(fiberCtx)) require.Equal(t, fiberCtx.GetRespHeader(constant.HeaderRetCode), fmt.Sprint(code.ErrInvalidParams)) }) }) }) t.Run("update with middleware", func(t *testing.T) { t.Run("not request id set in header", func(t *testing.T) { // controller controllerImpl := New(nil, nil, nil, nil) // fiber fiberApp := fiber.New() // register middleware fiberApp.Use(recover.New(), requestid.New(), trace.Trace()) // register route fiberApp.Group("/followInvestAdmin/:version/"). Post("/ClientInfoUpdate", m.RequestParams((*mapping.ClientInfoUpdateReq)(nil)), m.BodyParser(), m.Validator(), controllerImpl.ClientInfoUpdate) requestID := uuid.NewV4().String() rawReqBody := `{ "jsonrpc": "2.0", "params": { "subAccountId": "001", "clientInfo": { "tags": [1,3] } }, "id": "%v" }` rawReqBody = fmt.Sprintf(rawReqBody, requestID) req := httptest.NewRequest(http.MethodPost, "/followInvestAdmin/v1/ClientInfoUpdate", strings.NewReader(rawReqBody)) resp, err := fiberApp.Test(req, -1) require.Nil(t, err) require.NotNil(t, resp) require.Equal(t, resp.StatusCode, http.StatusOK) require.Equal(t, resp.Header.Get(constant.HeaderRetCode), fmt.Sprint(code.ErrEmptyReqId)) }) t.Run("request data validate failed: not subAccountID", func(t *testing.T) { // controller controllerImpl := New(nil, nil, nil, nil) // fiber fiberApp := fiber.New(fiber.Config{ErrorHandler: code.DefaultErrorHandler}) // register middleware fiberApp.Use(recover.New(), requestid.New(), trace.Trace()) // register route fiberApp.Group("/followInvestAdmin/:version/"). Post("/ClientInfoUpdate", m.RequestParams((*mapping.ClientInfoUpdateReq)(nil)), m.BodyParser(), m.Validator(), controllerImpl.ClientInfoUpdate) requestID := uuid.NewV4().String() rawReqBody := `{ "jsonrpc": "2.0", "params": {}, "id": "%v" }` rawReqBody = fmt.Sprintf(rawReqBody, requestID) req := httptest.NewRequest(http.MethodPost, "/followInvestAdmin/v1/ClientInfoUpdate", strings.NewReader(rawReqBody)) req.Header.Set(httpconstant.HeaderXRequestID, requestID) req.Header.Set(httpconstant.HeaderContentType, httpconstant.MIMEApplicationJSON) resp, err := fiberApp.Test(req, -1) require.Nil(t, err) require.NotNil(t, resp) require.Equal(t, resp.StatusCode, http.StatusOK) require.Equal(t, resp.Header.Get(constant.HeaderRetCode), fmt.Sprint(code.ErrRequest)) }) t.Run("success", func(t *testing.T) { // mock service serviceImpl := servicemocks.NewService(t) serviceImpl.On("ClientInfoUpdate", mock.Anything, mock.Anything).After(time.Second).Return(nil) // stub service.New patches := gomonkey.ApplyFuncReturn(service.New, serviceImpl) defer patches.Reset() // controller controllerImpl := New(nil, nil, nil, nil) // fiber fiberApp := fiber.New() // register middleware fiberApp.Use(recover.New(), requestid.New(), trace.Trace()) // register route fiberApp.Group("/followInvestAdmin/:version/"). Post("/ClientInfoUpdate", m.RequestParams((*mapping.ClientInfoUpdateReq)(nil)), m.BodyParser(), m.Validator(), controllerImpl.ClientInfoUpdate) requestID := uuid.NewV4().String() rawReqBody := `{ "jsonrpc": "2.0", "params": { "subAccountId": "001", "clientInfo": { "tags": [1,3] } }, "id": "%v" }` rawReqBody = fmt.Sprintf(rawReqBody, requestID) req := httptest.NewRequest(http.MethodPost, "/followInvestAdmin/v1/ClientInfoUpdate", strings.NewReader(rawReqBody)) req.Header.Set(httpconstant.HeaderXRequestID, requestID) req.Header.Set(httpconstant.HeaderContentType, httpconstant.MIMEApplicationJSON) resp, err := fiberApp.Test(req, -1) require.Nil(t, err) require.NotNil(t, resp) require.Equal(t, resp.StatusCode, http.StatusOK) require.Equal(t, resp.Header.Get(constant.HeaderRetCode), "0") }) }) }

10 测试覆盖率

测试覆盖率是指代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。 Go 提供内置功能来检查你的代码覆盖率。我们可以使用 go test -cover 来查看测试覆盖率。 Go 还提供了一个额外的 -coverprofile 参数,用来将覆盖率相关的记录信息输出到一个文件。 go test -cover -coverprofile=c.out 然后我们执行 go tool cover -html=c.out ,使用 cover 工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个 HTML 报告。

10 业务接口测试覆盖率

由于业务接口统一放在了各自服务模块的 controller 包下,故如果只需要获取业务接口的测试覆盖率可以在项目目录执行命令如下: go test -v -gcflags=all=-l -cover \go list ./... | grep 'controller'`如果是测试整个项目的覆盖率可以执行如下命令:go test -v -gcflags=all=-l -cover `go list ./... | grep -v 'mocks'``

来自: Wiki-11. 单元测试规范-自研项目-TAPD平台

本文作者:JIeJaitt

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!