本文主要是介绍103. Go单测系列3---mockey与convey综合实战,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
文章目录
- 前言
- 断言
- mock
- 整体使用方式:
- 具体示例
- mock结构体方法
- mock普通函数
- 序列化mock
- MySQL和Redis单测
- go-sqlmock
- miniredis
- F&Q
- 1. 如何禁用内联和编译优化
前言
工作中,随着业务的快速发展,代码量级和复杂度也会随之快速增长,面临的稳定性挑战越来越大。单测作为稳定性保障的重要一环越来越受到重视,编写单元测试应该成为程序员的基本素养。
之前写单元测试都是基于go
自己的test
方式,基本就是在线下跑通流程,遇到下游的接口无法访问时,只会束手无策。后来了解到一些单测工具,市面上已有很多成熟的单测工具,本文不会比较各种工具的优劣,而是结合自身经验介绍本人在工作时常用的工具。
我们在撰写单元测试的过程中其实关注的主要是两部分内容:mock(使用mockey包)和断言(使用convey包)。
断言
断言(assertion
)是一种在程序中的一阶逻辑(如:一个结果为真或假的逻辑判断式),目的为了表示与验证软件开发者预期的结果——当程序执行到断言的位置时,对应的断言应该为真。 若断言不为真时,程序会中止执行,并给出错误信息。
断言就是判断某个结果是否符合预期,工作中最常用的是goconvey
比如针对以下方法,我们可以编写相关的单元测试用例如下
func Add(a, b int) int {return a + b
}
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
参考实例:
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
参考实例
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
参考实例:
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函数定义要一致(即入参,返回值一致)
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
参考实例:
mock := Mock(Fun).Return("c").Build()
具体示例
mock结构体方法
type Animal struct {}func (t*Animal)Run() string {return "animal run"
}func AnimalRun() string {animal := &Animal{}return animal.Run()
}
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普通函数
func Add(a, b int) int {return a + b
}func TwoSum(a, b int) int {return Add(a, b)
}
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
函数在多次执行中每次执行的结果,我们看下如何示例:
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
成功,第二次也成功,我们可以撰写如下单测
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)})})
}
但是如果我希望第一次成功,第二次失败呢,使用上述方式就行不通了,我们可以这样写
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
会导致异常,如:
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-mockLine 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 get github.com/DATA-DOG/go-sqlmock
使用示例
这里使用的是go-sqlmock
官方文档中提供的基础示例代码。 在下面的代码中,我们实现了一个recordStats
函数用来记录用户浏览商品时产生的相关数据。具体实现的功能是在一个事务中进行以下两次SQL
操作:
-
在
products
表中将当前商品的浏览次数+1
-
在
product_viewers
表中记录浏览当前商品的用户id
package mainimport "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
}
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
数据库操作。
package mainimport ("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 methodif err = recordStats(db, 2, 3); err == nil {t.Errorf("was expecting an error, but there was none")}// we make sure that all expectations were metif err := mock.ExpectationsWereMet(); err != nil {t.Errorf("there were unfulfilled expectations: %s", err)}
}
上面的代码中,定义了一个执行成功的测试用例和一个执行失败回滚的测试用例,确保我们代码中的每个逻辑分支都能被测试到,提高单元测试覆盖率的同时也保证了代码的健壮性。
执行单元测试,看一下最终的测试结果。
➜ 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 get github.com/alicebob/miniredis/v2
package mainimport ("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()
。
package mainimport ("testing""time""github.com/alicebob/miniredis/v2""github.com/go-redis/redis/v8"
)func TestDoSomethingWithRedis(t *testing.T) {// mock一个redis servers, err := miniredis.Run()if err != nil {panic(err)}defer s.Close()// 准备数据s.Set("lym", "lym.com")s.SAdd(KeyValidWebsite, "lym")// 连接mock的redis serverrdb := 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")}
}
执行执行测试,查看单元测试结果:
➜ 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 test -gcflags="all=-l -N" -v ./...
goland
图形界面可以采用:
Debug 模式下跑单个测试时会自动带上该参数,Run 模式下跑单个测试或者跑一个包的测试则需要手动带上该参数
这篇关于103. Go单测系列3---mockey与convey综合实战的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!