Go工程化--项目测试

1. 前言

很多人都提到过测试的重要性,而在所有的测试类型当中,以单元测试为代表的单元测试无疑是成本最小,性价比最高的一种,而且有的公司为了保证质量会要求单元测试覆盖率的指标

那么对于Go程序而言,项目结构上进行单元测试的编写,如何可以做到又快又好?

2. 单元测试简明教程

2.1 go test

2.1.1一个简单的 ????

项目结构

.
├── max.go
└── max_test.go

max.go

package max

// Int get the max
func Int(a, b int) int {
        if a > b {
                return a
        }
        return b
}

max_test.go

package max

import "testing"

func TestInt(t *testing.T) {
        if got := Int(1, 2); got != 2 {
                t.Errorf("exp: %d, got: %d", 2, got)
        }
}

执行结果

▶ go test
PASS
ok      code/max        0.006s

2.1.2 单元测试文件说明

  • 文件名必须是_test.go结尾的,这样在执行go test的时候才会执行到相应的代码。
  • 必须import testing这个包。
  • 所有的测试用例函数必须是Test开头。
  • 测试用例会按照源代码中写的顺序依次执行。
  • 测试函数TestX()的参数是testing.T,我们可以使用该类型来记录错误或者是测试状态。
  • 测试函数格式:func TestXxx (t *testing.T),Xxx部分可以为任意的字母数字的组合,但是首字母不能是小写字母[a-z],例如Testintdiv是错误的函数名。
  • 函数中通过调用testing.TError, Errorf, FailNow, Fatal, FatalIf方法,说明测试不通过,调用Log方法用来记录测试的信息。

2.1.3 表驱动测试

在实际编写单元测试的时候,我们往往需要执行多个测试用例,期望达到更全面的覆盖效果,这时候就需要使用表驱动测试了。

func TestInt_Table(t *testing.T) {
        
        // 构造一个结构体列表
        tests := []struct {
                name string
                a    int
                b    int
                want int
        }{
                {name: "a>b", a: 10, b: 2, want: 10},
                {name: "a<b", a: 1, b: 2, want: 2},
        }
    
    // 遍历一个结构体列表,执行多次此时
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        if got := Int(tt.a, tt.b); got != tt.want {
                                t.Errorf("exp: %d, got: %d", tt.want, got)
                        }
                })
        }
}

执行结果

▶ go test -v
=== RUN   TestInt
--- PASS: TestInt (0.00s)
=== RUN   TestInt_Table
=== RUN   TestInt_Table/a>b
=== RUN   TestInt_Table/a<b
--- PASS: TestInt_Table (0.00s)
    --- PASS: TestInt_Table/a>b (0.00s)
    --- PASS: TestInt_Table/a<b (0.00s)
PASS

2.1.4 随机执行

上面的例子是按照顺序执行的,单元测试大多随机执行更能够发现一些没有注意到的错误, 如下面的这个例子,利用map的特性我们很容易将上面这个例子改造为随机执行的单元测试

func TestInt_RandTable(t *testing.T) {
        // 初始化map并赋值
        tests := map[string]struct {
                a    int
                b    int
                want int
        }{
                "a>b": {a: 10, b: 2, want: 10},
                "a<b": {a: 1, b: 2, want: 2},
        }
    
    // 利用map的无序性    
        for name, tt := range tests {
                t.Run(name, func(t *testing.T) {
                        if got := Int(tt.a, tt.b); got != tt.want {
                                t.Errorf("exp: %d, got: %d", tt.want, got)
                        }
                })
        }
}

2.2 testfiy

标准库为我们提供了一个还不错的测试框架,但是没有提供断言的功能,testify包含了 断言、mock、suite 三个功能,mock 推荐使用官方的 gomock

testify/assert 提供了非常多的方法,这里为大家介绍最为常用的一些,所有的方法可以访问 https://godoc.org/github.com/stretchr/testify/assert 查看


// 判断两个值是否相等
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 判断两个值不相等
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 测试失败,测试中断
func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool
// 判断值是否为nil,常用于 error 的判断
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 判断值是否不为nil,常用于 error 的判断
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

断言方法都会返回一个 bool 值,我们可以通过这个返回值判断断言成功/失败,从而做一些处理

2.2.1 一个????

max.go

package max

// Int get the max
func Int(a, b int) int {
        if a > b {
                return a
        }
        return b
}

max_test.go

func TestInt_assert_fail(t *testing.T) {
        
        got := Int(1, 2)
        assert.Equal(t, 1, got)
}

执行结果, 可以看到输出十分的清晰

=== RUN   TestInt_assert_fail
--- FAIL: TestInt_assert_fail (0.00s)
    max_test.go:62:
                Error Trace:    max_test.go:62
                Error:          Not equal:
                                expected: 1
                                actual  : 2
                Test:           TestInt_assert_fail
FAIL
FAIL    code/max        0.017s

2.3 gomock

2.3.1安装

注意: 请在非项目文件夹执行下面这条命令

GO111MODULE=on GOPROXY=https://goproxy.cn go get github.com/golang/mock/mockgen

mockgen 是一个代码生成工具,可以对包或者源代码文件生成指定接口的 Mock 代码

2.3.2 生成 Mock 代码

指定源文件

mockgen -source=./.go  -destination=./a_mock.go  INeedMockInterface

mockgen -source=源文件路径  -destination=写入文件的路径(没有这个参数输出到终端) 需要mock的接口名(多个接口逗号间隔)

指定包路径

mockgen  -destination=写入文件的路径(没有这个参数输出到终端) 包路径 需要mock的接口名(多个接口逗号间隔)

2.3.3 一个简单的 gomock ????

// UserAge 获取用户年龄
type UserAge interface {
        GetAge(user string) int
}

// Simple 一个简单的例子
func Simple(user string, age UserAge) string {
        return fmt.Sprintf("%s age is: %d", user, age.GetAge(user))
}

func TestSimple(t *testing.T) {
        
        // 新建一个mock对象
        ctrl := gomock.NewController(t)
        age := mock_mock.NewMockUserAge(ctrl)

    // mock 返回值
        age.EXPECT().GetAge(gomock.Any()).Return(1).AnyTimes()

        assert.Equal(t, "a age is: 1", Simple("a", age))
}

3. 项目单元测试

项目中说的是单元测试,其实很多不是单元测试,像 repo 层,如果涉及到数据库后面就会讲到我们一般会启动一个真实的数据库来测试,这其实已经算是 集成测试了,但是它仍然是轻量级的

3.1 service

这一层主要处理的网络层数据之间的相互转换,本身是不含什么业务逻辑的,目前使用的是

  • request请求,测试一般会使用 httptest 来模拟实际请求的测试。
  • 然后在对 usecase 层的调用上,使用 gomock mock 掉相关的接口,简化我们的测试。
  • 如果你不想写的那么麻烦,也可以不用启用 httptest 来测试,直接测试 service 层的代码也是可以的,不过这样的话,service 层的代码测试的内容就没有多少了,也就是看转换数据的时候符不符合预期。

这一层,主要完成的测试是

  • 参数: 校验是否符合预期
  • 数据的转换 是否符合预期,如果使用了类似 copier 的工具的话一定要写这部分的单元测试,不然还是很容易出错,容易字段名不一致导致 copier 的工作不正常

当然如果时间有限的话,这一层的测试也不是必须的,因为接入层相对来说变化也比较快一点,这是说写了单元测试,基本上在测试阶段很少会出现由于参数的问题提交过来的 bug

3.1 一个????

首先是 service 层的代码,可以看到逻辑很简单,就是调用了一下,usecase 层的接口

var _ v1.BlogServiceHTTPServer = &PostService{}

// PostService PostService
type PostService struct {
        Usecase domain.IPostUsecase
}

// CreateArticle 创建文章
func (p *PostService) CreateArticle(ctx context.Context, req *v1.Article) (*v1.Article, error) {
        article, err := p.Usecase.CreateArticle(ctx, domain.Article{
                Title:    req.Title,
                Content:  req.Content,
                AuthorID: req.AuthorId,
        })

        if err != nil {
                return nil, err
        }

        var resp v1.Article
        err = copier.Copy(&resp, &article)
        return &resp, err
}

再看看单元测试

首先是初始化,之前我们讲到初始化的时候我们一般在 cmd 当中使用 wire 自动生成,但是在单元测试中 wire 并不好用,并且由于单元测试的时候我们的依赖项其实没有真实的依赖项那么复杂我们只需要关心当前这一层的依赖即可,所以一般在单元测试的时候我都是手写初始化

type testPostService struct {
        post    *PostService
        usecase *mock_domain.MockIPostUsecase
        handler *gin.Engine
}

// 初始化
func initPostService(t *testing.T) *testPostService {
        
        // gomock mock新的controller
        ctrl := gomock.NewController(t)
        
        // mock一个单元测试
        usecase := mock_domain.NewMockIPostUsecase(ctrl)
        
        // 
        service := &PostService{Usecase: usecase}

        handler := gin.New()
        v1.RegisterBlogServiceHTTPServer(handler, service)

        return &testPostService{
                post:    service,
                usecase: usecase,
                handler: handler,
        }
}

实际的测试,这一块主要是为了展示一个完整的单元测试所以贴的代码稍微长了一些,后面的两层具体的单元测试代码都大同小异,我就不再贴了,主要的思路就是把依赖的接口都用 gomock mock 掉,这样实际写单元测试代码的时候就会比较简单。


func TestPostService_CreateArticle(t *testing.T) {
        // mock 一个service
        s := initPostService(t)
        
        // 测试判断
        s.usecase.EXPECT().
                CreateArticle(gomock.Any(), gomock.Eq(domain.Article{Title: "err", AuthorID: 1})).
                Return(domain.Article{}, fmt.Errorf("err"))
                
        s.usecase.EXPECT().
                CreateArticle(gomock.Any(), gomock.Eq(domain.Article{Title: "success", AuthorID: 2})).
                Return(domain.Article{Title: "success"}, nil)
    
    // 表驱动测试
        tests := []struct {
                name       string
                params     *v1.Article
                want       *v1.Article
                wantStatus int
                wantCode   int
                wantErr    string
        }{
                {
                        name: "参数错误 author_id 必须",
                        params: &v1.Article{
                                Title:    "1",
                                Content:  "2",
                                AuthorId: 0,
                        },
                        want:       nil,
                        wantStatus: 400,
                        wantCode:   400,
                },
                {
                        name: "失败",
                        params: &v1.Article{
                                Title:    "err",
                                AuthorId: 1,
                        },
                        want:       nil,
                        wantStatus: 500,
                        wantCode:   -1,
                },
                {
                        name: "成功",
                        params: &v1.Article{
                                Title:    "success",
                                AuthorId: 2,
                        },
                        want: &v1.Article{
                                Title: "success",
                        },
                        wantStatus: 200,
                        wantCode:   0,
                },
        }
        
        
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        // 下面这些一般都会封装在一起,这里是为了演示

                        // 初始化请求
                        b, err := json.Marshal(tt.params)
                        require.NoError(t, err)
                        uri := fmt.Sprintf("/v1/author/%d/articles", tt.params.AuthorId)
                        req := httptest.NewRequest(http.MethodPost, uri, bytes.NewReader(b))

                        // 初始化响应
                        w := httptest.NewRecorder()

                        // 调用相应的handler接口
                        s.handler.ServeHTTP(w, req)

                        // 提取响应
                        resp := w.Result()
                        defer resp.Body.Close()
                        require.Equal(t, tt.wantStatus, resp.StatusCode)

                        // 读取响应body
                        respBody, _ := ioutil.ReadAll(resp.Body)
                        r := struct {
                                Code int         `json:"code"`
                                Msg  string      `json:"msg"`
                                Data *v1.Article `json:"data"`
                        }{}
                        require.NoError(t, json.Unmarshal(respBody, &r))

                        assert.Equal(t, tt.wantCode, r.Code)
                        assert.Equal(t, tt.want, r.Data)
                })
        }
}

3.2 usecase

usecase 是主要的业务逻辑,所以一般写单元测试的时候都应该先写这一层的单远测试,而且这一层我们没有任何依赖,只需要把 repo 层的接口直接 mock 掉就可以了,是非常纯净的一层,其实也就这一层的单元测试才是真正的单元测试

3.3 repo

repo 层我们一般依赖 mysql 或者是 redis 等数据库,在测试的时候我们可以直接启动一个全新的数据库用于测试即可。

3.4 本地

直接使用 docker run 对应的数据库就可以了

3.5 ci/cd

gitlab 有一个比较好用的功能是指定 service,只需要指定对应的数据库镜像我们就可以在测试容器启动的时候自动启动对应的测试数据库容器,并且每一次都是全新的空数据库。我们只需要每次跑单元测试的时候先跑一下数据库的 migration 就可以了。

下面给出一个配置示例

test:
  stage: test
  image: golang:1.15-alpine-test
  services:
    - redis:v4.0.11
    - postgres:10-alpine
    - docker:19-dind
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: root
    POSTGRES_PASSWORD: 1234567
    GOPROXY: "这里设置 proxy 地址"
    CGO_ENABLED: 0
  script:
    - go mod download
    - go run db/*.go
    - mkdir artifacts
    - gotestsum -- -p 1 -v -coverprofile=./artifacts/coverage.out -coverpkg=./... ./...
    # 单元测试统计去除一些不需要测试的代码
    - |
      cat ./artifacts/coverage.out | \
      grep -v "/mock/" | grep -v "/db/" |  grep -v "pb.go" > ./artifacts/coverage.out2
    - go tool cover -func=./artifacts/coverage.out2
  # 捕获单元测试覆盖率在 gitlab job 上显示
  coverage: '/total:\s+.*\s+\d+\.\d+%/'
  artifacts:
    paths:
      - artifacts/coverage.out

4. 延伸阅读

4.1 Repo 与DAO 层区别

repo(Repository)层更侧重数据的管理,可以看做是仓库的管理员,数据放在仓库中。当Service层需要数据时,只需要从Repo层获取即可,Repo层屏蔽了数据的获取方式。

而Dao(Data access Object)层则更底层,单纯的从数据库中获取数据,因此,往往是Service层调用Repo层,Repo层再调用Dao层。

Repo层的存在使得数据的获取可以变得多种多样,虽然大部分情况下还是从Dao层获取数据,但有时也可以从rpc中获取数据,或是本地、内存等。

Repository是相对对象而言,而DAO是相对数据库而言,只要我们还是使用关系数据库保存对象,也可能这两者都同时存在,因为侧重点不一样,但是可以肯定的是,业务层应该直接和Repository打交道,而不是DAO.

4.2 model层

model层即数据库实体层,也被称为entity层,pojo层。

一般数据库一张表对应一个实体类,类属性同表字段一一对应。

4.3 dao层

dao层即数据持久层,也被称为mapper层。

dao层的作用为访问数据库,向数据库发送sql语句,完成数据的增删改查任务。

dao 层主要做数据持久层的工作,负责与数据库进行联络的一些任务都封装在此,dao层的设计首先是设计dao层的接口,然后在Spring的配置文件中定义此接口的实现类,然后就可以再模块中调用此接口来进行数据业务的处理,而不用关心此接口的具体实现类是哪个类,显得结构非常清晰,dao层的数据源配置,以及有关数据库连接参数都在Spring配置文件中进行配置。

4.4 service层

service层即业务逻辑层。

service层的作用为完成功能设计。

service,往往是Service层调用Repo层,Repo层再调用Dao层。

service 层主要负责业务模块的应用逻辑应用设计。同样是首先设计接口,再设计其实现类,接着在配置文件中配置其实现的关联。这样就可以在应用中调用service接口来进行业务处理。封装service层业务逻辑有利于通用的业务逻辑的独立性和重复利用性。程序显得非常简洁。

4.5 controller层

controller层即控制层。

controller层的功能为请求和响应控制。

controller层负责前后端交互,接受前端请求,调用service层,接收service层返回的数据,最后返回具体的页面和数据到客户端。

controller 层负责具体的业务模块流程的控制,在此层要调用service层的接口来控制业务流程,控制的配置也同样是在Spring的配置文件里进行,针对具体的业务流程,会有不同的控制器。我们具体的设计过程可以将流程进行抽象归纳,设计出可以重复利用的子单元流程模块。这样不仅使程序结构变得清晰,也大大减少了代码量。

4.6 view层

view 层与控制层结合比较紧密,需要二者结合起来协同开发。view层主要负责前台jsp页面的显示。

4.7 之间的关系

Service层是建立repo上,repo层又 在DAO层之上的,而Service层又是在Controller层之下的,因而Service层应该既调用Repo层的接口,又要提供接口给Controller层的类来进行调用,它刚好处于一个中间层的位置。每个模型都有一个Service接口,每个接口分别封装各自的业务处理方法。

5.参考

  1. go工程化
  2. https://github.com/stretchr/testify
  3. https://github.com/golang/mock
  4. https://pkg.go.dev/github.com/jinzhu/copier
  5. https://juejin.cn/post/6854573216002736141