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

相关文章

Go标准库常见错误分析和解决办法

《Go标准库常见错误分析和解决办法》Go语言的标准库为开发者提供了丰富且高效的工具,涵盖了从网络编程到文件操作等各个方面,然而,标准库虽好,使用不当却可能适得其反,正所谓工欲善其事,必先利其器,本文将... 目录1. 使用了错误的time.Duration2. time.After导致的内存泄漏3. jsO

Spring Boot + MyBatis Plus 高效开发实战从入门到进阶优化(推荐)

《SpringBoot+MyBatisPlus高效开发实战从入门到进阶优化(推荐)》本文将详细介绍SpringBoot+MyBatisPlus的完整开发流程,并深入剖析分页查询、批量操作、动... 目录Spring Boot + MyBATis Plus 高效开发实战:从入门到进阶优化1. MyBatis

MyBatis 动态 SQL 优化之标签的实战与技巧(常见用法)

《MyBatis动态SQL优化之标签的实战与技巧(常见用法)》本文通过详细的示例和实际应用场景,介绍了如何有效利用这些标签来优化MyBatis配置,提升开发效率,确保SQL的高效执行和安全性,感... 目录动态SQL详解一、动态SQL的核心概念1.1 什么是动态SQL?1.2 动态SQL的优点1.3 动态S

Pandas使用SQLite3实战

《Pandas使用SQLite3实战》本文主要介绍了Pandas使用SQLite3实战,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学... 目录1 环境准备2 从 SQLite3VlfrWQzgt 读取数据到 DataFrame基础用法:读

go中空接口的具体使用

《go中空接口的具体使用》空接口是一种特殊的接口类型,它不包含任何方法,本文主要介绍了go中空接口的具体使用,具有一定的参考价值,感兴趣的可以了解一下... 目录接口-空接口1. 什么是空接口?2. 如何使用空接口?第一,第二,第三,3. 空接口几个要注意的坑坑1:坑2:坑3:接口-空接口1. 什么是空接

利用Go语言开发文件操作工具轻松处理所有文件

《利用Go语言开发文件操作工具轻松处理所有文件》在后端开发中,文件操作是一个非常常见但又容易出错的场景,本文小编要向大家介绍一个强大的Go语言文件操作工具库,它能帮你轻松处理各种文件操作场景... 目录为什么需要这个工具?核心功能详解1. 文件/目录存javascript在性检查2. 批量创建目录3. 文件

Python实战之屏幕录制功能的实现

《Python实战之屏幕录制功能的实现》屏幕录制,即屏幕捕获,是指将计算机屏幕上的活动记录下来,生成视频文件,本文主要为大家介绍了如何使用Python实现这一功能,希望对大家有所帮助... 目录屏幕录制原理图像捕获音频捕获编码压缩输出保存完整的屏幕录制工具高级功能实时预览增加水印多平台支持屏幕录制原理屏幕

最新Spring Security实战教程之Spring Security安全框架指南

《最新SpringSecurity实战教程之SpringSecurity安全框架指南》SpringSecurity是Spring生态系统中的核心组件,提供认证、授权和防护机制,以保护应用免受各种安... 目录前言什么是Spring Security?同类框架对比Spring Security典型应用场景传统

最新Spring Security实战教程之表单登录定制到处理逻辑的深度改造(最新推荐)

《最新SpringSecurity实战教程之表单登录定制到处理逻辑的深度改造(最新推荐)》本章节介绍了如何通过SpringSecurity实现从配置自定义登录页面、表单登录处理逻辑的配置,并简单模拟... 目录前言改造准备开始登录页改造自定义用户名密码登陆成功失败跳转问题自定义登出前后端分离适配方案结语前言

OpenManus本地部署实战亲测有效完全免费(最新推荐)

《OpenManus本地部署实战亲测有效完全免费(最新推荐)》文章介绍了如何在本地部署OpenManus大语言模型,包括环境搭建、LLM编程接口配置和测试步骤,本文给大家讲解的非常详细,感兴趣的朋友一... 目录1.概况2.环境搭建2.1安装miniconda或者anaconda2.2 LLM编程接口配置2