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

前言

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

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

在开发中会经常用到各种数据库,比如常见的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 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
}
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 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)
	}
}

上面的代码中,定义了一个执行成功的测试用例和一个执行失败回滚的测试用例,确保我们代码中的每个逻辑分支都能被测试到,提高单元测试覆盖率的同时也保证了代码的健壮性。

执行单元测试,看一下最终的测试结果。

➜  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 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()

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")
	}
}

执行执行测试,查看单元测试结果:

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

在这里插入图片描述

  • 49
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值