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

文章目录

前言

工作中,随着业务的快速发展,代码量级和复杂度也会随之快速增长,面临的稳定性挑战越来越大。单测作为稳定性保障的重要一环越来越受到重视,编写单元测试应该成为程序员的基本素养。

之前写单元测试都是基于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 方法介绍

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

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

  2. 在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 模式下跑单个测试或者跑一个包的测试则需要手动带上该参数

相关推荐
microrain15 小时前
读数是 1008,用电量是 8:SagooIoT 计算服务的增量口径与七种计算策略
物联网·golang·计算服务·sagooiot
看浪的路人16 小时前
第7讲:大模型后端服务化——Go 实现高并发 LLM Proxy / Router / Fallback
开发语言·后端·golang
灯澜忆梦19 小时前
【Go Zap 日志库】#1 |快速上手 + 配置详解
开发语言·后端·golang
不是万能的豆学长1 天前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
tachibana21 天前
Go 语言 Map 的底层实现原理
开发语言·后端·golang·make
tachibana21 天前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
西瓜很甜哟1 天前
golang实现大文件分片上传
后端·golang·html
tachibana22 天前
使用for range 的时候,它的地址会发生变化吗?
开发语言·后端·golang·go·语法
王中阳Go2 天前
Go 后端转 AI:非科班怎么补?一张 3 列路线表(可保存)
开发语言·人工智能·后端·python·golang·大语言模型
for_ever_love__2 天前
LangChain Agentic RAG:让 Agent 自己决定要不要检索、检索几次
人工智能·golang·langchain·rag·langgraph·agentic·自适应检索