文章目录
前言
工作中,随着业务的快速发展,代码量级和复杂度也会随之快速增长,面临的稳定性挑战越来越大。单测作为稳定性保障的重要一环越来越受到重视,编写单元测试应该成为程序员的基本素养。
之前写单元测试都是基于go
自己的test
方式,基本就是在线下跑通流程,遇到下游的接口无法访问时,只会束手无策。后来了解到一些单测工具,市面上已有很多成熟的单测工具,本文不会比较各种工具的优劣,而是结合自身经验介绍本人在工作时常用的工具。
我们在撰写单元测试的过程中其实关注的主要是两部分内容:mock(使用mockey包)和断言(使用convey包)。
断言
断言(assertion
)是一种在程序中的一阶逻辑(如:一个结果为真或假的逻辑判断式),目的为了表示与验证软件开发者预期的结果------当程序执行到断言的位置时,对应的断言应该为真。 若断言不为真时,程序会中止执行,并给出错误信息。
断言就是判断某个结果是否符合预期,工作中最常用的是goconvey
比如针对以下方法,我们可以编写相关的单元测试用例如下
go
func Add(a, b int) int {
return a + b
}
go
import (
"testing"
"github.com/bytedance/mockey"
"github.com/smartystreets/goconvey/convey"
)
func TestAdd(t *testing.T) {
mockey.PatchConvey("test Add", t, func() {
mockey.PatchConvey("test 2+3=5", func() {
sum := Add(2, 3)
convey.So(sum, ShouldEqual, 5)
})
mockey.PatchConvey("test 1+1 != 3", func() {
sum := Add(1, 1)
convey.So(sum, ShouldNotEqual, 3)
})
})
}
从上述例子中我们可以看到,mockey
提供了PatchConvey
方法帮助我们进行测试用例的组织和编排,他能支持多级嵌套,方便我们进行case
管理。而convey
提供了断言方法So
。
注:作为最外层的PatchConvey要加参数t,而内层的PatchConvey不用加
运行单元测试后,我们可以看到相关代码的覆盖率,这样可进一步帮助我们判断单测覆盖情况,查漏补缺。
mock
在单元测试中,模拟对象可以模拟复杂的、真实的(非模拟)对象的行为, 如果真实的对象无法放入单元测试中,使用模拟对象就很有帮助。
当我们的代码依赖较多,由于多种因素导致我们可能无法准确的控制这些依赖的返回值,比如你在线下环境测试,依赖的某些服务并没有部署线下环境,此时你的代码根本无法执行通过;如果直接在预览环境测试有可能导致线上风险,因此这时候我们就需要对这些下游服务的返回结果进行mock
(关于mock
工具比较推荐字节的mockey
),使其按照我们预期的结果进行返回。
此处的下游不一定就是外部的服务(rpc
接口),也可能是自身的方法或者函数。根据工作中实际场景,将mock
分为如下几类:
整体使用方式:
MockerBuilder 方法介绍
- 开始mock
API :Mock(target interface{}) *MockBuilder
参数 :target 需要mock的函数
返回 :*MockBuilder
参考实例:
go
func Fun(a string) string {
fmt.Println(a)
return a
}
type Class struct {
}
func (*Class) FunA(a string) string {
fmt.Println(a)
return a
}
func TestMock(t *testing.T) {
Mock(Fun) //对于普通函数使用这种
Mock((*Class).FunA) //对于class(struct)使用这种方式
}
- 条件设置 (可选)
API :When(when interface{}) *MockBuilder
参数 :when 函数指针。表示在何种条件下调用mock函数返回mock结果。
函数原型 : when(args...) bool
args :与Mock 函数参数一致,一般通过args来判断是否需要执行 mock,注意类成员函数需要增加self作为第一个参数(目前已经兼容了不传入receiver,当不需要使用的时候可以忽略)
返回值 : bool ,是true的时候执行 mock
返回 : *MockBuilder
参考实例
go
func TestMock(t *testing.T) {
//对于普通函数使用这种
Mock(Fun).When(func(p string) bool { return p == "a" })
//对于class使用这种方式
Mock((*Class).FunA).When(func(self *Class, p string) bool { return p == "a" })
}
- 结果设置
API :Return(results ...interface{}) *MockBuilder
参数 : results 参数列表需要完全等同于需要mock的函数返回值列表,(mockey v1.2.4+新增sequence支持,可以设置多个连续的返回值)
返回 : *MockBuilder
参考实例:
go
Mock(Fun).Return("c")
// mockey v1.2.4+ 支持
Mock(Fun).Return(Sequence("Alice").Times(3).Then("Bob").Then("Tom").Times(2))
- 使用mock函数
API :To(hook interface{}) *MockBuilder
参数 : hook 参数与返回值需要与mock函数完全一致,注意类成员函数需要增加self作为第一个参数(目前已经兼容了不传入receiver,当不需要使用的时候可以忽略)
返回 : mockBuilder
参考实例 :原调用Fun函数的地方替换为调用mock函数,注意mock函数与Fun函数定义要一致(即入参,返回值一致)
go
func Fun(a string) string {
fmt.Println(a)
return a
}
mock := func(p string) string {
fmt.Println("b")
return "b"
}
Mock(Fun).To(mock).Build()
- 创建
API :Build()
参数: 无
**返回值:**Mocker
参考实例:
go
mock := Mock(Fun).Return("c").Build()
具体示例
mock结构体方法
go
type Animal struct {}
func (t*Animal)Run() string {
return "animal run"
}
func AnimalRun() string {
animal := &Animal{}
return animal.Run()
}
go
func TestAnimalRun(t *testing.T) {
PatchConvey("test animal run", t, func() {
Mock((*Animal).Run).Return("animal jump").Build()
So(AnimalRun(), ShouldEqual, "animal jump")
})
}
我们通过Mock
方法修改了Animal.Run
函数的返回值恒定为"animal jump"
。
注意:Return()方法中参数的数量要与被mock函数的返回值数量及其顺序保持一致。
mock普通函数
go
func Add(a, b int) int {
return a + b
}
func TwoSum(a, b int) int {
return Add(a, b)
}
go
import (
"testing"
. "github.com/bytedance/mockey"
. "github.com/smartystreets/goconvey/convey"
)
func TestTwoSum(t *testing.T) {
PatchConvey("test two sum", t, func() {
Mock(Add).Return(10).Build()
So(TwoSum(1, 2), ShouldEqual, 10)
})
}
我们通过Mock
方法修改了Add
函数的返回值恒定为10
。
序列化mock
在实际工作中会有这样一种场景,我们会会在一次请求处理中对某个方法调用多次,我们希望每次调用都可以返回不同的结果,这种该如何实现呢?别担心,mockey
提供了序列化方式,可以指定mock
函数在多次执行中每次执行的结果,我们看下如何示例:
go
type Event struct {
Extra string `json:"extra"` // map
}
func parseEvent(value string) (map[string]interface{},error) {
event := &Event{}
if err := json.Unmarshal([]byte(value), &event); err != nil {
return nil, errors.New("unmarshal_event_failed")
}
ret := make(map[string]interface{})
if err := json.Unmarshal([]byte(event.Extra), &ret); err != nil {
return nil, errors.New("unmarshal_extra_failed")
}
return ret, nil
}
比如我们希望第一次unmarshal
成功,第二次也成功,我们可以撰写如下单测
go
func TestParseEvent(t *testing.T) {
PatchConvey("test parse event", t, func() {
PatchConvey("test success", func() {
Mock(json.Unmarshal).Return(nil).Build() // 一次mock后续所有执行全部都是这个结果
ret, err := ParseEvent("")
So(ret, ShouldNotBeNil)
So(err, ShouldBeNil)
})
})
}
但是如果我希望第一次成功,第二次失败呢,使用上述方式就行不通了,我们可以这样写
go
func TestParseEvent(t *testing.T) {
PatchConvey("test parse event", t, func() {
PatchConvey("test unmarshal extra failed", func() {
Mock(json.Unmarshal).Return(Sequence(nil).Then(errors.New("unmarshal failed"))).Build()
ret, err := ParseEvent("")
So(ret, ShouldBeNil)
So(err.Error(), ShouldEqual, "unmarshal_extra_failed")
})
})
}
你可能会问,那我连续mock
两次json.unmarshal
是否可以的,答案当然是no
,连续mock
会导致异常,如:
go
func TestParseEvent(t *testing.T) {
PatchConvey("test parse event", t, func() {
PatchConvey("test unmarshal extra failed", func() {
// Mock(json.Unmarshal).Return(Sequence(nil).Then(errors.New("unmarshal failed"))).Build()
Mock(json.Unmarshal).Return(nil).Build()
Mock(json.Unmarshal).Return(errors.New("unmarshal failed")).Build()
ret, err := ParseEvent("")
So(ret, ShouldBeNil)
So(err.Error(), ShouldEqual, "unmarshal_extra_failed")
})
})
}
运行结果如下:会提示re-mock
Line 51: - re-mock <func([]uint8, interface {}) error Value>, previous mock at: /Users/bytedance/go/src/code.byted.org/namespace/test/unittest/exemple_test.go:50
goroutine 6 [running]:
MySQL和Redis单测
在开发中会经常用到各种数据库,比如常见的MySQL
和Redis
等。本部分就分别举例来演示如何在编写单元测试的时候对MySQL
和Redis
进行mock
。
go-sqlmock
sqlmock
是一个实现 sql/driver
的mock
库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql
驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql
语句的执行结果。
安装
go
go get github.com/DATA-DOG/go-sqlmock
使用示例
这里使用的是go-sqlmock
官方文档中提供的基础示例代码。 在下面的代码中,我们实现了一个recordStats
函数用来记录用户浏览商品时产生的相关数据。具体实现的功能是在一个事务中进行以下两次SQL
操作:
-
在
products
表中将当前商品的浏览次数+1
-
在
product_viewers
表中记录浏览当前商品的用户id
go
package main
import "database/sql"
// recordStats 记录用户浏览产品信息
func recordStats(db *sql.DB, userID, productID int64) (err error) {
// 开启事务
// 操作views和product_viewers两张表
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
switch err {
case nil:
err = tx.Commit()
default:
tx.Rollback()
}
}()
// 更新products表
if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
return
}
// product_viewers表中插入一条数据
if _, err = tx.Exec(
"INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)",
userID, productID); err != nil {
return
}
return
}
go
func main() {
// 注意:测试的过程中并不需要真正的连接
db, err := sql.Open("mysql", "root@/blog")
if err != nil {
panic(err)
}
defer db.Close()
// userID为1的用户浏览了productID为5的产品
if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err != nil {
panic(err)
}
}
现在我们需要为代码中的recordStats
函数编写单元测试,但是又不想在测试过程中连接真实的数据库进行测试。这个时候我们就可以像下面示例代码中那样使用sqlmock
工具去mock
数据库操作。
go
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// TestShouldUpdateStats sql执行成功的测试用例
func TestShouldUpdateStats(t *testing.T) {
// mock一个*sql.DB对象,不需要连接真实的数据库
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
// mock执行指定SQL语句时的返回结果
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// 将mock的DB对象传入我们的函数中
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// 确保期望的结果都满足
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// TestShouldRollbackStatUpdatesOnFailure sql执行失败回滚的测试用例
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").
WithArgs(2, 3).
// 注意:此处返回了错误
WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
// now we execute our method
if err = recordStats(db, 2, 3); err == nil {
t.Errorf("was expecting an error, but there was none")
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
上面的代码中,定义了一个执行成功的测试用例和一个执行失败回滚的测试用例,确保我们代码中的每个逻辑分支都能被测试到,提高单元测试覆盖率的同时也保证了代码的健壮性。
执行单元测试,看一下最终的测试结果。
go
➜ demo_ut_go go test -v -run=TestShould
=== RUN TestShouldUpdateStats
--- PASS: TestShouldUpdateStats (0.00s)
=== RUN TestShouldRollbackStatUpdatesOnFailure
--- PASS: TestShouldRollbackStatUpdatesOnFailure (0.00s)
PASS
可以看到两个测试用例的结果都符合预期,单元测试通过。
在很多使用ORM工具的场景下,也可以使用go-sqlmock库mock数据库操作进行测试。
miniredis
除了经常用到MySQL
外,Redis
在日常开发中也会经常用到。接下来我们将一起学习如何在单元测试中mock Redis
的相关操作。
miniredis
是一个纯go
实现的用于单元测试的redis server
。它是一个简单易用的、基于内存的redis
替代品,它具有真正的TCP
接口,你可以把它当成是redis
版本的net/http/httptest
。
当我们为一些包含Redis
操作的代码编写单元测试时就可以使用它来mock Redis
操作。
安装
这里以github.com/go-redis/redis
库为例,编写了一个包含若干Redis
操作的DoSomethingWithRedis
函数。
go
go get github.com/alicebob/miniredis/v2
go
package main
import (
"context"
"strings"
"time"
"github.com/go-redis/redis/v8" // 注意导入版本
)
const (
KeyValidWebsite = "app:valid:website:list"
)
func DoSomethingWithRedis(rdb *redis.Client, key string) bool {
// 这里可以是对redis操作的一些逻辑
ctx := context.TODO()
if !rdb.SIsMember(ctx, KeyValidWebsite, key).Val() {
return false
}
val, err := rdb.Get(ctx, key).Result()
if err != nil {
return false
}
if !strings.HasPrefix(val, "https://") {
val = "https://" + val
}
// 设置 blog key 五秒过期
if err := rdb.Set(ctx, "blog", val, 5*time.Second).Err(); err != nil {
return false
}
return true
}
下面的代码是使用miniredis
库为DoSomethingWithRedis
函数编写的单元测试代码,其中miniredis
不仅支持mock
常用的Redis
操作,还提供了很多实用的帮助函数,例如检查key
的值是否与预期相等的s.CheckGet()
和帮助检查key
过期时间的s.FastForward()
。
go
package main
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
)
func TestDoSomethingWithRedis(t *testing.T) {
// mock一个redis server
s, err := miniredis.Run()
if err != nil {
panic(err)
}
defer s.Close()
// 准备数据
s.Set("lym", "lym.com")
s.SAdd(KeyValidWebsite, "lym")
// 连接mock的redis server
rdb := redis.NewClient(&redis.Options{
Addr: s.Addr(), // mock redis server的地址
})
// 调用函数
ok := DoSomethingWithRedis(rdb, "lym")
if !ok {
t.Fatal()
}
// 可以手动检查redis中的值是否复合预期
if got, err := s.Get("blog"); err != nil || got != "https://lym.com" {
t.Fatalf("'blog' has the wrong value")
}
// 也可以使用帮助工具检查
s.CheckGet(t, "blog", "https://lym.com")
// 过期检查
s.FastForward(5 * time.Second) // 快进5秒
if s.Exists("blog") {
t.Fatal("'blog' should not have existed anymore")
}
}
执行执行测试,查看单元测试结果:
go
➜ demo_ut_go go test -v -run=TestDoSomethingWithRedis
=== RUN TestDoSomethingWithRedis
--- PASS: TestDoSomethingWithRedis (0.00s)
PASS
miniredis
基本上支持绝大多数的Redis
命令,大家可以通过查看文档了解更多用法。
当然除了使用miniredis
搭建本地redis server
这种方法外,还可以使用各种打桩工具对具体方法进行打桩。在编写单元测试时具体使用哪种mock
方式还是要根据实际情况来决定。
F&Q
执行单测时需要关闭内联优化,这样可以保证mock
成功!!!
1. 如何禁用内联和编译优化
命令行跑单测可以采用:
go
go test -gcflags="all=-l -N" -v ./...
goland
图形界面可以采用:
Debug 模式下跑单个测试时会自动带上该参数,Run 模式下跑单个测试或者跑一个包的测试则需要手动带上该参数