Go: 基于 Ginkgo 框架进行单元测试实践

这是在做 Golang 项目中的一些实践

ginkgo 基于BDD的测试框架; 开始前, 需要花半个小时阅读官方文档

reference

table-driven

一个函数

// TruncateString truncate string to specific length
func TruncateString(s string, n int) string {
	if n > len(s) {
		return s
	}
	return s[:n]
}

使用table-driven方式生成测试代码

func TestTruncateString(t *testing.T) {
	type args struct {
		s string
		n int
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := util.TruncateString(tt.args.s, tt.args.n); got != tt.want {
				t.Errorf("TruncateString() = %v, want %v", got, tt.want)
			}
		})
	}
}

如果是简单的util函数, 用例比较简单并且用例数量较少, 此时单元测试可读性和可维护性没有问题

table-driven的问题

当涉及业务相关的单元测试, 此时

  1. 用例复杂, 输入可能是多层嵌套的struct, 某一层的某个变量的值影响输出
  2. 用例数量多, 某个函数可能有5个以上的用例

由于table-driven的表达能力有限:

  1. 如何复用输入结构体? (当前情况开发会复制粘贴过去改)
  2. name过于简单不被重视, 导致单元测试失败时难以快速阅读

最终带来的问题是:

  1. 单个用例构造复杂, 可能是十几行甚至是几十行;
  2. 用例和用例之间没有复用, 基本基于复制粘贴;
  3. 难以区分用例之间的差异
  4. 用例过多导致难以维护, 不能明确知道每个用例的目的, 用例和用例之间的差别;
  5. 单个测试集过大, 可能有几百行测试代码;

难以阅读/难以理解/难以维护

Ginkgo解决什么问题?

优势:

  • 丰富的表达能力
  • 复用: 减少了大量重复代码, 复用
  • 让注意力更集中, 只关注每个用例的差异点
  • 减少了认知负担, 每个case有明确的描述/上下文/差异
  • 更好维护

注意, 实践中:

  • 使用 DescribeContext描述/表示行为和逻辑, 以及层次结构
  • 使用 It 描述及放置每一个用例
  • 利用BeforeEach构建每个用例(It)用到的公共内容(setup); 利用AfterEach执行回收动作(teardown). 此时BeforeEach用到闭包, 构建闭包变量/类型声明等, 在每个It中可以复用到
  • 使用testify 替换ginkgo的Matcher Using Other Matcher Libraries
      1. 减少目前迁移切换的工作量
      1. testify语法更加简洁直接, ginkgo matcher语法略啰嗦多敲好多字

请先阅读官方文档之后, 再开始阅读以下内容

1. 基于ginkgo的table-driven

使用场景: util类的简单测试, input和expect都比较简单

使用

package util_test

import (
	"fmt"
	"strconv"
	"testing"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/ginkgo/extensions/table"
	"github.com/stretchr/testify/assert"

	"example/pkg/util"
)

var _ = Describe("String", func() {

	Describe("TruncateBytes", func() {
		var s = []byte("helloworld")

		DescribeTable("TruncateBytes cases", func(expected []byte, truncatedSize int) {
			assert.Equal(GinkgoT(), expected, util.TruncateBytes(s, truncatedSize))
		},
			Entry("truncated size less than real size", []byte("he"), 2),
			Entry("truncated size equals to real size", s, 10),
			Entry("truncated size greater than real size", s, 20),
		)
	})
})

2. 基于闭包/BeforeEach/It的复用

使用场景: 复杂的输入

Extracting Common Setup: BeforeEach

简单复用

		Describe("WithoutResourceType", func() {
			var a types.Action
			BeforeEach(func() {
				a = types.NewAction()
			})

			It("true", func() {
				assert.True(GinkgoT(), a.WithoutResourceType())
			})

			It("true, empty ResourceType", func() {
				a.Attribute.SetResourceTypes([]types.ActionResourceType{})
				assert.True(GinkgoT(), a.WithoutResourceType())
			})

			It("false", func() {
				a.Attribute.SetResourceTypes([]types.ActionResourceType{
					{
						System: "test",
						Type:   "test",
					},
				})
				assert.False(GinkgoT(), a.WithoutResourceType())
			})

		}

复杂的复用

	Describe("GetPolicesAttrKeys", func() {
		var resource *types.Resource
		var policies []types.AuthPolicy
		BeforeEach(func() {
			resource = &types.Resource{
				System:    "test",
				Type:      "host",
				ID:        "1",
				Attribute: nil,
			}
			policies = []types.AuthPolicy{}
		})

		It("fail", func() {
			errExpr := `[{"system": "test", "type": "host", "expression":
{"OR": {"content": [{"NotExists": {"id": []}}]}}}]`
			policies = []types.AuthPolicy{
				{
					Expression: errExpr,
				},
			}
			_, err := GetPolicesAttrKeys(resource, policies)
			assert.Error(GinkgoT(), err)
		})
})

with patch

	Describe("FillSubjectDetail", func() {
		var r *request.Request
		var ctl *gomock.Controller
		var patches *gomonkey.Patches
		BeforeEach(func() {
			ctl = gomock.NewController(GinkgoT())
			r = request.NewRequest()
		})
		AfterEach(func() {
			ctl.Finish()
			if patches != nil {
				patches.Reset()
			}
		})

		It("pip.GetSubjectPK fail", func() {
			patches = gomonkey.ApplyFunc(pip.GetSubjectPK, func(_type, id string) (pk int64, err error) {
				return -1, errors.New("get subject_pk fail")
			})
			err := r.FillSubjectDetail()
			assert.Error(GinkgoT(), err)
			assert.Contains(GinkgoT(), err.Error(), "get subject_pk fail")

		})
})

ginkgo cli

ginkgo cli文档

bootstrap & generate

# 进入模块
$ cd pkg/util

# 生成一个模块的 {module_name}_suite_test.go
$ ginkgo bootstrap

# 生成模块下某个文件 {file_name}_test.go
$ ginkgo generate set

run

# 运行当前目录下的
$ ginkgo

# 带tags和gcflags (如果做了mock)
$ ginkgo -tags=jsoniter -gcflags=all=-l

# 递归执行当前目录及所有子目录下的
$ ginkgo -r -v -cover

# 只执行某几个目录
$ ginkgo  -r pkg/util pkg/cache

确认覆盖率 make test && make cov

# in Makefile
test:
	go test -mod=vendor -gcflags=all=-l $(shell go list ./... | grep -v mock | grep -v docs) -covermode=count -coverprofile .coverage.cov

cov:
	go tool cover -html=.coverage.cov

golang

1342 Words

2021-01-22 11:00 +0800