103. Go单测系列3---mockey与convey综合实战

2024-03-11 21:52

本文主要是介绍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 方法介绍

  1. 开始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)使用这种方式
}
  1. 条件设置 (可选)
    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" })     
}
  1. 结果设置
    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))
  1. 使用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()
  1. 创建
    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单测

在开发中会经常用到各种数据库,比如常见的MySQLRedis等。本部分就分别举例来演示如何在编写单元测试的时候对MySQLRedis进行mock

go-sqlmock

sqlmock 是一个实现 sql/driver mock库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql语句的执行结果。

安装

go get github.com/DATA-DOG/go-sqlmock

使用示例

这里使用的是go-sqlmock官方文档中提供的基础示例代码。 在下面的代码中,我们实现了一个recordStats函数用来记录用户浏览商品时产生的相关数据。具体实现的功能是在一个事务中进行以下两次SQL操作:

  1. products表中将当前商品的浏览次数+1

  2. 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综合实战的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/799196

相关文章

React+TS前台项目实战(十七)-- 全局常用组件Dropdown封装

文章目录 前言Dropdown组件1. 功能分析2. 代码+详细注释3. 使用方式4. 效果展示 总结 前言 今天这篇主要讲全局Dropdown组件封装,可根据UI设计师要求自定义修改。 Dropdown组件 1. 功能分析 (1)通过position属性,可以控制下拉选项的位置 (2)通过传入width属性, 可以自定义下拉选项的宽度 (3)通过传入classN

JavaWeb系列二十: jQuery的DOM操作 下

jQuery的DOM操作 CSS-DOM操作多选框案例页面加载完毕触发方法作业布置jQuery获取选中复选框的值jQuery控制checkbox被选中jQuery控制(全选/全不选/反选)jQuery动态添加删除用户 CSS-DOM操作 获取和设置元素的样式属性: css()获取和设置元素透明度: opacity属性获取和设置元素高度, 宽度: height(), widt

C语言入门系列:探秘二级指针与多级指针的奇妙世界

文章目录 一,指针的回忆杀1,指针的概念2,指针的声明和赋值3,指针的使用3.1 直接给指针变量赋值3.2 通过*运算符读写指针指向的内存3.2.1 读3.2.2 写 二,二级指针详解1,定义2,示例说明3,二级指针与一级指针、普通变量的关系3.1,与一级指针的关系3.2,与普通变量的关系,示例说明 4,二级指针的常见用途5,二级指针扩展到多级指针 小结 C语言的学习之旅中,二级

PyTorch模型_trace实战:深入理解与应用

pytorch使用trace模型 1、使用trace生成torchscript模型2、使用trace的模型预测 1、使用trace生成torchscript模型 def save_trace(model, input, save_path):traced_script_model = torch.jit.trace(model, input)<

JavaWeb系列六: 动态WEB开发核心(Servlet) 上

韩老师学生 官网文档为什么会出现Servlet什么是ServletServlet在JavaWeb项目位置Servlet基本使用Servlet开发方式说明快速入门- 手动开发 servlet浏览器请求Servlet UML分析Servlet生命周期GET和POST请求分发处理通过继承HttpServlet开发ServletIDEA配置ServletServlet注意事项和细节 Servlet注

MySQL的综合运用

MySQL版的葵花宝典,欲练此功,挥刀自。。。呃,,,说错了,是先创建两个表,分别是location表和store_info表 示例表为location表和store_info表,如下图所示: 操作一: ---- DISTINCT ----不显示重复的数据记录 语法:SELECT DISTINCT "字段" FROM "表名"; 示例:select distinct store_na

C语言入门系列:初识函数

文章目录 一,C语言函数与数学函数的区别1,回忆杀-初中数学2,C语言中的函数 二, 函数的声明1,函数头1.1,函数名称1.2,返回值类型1.3,参数列表 2,函数体2.1,函数体2.2,return语句 三,main函数四,函数的参数与传递方式1,实参和形参1.1,函数定义(含形参)1.2,函数调用(使用实参) 2,参数传递方式2.1,值传递2.2,引用传递 五,函数原型与预声明1,

MyBatis-Plus常用注解详解与实战应用

MyBatis-Plus 是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。它提供了大量的常用注解,使得开发者能够更方便地进行数据库操作。 MyBatis-Plus 提供的注解可以帮我们解决一些数据库与实体之间相互映射的问题。 @TableName @TableName 用来指定表名 在使用 MyBatis-Plus 实现基本的 C

[大师C语言(第三十六篇)]C语言信号处理:深入解析与实战

引言 在计算机科学中,信号是一种软件中断,它允许进程之间或进程与内核之间进行通信。信号处理是操作系统中的一个重要概念,它允许程序对各种事件做出响应,例如用户中断、硬件异常和系统调用。C语言作为一门接近硬件的编程语言,提供了强大的信号处理能力。本文将深入探讨C语言信号处理的技术和方法,帮助读者掌握C语言处理信号的高级技巧。 第一部分:C语言信号处理基础 1.1 信号的概念 在Unix-lik

django学习入门系列之第三点《案例 小米商城头标》

文章目录 阴影案例 小米商城头标往期回顾 阴影 设置阴影 box-shadow:水平方向 垂直方向 模糊距离 颜色 box-shadow: 5px 5px 5px #aaa; 案例 小米商城头标 目标样式: CSS中的代码 /*使外边距等于0,即让边框与界面贴合*/body{margin: 0;}/*控制父级边框*/.header{backgroun