软件开发离不开测试,测试是保证代码质量的有效手段。Mike Cohn 在他的书籍《Succeeding With Agile》中将测试策略划分为三个级别,如图1.1所示。
图1.1 测试金字塔
在“测试金字塔”最底部就是单元测试,越往下被测对象之间就越隔离,测试速度也越快。
单元测试由开发人员在开发阶段编写,其核心目标是保证项目的可持续发展,它具有自动化执行,自动回归,执行速度快,运行效率高等特点。
每一种编程语言基本都会有支持的单元测试框架和工具,因为我们现在的项目大部分使用的是 Go 语言,所以本文将和大家一起探索如何在 Go 语言项目的开发中编写单元测试,以保证代码质量。
一、单元测试的概念
1.单元
单元测试(unit testing),是对程序中的最小可测试单元进行正确性验证的过程。这里的单元是人为规定的最小的被测功能模块,可以是一个函数、一个方法或者一个类。例如下面用于做加法运算的 Add 函数就可以被当作一个单元,我们可以对它进行单元测试。
package calculator
func Add(v1 int, v2 int) int {
return v1 + v2
}
实际开发中,我们的代码逻辑不会这么简单,通常函数内部会做很多的工作,例如从数据库读取数据、调用 API 等 ,然后再做一些逻辑判断和拼装。我们可以将这些逻辑块再拆分为更小的函数,使每个函数都成为最小可测试单元。
单元的划分始终是人为规定的,没有硬性的标准,我们可以在开发中按照具体情况划分。
2.基境
在编写单元测试时,最麻烦的事情之一就是编写代码将整个测试环境设置成某个已知的状态,并在测试结束后将其恢复到初始状态。这个已知的状态称作单元测试的基境(fixture)。
例如,编写一个针对上面Add函数的单元测试:
package calculator
import (
"testing"
)
func TestAdd(t *testing.T) {
// 构造测试用例(基境)
var addTestCases = [][]int{
{1, 1, 2},
{1, 0, 1},
{1, -1, 0},
{-1, -1, -2},
}
for _, values := range addTestCases {
t.Run("Add testCase", func(t *testing.T) {
result := Add(values[0], values[1])
if result != values[2] {
t.Fatalf("Add error, expected %+v, got %+v", values[2], result)
}
})
}
}
变量 addTestCases 的初始化就是我们为这个单元测试构造的基境。
绝大多数时候,构建基境远比上面的情况要复杂,用于构建基境的代码也会随之增多,大量的时间会花费在此,也就无法把重点放在测试的真正内容上。当编写多个需要相似基境的测试时这个问题就变得更严重了,如果没有来自于测试框架的帮助,就不得不在写每一个测试时都将构建基境的代码重复一次。好在我们有非常好用的单元测试工具可以使用,在后面的内容中会介绍。
3.测试套件
针对一个单元的测试用例可能会有多个,如果我们的被测对象很多,那么对单元测试的组织就很有必要了。我们可以把一组相关的测试用例组织在一起,组成一个测试套件,我们通过测试套件能够将任意数量的测试以任意组合方式运行。
(1) 测试替身
有的时候进行测试是非常困难的,无论是单元测试还是功能测试,因为测试可能依赖于其它无法在测试环境中使用的组件。这有可能是因为这些组件暂时不可用,或者是它们不会返回测试所需要的结果,还有可能是执行它们会有不良副作用。
如果在编写单元测试时无法使用(或选择不使用)实际的组件,可以用测试替身来代替。测试替身不需要和真正的组件有完全一样的实现方式,它只需要提供和真正的组件同样的 API 即可,这样被测系统就会以为它是真正的组件。
(2) 代码覆盖率
代码覆盖率是一种用于衡量程序中的源代码被测试程度的指标。它通常依据某种覆盖准则来对测试用例执行情况进行衡量,以判断测试执行得是否充分。
代码覆盖率不是越高越好,因为写单元测试是需要投入时间和人力的,我们不需要一味地追求高代码覆盖率,而是要进行一定的平衡,即做到保证代码质量,又做到对人力资源的合理使用。
二、单元测试的实践
实践部分我们开始上手写代码。实际的项目中会包含很多的业务逻辑,比较繁琐,而这篇文章的重点在于单元测试本身,所以我们就重新创建一个新项目。
项目的目录结构如图1.2所示。
图1.2 项目目录结构
结构说明:
(1) entity:表结构对应的结构体。
(2) proto:RPC 服务的 proto,实际开发时 proto 文件会放在一个单独的项目中。
(3) repository:数据仓库,包含各种查 DB 的 API 。
(4) serrors:自定义的 error。
(5) serviceImpl/userService:用户服务 RPC。
(6) utils:工具包。
userService 包中有一个 GetUserInfoByIds 方法,用于根据用户ID获取用户信息:
// app/serviceImpl/userService/userService.go
package userService
import (
"context"
"unittestingpractice/app/proto"
)
type UserService struct {}
func NewUserService() *UserService {
return new(UserService)
}
// GetUserInfoByIds 用于根据用户ID获取用户信息
func (this *UserService) GetUserInfoByIds(ctx context.Context, req *proto.GetUserInfoByIdsReq, resp *proto.GetUserInfoByIdsResp) error {
return nil
}
参数和返回结构如下:
// app/proto/proto.go
package proto
type GetUserInfoByIdsReq struct {
Ids []int `json:"ids"`
}
type GetUserInfoByIdsResp map[int]UserInfoItem
type UserInfoItem struct {
Id int `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}
请求参数是 []int 用户ID列表,返回结构中,有用户ID、用户名以及用户昵称。我们所需要处理的逻辑很简单,根据用户ID查询用户表,然后返回,如果用户的昵称为空,则返回的昵称与用户名相同。
1.Go testing 包
我们查询表的时候,肯定需要根据用户ID列表,做 where in 查询,所以我们写一个工具方法,用于将 []int 转换为 SQL 查询支持的格式。其实 orm 支持 In 方法传入切片,但这里我们自己实现一个。我们在 utils 包下面写这个函数:
// app/utils/strings.go
package utils
import (
"fmt"
"strings"
"github.com/spf13/cast"
)
// TransIntSliceToSqlIn 用于将 []int 转换为查询 MySQL 所用到的格式,类似,"(1,2)"
func TransIntSliceToSqlIn(values []int) string {
sv := make([]string, 0, len(values))
for _, value := range values {
sv = append(sv, cast.ToString(value))
}
return fmt.Sprintf("(%s)", strings.Join(sv, ","))
}
Go 官方提供了 testing 包用于单元测试。单元测试文件以被测对象所在文件名加上 _test 结尾,而包名可以与被测对象的包名相同,也可以以 _test 结尾。下面是单元测试:
// app/utils/strings_test.go
package utils_test
import (
"testing"
"unittestingpractice/app/utils"
)
func TestTransIntSliceToSqlIn(t *testing.T) {
params := []int{1, 2}
expected := "(1,2)"
result := utils.TransIntSliceToSqlIn(params)
if result != expected {
t.Fatalf("utils.TransIntSliceToString error, expected %+v, got %+v", expected, result)
}
}
我们执行 go test -v ./app/utils/... -run TestTransIntSliceToSqlIn 命令执行单元测试,输出结果为:
➜ unittestingpractice go test -v ./app/utils/... -run TestTransIntSliceToSqlIn
=== RUN TestTransIntSliceToSqlIn
--- PASS: TestTransIntSliceToSqlIn (0.00s)
PASS
ok unittestingpractice/app/utils (1.016s)
这说明我们的测试通过了。上面的命令中 -v 用于打印详情信息,-run 用于指定单元测试,更多的 testing 包的用法可以参考官网文档。
2.AAA 模式
上面 TestTransIntSliceToSqlIn 测试是一个非常基本的单元测试示例。这里介绍一下单元测试的基本组成部分。
AAA模式是一个写单元测试的常见方式,它由三部分组成:
(1) arrange:在 arrange 部分初始化单元测试需要的变量和对象,以及构造参数等。
(2) act:在 act 部分使用组织好的参数执行方法。
(3) assert:在 assert 部分验证方法的返回值是否和期望的一样。
func TestTransIntSliceToSqlIn(t *testing.T) {
// arrange
params := []int{1, 2}
expected := "(1,2)"
// act
result := utils.TransIntSliceToSqlIn(params)
// assert
if result != expected {
t.Fatalf("utils.TransIntSliceToString error, expected %+v, got %+v", expected, result)
}
}
3.Testify
Go 官方的 testing 包基本够用,但是有的时候不太方便简洁。我们下面使用 stretchr/testify项目中的包,继续优化我们的单元测试。
(1) assert
上面 TestTransIntSliceToSqlIn 的 assert 部分我们是用 if 判断的,我们换种方式:
// app/utils/strings_test.go
package utils_test
import (
"testing"
"unittestingpractice/app/utils"
"github.com/stretchr/testify/assert"
)
func TestTransIntSliceToSqlIn(t *testing.T) {
// arrange
params := []int{1, 2}
expected := "(1,2)"
// act
result := utils.TransIntSliceToSqlIn(params)
// assert
assert.Equal(t, expected, result)
}
这里我们使用了 github.com/stretchr/testify/assert 包中的 Equal 方法,用于断言函数返回值和我们所期望的值相同。这个包支持其它的断言函数,后面我们也会用到。
我们执行单元测试,同样测试通过:
➜ unittestingpractice go test -v ./app/utils/... -run TestTransIntSliceToSqlIn
=== RUN TestTransIntSliceToSqlIn
--- PASS: TestTransIntSliceToSqlIn (0.00s)
PASS
ok unittestingpractice/app/utils (1.016s)
(2) mock 及 mockery
我们写完工具函数,开始写 GetUserInfoByIds 方法。因为需要从表里查询数据,我们在 entity 包定义表结构,在 repository 包封装查询方法。
// app/entity/user.go
package entity
type User struct {
Id int
Username string
Nickname string
}
// app/repository/user.go
package repository
import (
"unittestingpractice/app/entity"
"unittestingpractice/app/utils"
"git.100tal.com/wangxiao_go_lib/dbdao"
)
type UserRepository struct {
}
func NewUserRepository() *UserRepository {
return new(UserRepository)
}
func (repo *UserRepository) GetUserInfoByIds(ids []int) ([]entity.User, error) {
users := make([]entity.User, 0, len(ids))
err := dbdao.GetDbInstance("default", "reader").GetSession().Where("id in ?", utils.TransIntSliceToSqlIn(ids)).Find(&users)
if err != nil {
return nil, err
}
return users, nil
}
// app/serviceImpl/userService/userService.go
package userService
import (
"context"
"unittestingpractice/app/proto"
"unittestingpractice/app/repository"
"unittestingpractice/app/serrors"
)
type UserService struct {
}
func NewUserService() *UserService {
return new(UserService)
}
// GetUserInfoByIds 用于根据用户ID获取用户信息
func (this *UserService) GetUserInfoByIds(ctx context.Context, req *proto.GetUserInfoByIdsReq, resp *proto.GetUserInfoByIdsResp) error {
repo := repository.NewUserRepository()
users, err := repo.GetUserInfoByIds(req.Ids)
if err != nil {
return serrors.GetUserError
}
*resp = make(proto.GetUserInfoByIdsResp)
for _, user := range users {
nickname := user.Nickname
if nickname == "" { // 处理逻辑,如果 nickname 为空,则用 username
nickname = user.Username
}
(*resp)[user.Id] = proto.UserInfoItem{
Id: user.Id,
Username: user.Username,
Nickname: nickname,
}
}
return nil
}
在写 repository 包的时候,我们需要静下心来仔细设计一下了。一般我们查询表的时候,都是直接在 service 里用 orm 直接查询,或者在 repository 中封装一下查询逻辑,本质上还是直接读表。这样实现其实是有问题的,我们的单元测试依赖于外部数据,为了跑一次测试,我们需要在表中构造数据。假如我们不是读表,而是调用外部接口,我们跑单元测试还能叫接口调用方为我们造数据吗?显然不现实。
或许我们更应该遵循依赖倒置原则,将查询数据表的部分抽象成接口(interface),然后 userService 依赖这个接口,而不用去管实现细节。这样做的好处是,我们有途径去模仿这个真正的实现细节,而调用方并不知情,事实上它也不需要关心。
我们来改造一下 UserRepository 和 UserService:
// app/repository/user.go
package repository
import (
"unittestingpractice/app/entity"
"unittestingpractice/app/utils"
"git.100tal.com/wangxiao_go_lib/dbdao"
)
type UserRepository interface {
GetUserInfoByIds(ids []int) ([]entity.User, error)
}
func NewUserRepository() UserRepository {
return new(userRepository)
}
type userRepository struct {
}
func (repo *userRepository) GetUserInfoByIds(ids []int) ([]entity.User, error) {
users := make([]entity.User, 0, len(ids))
err := dbdao.GetDbInstance("default", "reader").GetSession().Where("id in ?", utils.TransIntSliceToSqlIn(ids)).Find(&users)
if err != nil {
return nil, err
}
return users, nil
}
// app/serviceImpl/userService/userService.go
package userService
import (
"context"
"unittestingpractice/app/proto"
"unittestingpractice/app/repository"
"unittestingpractice/app/serrors"
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{
repo: repo,
}
}
// GetUserInfoByIds 用于根据用户ID获取用户信息
func (service *UserService) GetUserInfoByIds(ctx context.Context, req *proto.GetUserInfoByIdsReq, resp *proto.GetUserInfoByIdsResp) error {
users, err := service.repo.GetUserInfoByIds(req.Ids)
if err != nil {
return serrors.GetUserError
}
*resp = make(proto.GetUserInfoByIdsResp)
for _, user := range users {
nickname := user.Nickname
if nickname == "" { // 实现逻辑,nickname 为空时,使用 username 代替
nickname = user.Username
}
(*resp)[user.Id] = proto.UserInfoItem{
Id: user.Id,
Username: user.Username,
Nickname: nickname,
}
}
return nil
}
现在 UserRepository 是一个接口,UserService 依赖这个接口。然后在 serviceInit.go 中,调用 repository.NewUserRepository() 注入依赖:
// app/serviceInit.go
package app
import (
"unittestingpractice/app/repository"
"unittestingpractice/app/service"
"unittestingpractice/app/serviceImpl/userService"
)
func NewService() *service.Unittestingpractice {
s := service.NewServiceBridge()
UserService := userService.NewUserService(repository.NewUserRepository())
s.GetUserInfoByIdsImpl = UserService
return service.NewUnittestingpractice(s)
}
还记得在第一部分的时候介绍过单元测试的测试替身,创建测试替身的过程我们称之为模仿(mock)。我们使用 github.com/stretchr/testify/mock 包来实现模仿的过程。不过如果手写模仿过程,需要花费不少的时间,我们使用 github.com/vektra/mockery 命令行工具,替我们生成需要的文件。
执行命令 cd app/repository && mockery --name=UserRepository --case underscore --output ../mocks,会在 app/mocks/user_repository.go 生成实现了 UserRepository 接口的结构体:
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
entity "unittestingpractice/app/entity"
mock "github.com/stretchr/testify/mock"
)
// UserRepository is an autogenerated mock type for the UserRepository type
type UserRepository struct {
mock.Mock
}
// GetUserInfoByIds provides a mock function with given fields: ids
func (_m *UserRepository) GetUserInfoByIds(ids []int) ([]entity.User, error) {
ret := _m.Called(ids)
var r0 []entity.User
if rf, ok := ret.Get(0).(func([]int) []entity.User); ok {
r0 = rf(ids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]entity.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]int) error); ok {
r1 = rf(ids)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
现在我们可以完成单元测试了:
// app/serviceImpl/userService/userService_test.go
package userService_test
import (
"context"
"testing"
"unittestingpractice/app/entity"
"unittestingpractice/app/mocks"
"unittestingpractice/app/proto"
"unittestingpractice/app/serviceImpl/userService"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// 验证 GetUserInfoByIds 返回正确的数据
func TestUserServiceGetUserInfoByIds(t *testing.T) {
// arrange
users := []entity.User{
{
Id: 1,
Username: "test",
Nickname: "",
},
}
repo := new(mocks.UserRepository)
repo.On("GetUserInfoByIds", mock.Anything).Return(users, nil)
us := userService.NewUserService(repo)
req := proto.GetUserInfoByIdsReq{}
resp := proto.GetUserInfoByIdsResp{}
// act
err := us.GetUserInfoByIds(context.Background(), &req, &resp)
// assert
assert.Nil(t, err)
assert.Equal(t, 1, resp[1].Id)
assert.Equal(t, "test", resp[1].Username)
assert.Equal(t, "test", resp[1].Nickname)
}
上面单元测试的核心在于 repo.On("GetUserInfoByIds", mock.Anything).Return(users, nil) 部分,这段的含义为无论 GetUserInfoByIds 方法的实参是什么,它都将返回我们定义好的 users 变量。这就相当于在代码层面 mock 了表中的数据,而不需要真的去改表数据,减少了对外部组件的依赖,而把重点放在了对功能逻辑的验证上。在测试的最后,我们通过四个断言,验证了我们的实现逻辑没有问题。
➜ unittestingpractice go test -v ./app/serviceImpl/userService/... -run TestUserServiceGetUserInfoByIds
=== RUN TestUserServiceGetUserInfoByIds
--- PASS: TestUserServiceGetUserInfoByIds (0.00s)
PASS
ok unittestingpractice/app/serviceImpl/userService 1.048s
针对 GetUserInfoByIds 方法我们需要还有需要验证的东西,例如,当查询数据表失败时返回的 error,是否是我们定义的那个。我们再写一个单元测试:
// app/serviceImpl/userService/userService_test.go
package userService_test
import (
"context"
"errors"
"testing"
"unittestingpractice/app/entity"
"unittestingpractice/app/mocks"
"unittestingpractice/app/proto"
"unittestingpractice/app/serrors"
"unittestingpractice/app/serviceImpl/userService"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestUserServiceGetUserInfoByIds(t *testing.T) {
// 此处略过具体实现
}
// 验证 GetUserInfoByIds 返回正确的 error
func TestUserServiceGetUserInfoByIdsReturnErrorCorrectly(t *testing.T) {
// arrange
repo := new(mocks.UserRepository)
repo.On("GetUserInfoByIds", mock.Anything).Return(nil, errors.New("read db error"))
us := userService.NewUserService(repo)
req := proto.GetUserInfoByIdsReq{}
resp := proto.GetUserInfoByIdsResp{}
// act
err := us.GetUserInfoByIds(context.Background(), &req, &resp)
// assert
assert.Equal(t, serrors.GetUserError, err)
}
我们再次 mock GetUserInfoByIds 方法,无论什么实参都返回 error,以验证 GetUserInfoByIds 方法读表失败时,返回 serrors.GetUserError 错误。
(3) suite
一个包中的被测对象有可能很多,那么针对不同被测对象的单元测试就会很多,如果不对它们进行一定的组织,那么就会显得很乱。
我们可以使用 github.com/stretchr/testify/suite 包,对单元测试进行组织。
// app/serviceImpl/userService/userService_test.go
package userService_test
import (
"context"
"errors"
"testing"
"unittestingpractice/app/entity"
"unittestingpractice/app/mocks"
"unittestingpractice/app/proto"
"unittestingpractice/app/serrors"
"unittestingpractice/app/serviceImpl/userService"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
// 测试 UserService 测试套件
func TestUserService(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
// UserService 测试套件
type UserServiceTestSuite struct {
suite.Suite
ctx context.Context
repo *mocks.UserRepository
service *userService.UserService
}
// 构建基境
func (suite *UserServiceTestSuite) SetupTest() {
suite.ctx = context.Background()
suite.repo = new(mocks.UserRepository)
suite.service = userService.NewUserService(suite.repo)
}
// 复原
func (suite *UserServiceTestSuite) TearDownTest() {}
// 验证 GetUserInfoByIds 返回的数据正确
func (suite *UserServiceTestSuite) TestUserServiceGetUserInfoByIds() {
// arrange
users := []entity.User{
{
Id: 1,
Username: "test",
Nickname: "",
},
}
suite.repo.On("GetUserInfoByIds", mock.Anything).Return(users, nil)
req := proto.GetUserInfoByIdsReq{}
resp := proto.GetUserInfoByIdsResp{}
// act
err := suite.service.GetUserInfoByIds(context.Background(), &req, &resp)
// assert
suite.Nil(err)
suite.Equal(1, resp[1].Id)
suite.Equal("test", resp[1].Username)
suite.Equal("test", resp[1].Nickname)
}
// 验证 GetUserInfoByIds 方法返回正确的 error
func (suite *UserServiceTestSuite) TestUserServiceGetUserInfoByIdsReturnErrorCorrectly() {
// arrange
suite.repo.On("GetUserInfoByIds", mock.Anything).Return(nil, errors.New("read db error"))
req := proto.GetUserInfoByIdsReq{}
resp := proto.GetUserInfoByIdsResp{}
// act
err := suite.service.GetUserInfoByIds(context.Background(), &req, &resp)
// assert
suite.Equal(serrors.GetUserError, err)
}
现在两个单元测试组织在了UserServiceTestSuite 测试套件中,我们只需要执行 TestUserService 这个单元测试,就可以跑下面所有的测试用例了。
第一部分的时候介绍过单元测试的基境的概念,现在可以在 SetupTest 方法构建环境,在 TearDownTest 方法里还原环境。
三、单元测试的意义
通过第一部分的概念和第二部分的实践,想必大家对如何写 Go 单元测试有了基本的了解。那么单元测试存在的意义是什么呢?为什么我们需要单元测试呢?
1.快速验证
单元测试最基本的好处就是测试执行速度快,验证过程用时短,效率高。因为我们不需要等待 push 代码、线上构建、功能测试的过程,只需要在本地执行一条命令就可以完成测试。
2.自成文档
单元测试本身就是最好的文档。单元测试用例可以很好地反应功能逻辑的实现细节,通过阅读单元测试,后面接手项目的人,也可以比较容易地理解原作者的意图。
3.可持续发展
单元测试给开发人员重构的信心。我们都知道改动老代码是很可怕的,谁也不知道改动之后会出现什么问题。如果单元测试一直都存在,那么我们改动了老代码之后,跑一下单元测试通过了,就基本说明改动的代码没有问题,开发人员就可以放心大胆地去写。这也促进了项目的可持续发展,不会导致我们陷入到历史的泥潭中不能自拔,保证每次的改动都是经过测试的。
参考文献
[1] 原著图书:VLADIMIR KHORIKOV.《Unit Testing: Principles, Practices, and Patterns》 第一版. 纽约:Manning Publications Co,2020年.
[2] 原著图书:Mike Cohn.《Succeeding with Agile》 第一版. 纽约:Addison-Wesley Professional,2020年.
[3] PHPUnit 中文手册(https://phpunit.readthedocs.io/zh_CN/latest/index.html)
[4] Golang 单元测试:有哪些误区和实践?(https://www.infoq.cn/article/3o9mnjlsejkfifvbmrrn)
也许你感兴趣
扫码关注我们
@学而思网校技术团队
分享、点赞与在看
只要你点,我们就是胖友