|
引入
随着工程化开发在司内大力的推广,单元测试越来越受到广大开发者的重视。在学习的过程中,发现网上针对 Golang 单元测试大多从理论角度出发介绍,缺乏完整的实例说明,晦涩难懂的 API 让初学接
触者难以下手。
本篇不准备大而全的谈论单元测试、笼统的介绍 Golang 的单测工具,而将从 Golang 单测的使用场景出发,以最简单且实际的例子讲解如何进行单测,最终由浅入深探讨 go 单元测试的两个比较细节的
问题。
在阅读本文时,请务必对 Golang 的单元测试有最基本的了解。
一段需要单测的 Golang 代码
package unit
import (
"encoding/json"
"errors"
"github.com/gomodule/redigo/redis"
"regexp"
)
type PersonDetail struct {
Username string `json:"username"`
Email string `json:"email"`
}
// 检查用户名是否非法
func checkUsername(username string) bool {
const pattern = `^[a-z0-9_-]{3,16}$`
reg := regexp.MustCompile(pattern)
return reg.MatchString(username)
}
// 检查用户邮箱是否非法
func checkEmail(email string) bool {
const pattern = `^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$`
reg := regexp.MustCompile(pattern)
return reg.MatchString(email)
}
// 通过 redis 拉取对应用户的资料信息
func getPersonDetailRedis(username string) (*PersonDetail, error) {
result := &PersonDetail{}
client, err := redis.Dial("tcp", ":6379")
defer client.Close()
data, err := redis.Bytes(client.Do("GET", username))
if err != nil {
return nil, err
}
err = json.Unmarshal(data, result)
if err != nil {
return nil, err
}
return result, nil
}
// 拉取用户资料信息并校验
func GetPersonDetail(username string) (*PersonDetail, error) {
// 检查用户名是否有效
if ok := checkUsername(username); !ok {
return nil, errors.New("invalid username")
}
// 从 redis 接口获取信息
detail, err := getPersonDetailRedis(username)
if err != nil {
return nil, err
}
// 校验
if ok := checkEmail(detail.Email); !ok {
return nil, errors.New("invalid email")
}
return detail, nil
}
这是一段典型的有 I/O 的功能代码,主体功能是传入用户名,校验合法性之后通过 redis 获取信息,之后校验获取值内容的合法性后并返回。
后台服务单测场景
对于一个传统的后端服务,它主要有以下几点的职责和功能:
·接收外部请求,controller 层分发请求、校验请求参数
· 请求有效分发后,在 service 层与 dao 层进行交互后做逻辑处理
· dao 层负责数据操作,主要是数据库或持久化存储相关的操作
因此,从职责出发来看,在做后台单测中,核心主要是验证 service 层和 dao 层的相关逻辑,此外 controller 层的参数校验也在单测之中。
细分来看,对于相关逻辑的单元测试,笔者倾向于把单测分为两种:
· 无第三方依赖,纯逻辑代码
· 有第三方依赖,如文件、网络 I/O、第三方依赖库、数据库操作相关的代码
注:单元测试中只是针对单个函数的测试,关注其内部的逻辑,对于网络/数据库访问等,需要通过相应的手段进行 mock。
Golang 单测工具选型
由于我们把单测简单的分为了两种:
对于无第三方依赖的纯逻辑代码,我们只需要验证相关逻辑即可,这里只需要使用 assert(断言),通过控制输入输出比对结果即可。
对于有第三方依赖的代码,在验证相关代码逻辑之前,我们需要将相关的依赖 mock(模拟),之后才能通过断言验证逻辑。这里需要借助第三方工具库来处理。
因此,对于 assert **(断言)**工具,可以选择 testify 或 convery,笔者这里选择了 testify。对于 mock **(模拟)**工具,笔者这里选择了 gomock 和 gomonkey。关于 mock 工具同时使用
gomock 和 gomonkey,这里跟 Golang 的语言特性有关,下面会详细的说明。
完善测试用例
这里我们开始对示例代码中的函数做单元测试。
生成单测模板代码
首先在 Goland 中打开项目,加载对应文件后右键找到 Generate 项,点击后选择 Tests for package,之后生成以 _test.go 结尾的单测文件。(如果想针对某一特定函数做单测,请选择对应的函数后右
键选定 Generate 项执行 Tests for selection。)
这里展示通过 IDE 生成的 TestGetPersonDetail 测试函数:
package unit
import (
"reflect"
"testing"
)
func TestGetPersonDetail(t *testing.T) {
type args struct {
username string
}
tests := []struct {
name string
args args
want *PersonDetail
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetPersonDetail(tt.args.username)
if (err != nil) != tt.wantErr {
t.Errorf("GetPersonDetail() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetPersonDetail() got = %v, want %v", got, tt.want)
}
})
}
}
由 Goland 生成的单测模板代码使用的是官方的 testing 框架,为了更方便的断言,我们把 testing 改造成 testify 的断言方式。
这里其实只需要引入 testify 后修改 test 函数最后的断言代码即可,这里我们以 TestGetPersonDetail 为例子,其他函数不赘述。
package unit
import (
"github.com/stretchr/testify/assert" // 这里引入了 testify
"reflect"
"testing"
)
func TestGetPersonDetail(t *testing.T) {
type args struct {
username string
}
tests := []struct {
name string
args args
want *PersonDetail
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
got, err := GetPersonDetail(tt.args.username)
// 改写这里断言的方式即可
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantErr, err != nil)
}
}
分析代码生成测试用例
对 checkUsername 、 checkEmail 纯逻辑函数编写测试用例,这里以 checkEmail 为例。
func Test_checkEmail(t *testing.T) {
type args struct {
email string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "email valid",
args: args{
email: "1234567@qq.com",
},
want: true,
},
{
name: "email invalid",
args: args{
email: "test.com",
},
want: false,
},
}
for _, tt := range tests {
got := checkEmail(tt.args.email)
assert.Equal(t, tt.want, got)
}
}
使用 gomonkey 打桩
对于 GetPersonDetail 函数而言,该函数调用了 getPersonDetailRedis 函数获取具体的 PersonDetail 信息。为此,我们需要为它打一个“桩”。
所谓的“桩”,也叫做“桩代码”,是指用来代替关联代码或者未实现代码的代码。
// 拉取用户资料信息并校验
func GetPersonDetail(username string) (*PersonDetail, error) {
// 检查用户名是否有效
if ok := checkUsername(username); !ok {
return nil, errors.New("invalid username")
}
// 从 redis 接口获取信息
detail, err := getPersonDetailRedis(username)
if err != nil {
return nil, err
}
// 校验
if ok := checkEmail(detail.Email); !ok {
return nil, errors.New("invalid email")
}
return detail, nil
}
从 GetPersonDetail 函数可见,为了能够完全覆盖该函数,我们需要控制 getPersonDetailRedis 函数不同的输出来保证后续代码都能够被覆盖运行到。因此,这里需要使用 gomonkey 来给
getPersonDetailRedis 函数打一个“桩序列”。
所谓的函数“桩序列”指的是提前指定好调用函数的返回值序列,当该函数多次调用时候,能够按照原先指定的返回值序列依次返回。
func TestGetPersonDetail(t *testing.T) {
type args struct {
username string
}
tests := []struct {
name string
args args
want *PersonDetail
wantErr bool
}{
{name: "invalid username", args: args{username: "steven xxx"}, want: nil, wantErr: true},
{name: "invalid email", args: args{username: "invalid_email"}, want: nil, wantErr: true},
{name: "throw err", args: args{username: "throw_err"}, want: nil, wantErr: true},
{name: "valid return", args: args{username: "steven"}, want: &PersonDetail{Username: "steven", Email: "12345678@qq.com"}, wantErr: false},
}
// 为函数打桩序列
// 使用 gomonkey 打函数桩序列
// 第一个用例不会调用 getPersonDetailRedis,所以只需要 3 个值
outputs := []gomonkey.OutputCell{
{
Values: gomonkey.Params{&PersonDetail{Username: "invalid_email", Email: "test.com"}, nil},
},
{
Values: gomonkey.Params{nil, errors.New("request err")},
},
{
Values: gomonkey.Params{&PersonDetail{Username: "steven", Email: "12345678@qq.com"}, nil},
},
}
patches := gomonkey.ApplyFuncSeq(getPersonDetailRedis, outputs)
// 执行完毕后释放桩序列
defer patches.Reset()
for _, tt := range tests {
got, err := GetPersonDetail(tt.args.username)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantErr, err != nil)
}
}
当使用桩序列时,要分析好单元测试用例和序列值的对应关系,保证最终被测试的代码块都能被完整覆盖。
|
|