Go单元测试学习笔记 V1.0


与你相识


博主介绍:

– 本人是普通大学生一枚,每天钻研计算机技能,CSDN主要分享一些技术内容,因我常常去寻找资料,不经常能找到合适的,精品的,全面的内容,导致我花费了大量的时间,所以会将摸索的内容全面细致记录下来。另外,我更多关于管理,生活的思考会在简书中发布,如果你想了解我对生活有哪些反思,探索,以及对管理或为人处世经验的总结,我也欢迎你来找我。

– 目前的学习专注于Go语言,辅学算法,前端领域。也会分享一些校内课程的学习,例如数据结构,计算机组成原理等等,如果你喜欢我的风格,请关注我,我们一起成长。


Table of Contents

  • 单元测试
    • 只从功利的角度探讨单元测试
    • 单元测试技术选型
    • Go单元测试
      • 基本概念
      • testing包
        • 测试函数API和基本格式介绍
      • testify
        • 安装
        • assert
        • require
      • go-sqlmock
        • 安装
      • miniredis
        • 安装
        • 使用案例
      • gomock
        • 安装
        • 使用
          • 源码模式
          • 反射模式
          • flags
          • 打桩(stub)
            • 参数
            • 返回值
            • 调用次数
            • 调用顺序
      • monkey
        • 使用
      • httptest
        • 使用
    • 草稿收集
    • 参考资料
    • 书籍&资料收集


单元测试

只从功利的角度探讨单元测试

单元测试在工程中实用的规则和方法

1、

问:“为什么要做单元测试?”

答:“这是保证——你写的代码是你想要的结果的最有效办法”。没有完备的单元测试的代码所构成的⼀个系统,就像组装⼀架飞机,各个配件没有分别经过严格检验,只在最后组装好后,再通过试飞来检验飞机是否正常⼀样。

尽管软件开发可以“开着飞机换引擎”,但万⼀引发了线上事故,影响了绩效,减少了发量,这样的成本还是太⾼了。所以优秀的工程师总会想尽⼀切办法保证⾃⼰的出品没有质量问题,而单元测试就是⼀个有⼒的武器,可以⼤幅降低⼤家上线时的紧张指数。

2、

问:“什么是好的单元测试?”

答:正确性:实践中单元测试不光测试代码的正确性,还能够帮助其他开发者理解代码逻辑,理解如何使⽤相关的类或者函数(可以当做接⼝或函数的使⽤⽰例了,省的写使⽤⽂档 和demo了),所以要求单测写的清晰,简洁,有⾮常好的可读性。

完整性:单测应该有很⾼的覆盖率,把可能的输⼊输出场景都考虑到。

健壮性:当被测试的类或者函数被修改内部实现或者添加功能时,⼀个好的单测应该完全不需要被修改或者只有极少的修改。⽐如⼀个排序函数的单测实现是完全稳定的,它不应该跟着不同的排序算法⽽变化。

3、

问:“单元测试应该测什么?”

答:单元测试的是“What”,⽽不是“How”。

还是上⾯排序的例⼦。排序函数的单测只是验证任意⼀个序列是否能被排序函数输出正确的排序结果,⾄于排序算法实施细节的正确性是不需要被关注的。

另外注意,要把“What”拆分成⼀系列不同的⾏为。⾏为就是对不同的输⼊场景有不同的输出,每⼀个⾏为都需要独⽴的单测。这样有新的⾏为引⼊时,就不需要修改已有的单测了。⽐如上⾯的排序函数,⾄少需要两个单测,⼀个是⽆重复输⼊,⼀个是有重复输⼊;⽽不是只⽤⼀个有重复输⼊的单测。

4、

问:“什么时候写单元测试?”

答:一般同时在写代码的时候实现单元测试。这样可以在实现代码的过程中随时执行单元测试来验证,也避免分开写的时候会忘记接口的需求,节约了时间。

这⾥特别要强调的⼀点是,有时候,写单元测试和不写单元测试,会直接影响到代码的设计和实现。⽐如要写⼀个有很多条件分⽀处理的函数,如果不考虑单测,你很可能把这所有的逻辑写在⼀个函数⾥。但是如果考虑到单测实现的简洁,你就会把各个分⽀各写成⼀个函数,然后把分⽀逻辑另写成⼀个函数,最终意外达到了优化代码的⽬的。所以评判代码或者设计好不好的⼀个准则是看它容不容易测试。

5、

问:“什么时候可以不写单元测试?”

答:在个⼈的⼯作实践中,很少遇到可以不写单元测试的情况,当然确实有过不⽤写的时候。下面是可能遇到的几种情况,请自行掂量。

  • 函数特别简单,逻辑很直接,甚⾄于可以直接inline的函数。

  • 函数逻辑太复杂了,历史上也从没有⼈为它写过单测,代码的reviewer也没有要求我写。

  • 代码的重要性不够,都是自己写自己维护的,即使代码有问题也不会有什么重要影响的。有些接⼝的⼤函数,典型如Main函数…

  • 写对应的单元测试⾮常的复杂,甚⾄⽆法写。这时候很可能

    • 需要修改设计,必须让你的设计易于单元测试
    • 需要增强单元测试框架,框架功能不够,不能很好⽀持某种场景下的单测。

6、

问:“谁来写单元测试?”

答:写生产代码的开发者来写单元测试,这是最⾼效的。不要让测试开发人员来写单元测试,他们有更重要的事情需要做,⽐如单元测试框架,Mock框架,测试基础设施,⾃动化测试,集成测 试,及各种测试⼯具等等。

7、

问:“怎么写单元测试?”

答:单元测试的代码结构⼀般一个三步经典结构:准备,调⽤,断⾔。

  1. 准备部分的⽬的是准备好调⽤所需要的外部环境,如数据,Stub,Mock,临时变量,调⽤请求,环境背景变量等等。
  2. 调⽤部分则是实际调⽤需要测试⽅法,函数或者流程。
  3. 断⾔部分判断调⽤部分的返回结果是否符合预期。

每个单元测试都应该能清晰地分出这三部分,当然有时调⽤断⾔两部分合在⼀起也是⽐较常见的。

8、

问:“再谈‘怎么写单元测试?’”

答:一、每个单元测试应该有个好名字,让⼈⼀看就知道是做什么测试,如果名字不能说明问题也要加上完整的注释。⽐如 testSortNumbers_withDuplicated, 意味SortNumbers函数的单元测试来验证有重复数字的情况。

二、尽量避免使⽤命令式编程(Imperative Programming)引⼊条件判断,循环等复杂逻辑。否则很可能会给单元测试⾃⾝带来不少bugs,这样就需要写单元测试的单元测试了:)。⼀句话单元测试不要引⼊复杂的逻辑,最好是不要引⼊逻辑。

三、好的单元测试完备⽽不重复。同样的测试场景,或者同类型的测试输⼊不要写多个单元测试,找⼀个有代表性的场景输⼊就可以了。

四、单元测试针对的是所有单元的对外接⼝,对外⾏为(即public),⽽不是关注于⼀些内部的实现或者内部逻辑。

五、要保证单元测试的外部环境尽量和实际使⽤时是⼀致的,尽量不要给单元测试开任何的后门(Mock除外),也不要去测试⼀个被修改了的单元,如为了测试⽅便,继承了⼀个被测试类,然后修改它的某些⾏为⽅便测试。

单元测试技术选型

基本选型:testify + gomonkey

附加:httptest + sqlmock + miniredis

另外还涉及到了gomocktesting,因为这两个是官方库,第三方库都是基于官方库的,所以了解之前我们先了解一下官方库。

Go单元测试学习笔记 V1.0_第1张图片

Go单元测试

基本概念

TDD(Test Driven Development):用测试来驱动开发,是一种当下开发的理想情况。

testing包

编写测试代码和编写普通的Go代码过程类似,不需要学习新的语法、规则。

在包目录内,所有以_test.go为后缀名的源代码文件都是go test测试的一部分,也不会被go build编译到最终的可执行文件中。

而在*_test.go这样的文件中,根据前缀有三种类型的函数——单元测试函数基准测试函数示例函数

类型 格式 作用
测试函数 函数名前缀为Test 测试程序的一些逻辑行为是否正确
基准函数 函数名前缀为Benchmark 测试函数的性能
示例函数 函数名前缀为Example 为文档提供示例文档

测试函数API和基本格式介绍

测试函数的名字必须以Test开头,后缀名必须以大写字母开头

func TestAdd(t *testing.T){ ... }
func TestSum(t *testing.T){ ... }
func TestLog(t *testing.T){ ... }

其中t参数用来收集关于测试的各种信息。 API如下:

func (c *T) Error(args ...interface{})  // 输出测试错误信息
func (c *T) Errorf(format string, args ...interface{})  // 格式化输出测试错误信息
func (c *T) Fail()  // 将当前的测试函数标识为“失败”, 但仍然继续执行该函数。
func (c *T) FailNow()  // 将当前的测试函数标识为“失败”, 并停止执行该函数。 在此之后, 测试过程将在下一个测试或者下一个基准测试中继续。FailNow 必须在运行测试函数或者基准测试函数的 goroutine 中调用, 而不能在测试期间创建的 goroutine 中调用。 调用 FailNow 不会导致其他 goroutine 停止。
func (c *T) Failed() bool  // Failed 用于报告测试函数是否已失败。
func (c *T) Fatal(args ...interface{})  // 调用 Fatal 相当于在调用 Log 之后调用 FailNow 。
func (c *T) Fatalf(format string, args ...interface{})  // 调用 Fatalf 相当于在调用 Logf 之后调用 FailNow 。
func (c *T) Log(args ...interface{})  // 输出信息,自动换行。
func (c *T) Logf(format string, args ...interface{})  
func (c *T) Name() string  // 返回正在运行的测试或者基准测试的名字
func (t *T) Parallel()  // Parallel 用于表示当前测试只会与其他带有 Parallel 方法的测试并行进行测试。
func (t *T) Run(name string, f func(t *T)) bool  // 执行名字为 name 的子测试 f , 并报告 f 在执行过程中是否出现了任何失败。 Run 将一直阻塞直到 f 的所有并行测试执行完毕。
func (c *T) Skip(args ...interface{})  // 调用 Skip 相当于在调用 Log 之后调用 SkipNow 。
func (c *T) SkipNow()  
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool  // Skipped 用于报告测试函数是否已被跳过。

一些常用命令:

go test // 可以运行这个包下的测试用例
go test -v  // 输出完整的测试结果

testify

testify是一个社区非常流行的Go单元测试工具包。

它的核心有三部分内容:

  • assert:断言
  • mock:测试替身
  • suite:测试套件

安装

本文代码使用 Go Modules。

创建目录并初始化:

$ mkdir -p testify && cd testify
$ go mod init github.com/darjun/go-daily-lib/testify

安装testify库:

$ go get -u github.com/stretchr/testify

assert

testing包中并没有断言。

我们简单的使用一个函数

Go单元测试学习笔记 V1.0_第2张图片

如果我们用testing来做单元测试,通常会是这样:

Go单元测试学习笔记 V1.0_第3张图片

如果我们用testify的话,通常结果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zB0Cf2m6-1632919614967)(C:\Users\huyunfei\AppData\Roaming\Typora\typora-user-images\image-20210929093008199.png)]

使用testify编写测试代码与testing一样,测试文件为_test.go,测试函数为TestXxx。使用go test命令运行测试:

testify提供的assert类函数众多,每种函数都有两个版本,一个版本是函数名不带f的,一个是带f的,区别就在于带f的函数,我们需要指定至少两个参数,一个格式化字符串format,若干个参数args

func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{})
func Equalf(t TestingT, expected, actual interface{}, msg string, args ...interface{})

我们可以通过testify来做很多类型的断言,比如是否是包含关系,路径是否存在,两个list是否包含同样的元素,以及是否为空,或者值是否相等。

但是我们可能需要很多的断言在一个函数逻辑中,如果每次都传入一个t TestingT会显得有点麻烦,所以testify提供了一种比较便捷的方式:

func TestEqual(t *testing.T) {
  assertions := assert.New(t)
  assertion.Equal(a, b, "")
  // ...
}

顺带提一句TestingT是一个接口,对*testing.T做了一个简单的包装:

type TestingT interface{
  Errorf(format string, args ...interface{})
}

require

require提供了和assert同样的接口,但是遇到错误的时候,require直接终止测试,而assert返回false.

go-sqlmock

这个mock有模拟,模仿的意思,也就是说sqlmock能够模拟sql的行为,或者可以变成一个虚拟的sql来帮助我们测试sql方面的事情。

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

安装

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

这里使用的是go-sqlmock官方文档中提供的基础示例代码。

可以看到下面的代码中我们开启了事务,先更新了一条记录,又插入了一条记录,在业务代码中,我们启动了sql连接。

// app.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
}

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函数编写单元测试。

但是又不想在测试过程中连接真实的数据库进行测试。

package main

import (
	"fmt"
	"testing"
	// 引入sqlmock
	"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 test -v
=== RUN   TestShouldUpdateStats
--- PASS: TestShouldUpdateStats (0.00s)
=== RUN   TestShouldRollbackStatUpdatesOnFailure
--- PASS: TestShouldRollbackStatUpdatesOnFailure (0.00s)
PASS
ok      golang-unit-test-demo/sqlmock_demo      0.011s

在很多orm工具的场景下,都可以使用go-sqlmock库来mock数据库操作进行测试

miniredis

miniredis是一个纯go实现的用于单元测试的redis server。

安装

go get github.com/alicebob/miniredis/v2

使用案例

// redis_op.go
package miniredis_demo

import (
	"context"
	"github.com/go-redis/redis/v8" // 注意导入版本
	"strings"
	"time"
)

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
}

测试函数:

// redis_op_test.go

package miniredis_demo

import (
	"github.com/alicebob/miniredis/v2"
	"github.com/go-redis/redis/v8"
	"testing"
	"time"
)

func TestDoSomethingWithRedis(t *testing.T) {
	// mock一个redis server
	s, err := miniredis.Run()
	if err != nil {
		panic(err)
	}
	defer s.Close()

	// 准备数据
	s.Set("q1mi", "liwenzhou.com")
	s.SAdd(KeyValidWebsite, "q1mi")

	// 连接mock的redis server
	rdb := redis.NewClient(&redis.Options{
		Addr: s.Addr(), // mock redis server的地址
	})

	// 调用函数
	ok := DoSomethingWithRedis(rdb, "q1mi")
	if !ok {
		t.Fatal()
	}

	// 可以手动检查redis中的值是否复合预期
	if got, err := s.Get("blog"); err != nil || got != "https://liwenzhou.com" {
		t.Fatalf("'blog' has the wrong value")
	}
	// 也可以使用帮助工具检查
	s.CheckGet(t, "blog", "https://liwenzhou.com")

	// 过期检查
	s.FastForward(5 * time.Second) // 快进5秒
	if s.Exists("blog") {
		t.Fatal("'blog' should not have existed anymore")
	}
}

结果:

go test -v
=== RUN   TestDoSomethingWithRedis
--- PASS: TestDoSomethingWithRedis (0.00s)
PASS
ok      golang-unit-test-demo/miniredis_demo    0.052s

miniredis支持绝大多数Redis命令

gomock

gomock是Go官方提供的测试框架,我们使用它可以对代码中的那些接口进行mock。

安装

互联网开源库更新迭代比较快,建议直接查看官方文档:

https://github.com/golang/mock

首先需要确保你的$GOPATH/bin已经加入到环境变量中。

Go版本号<1.16时:

GO111MODULE=on go get github.com/golang/mock/[email protected]

Go版本>=1.16时:

go install github.com/golang/mock/[email protected]

如果是在你的CI流水线中安装,则需要安装与你的CI环境匹配的合适版本。

使用

mockgen有两种工作模式:源码(source)模式和反射(reflect)模式。

源码模式

源码模式根据源文件mock接口。它是通过使用 -source 标志启用。在这个模式下可能有用的其他标志是 -imports-aux_files

例如:

mockgen -source=foo.go [other options]
反射模式

反射模式通过构建使用反射来理解接口的程序来mock接口。它是通过传递两个非标志参数来启用的:一个导入路径和一个逗号分隔的符号列表。可以使用 ”.”引用当前路径的包。

例如:

mockgen database/sql/driver Conn,Driver

# Convenient for `go:generate`.
mockgen . Conn,Driver
flags

mockgen 命令用来为给定一个包含要mock的接口的Go源文件,生成mock类源代码。它支持以下标志:

  • -source:包含要mock的接口的文件。
  • -destination:生成的源代码写入的文件。如果不设置此项,代码将打印到标准输出。
  • -package:用于生成的模拟类源代码的包名。如果不设置此项包名默认在原包名前添加mock_前缀。
  • -imports:在生成的源代码中使用的显式导入列表。值为foo=bar/baz形式的逗号分隔的元素列表,其中bar/baz是要导入的包,foo是要在生成的源代码中用于包的标识符。
  • -aux_files:需要参考以解决的附加文件列表,例如在不同文件中定义的嵌入式接口。指定的值应为foo=bar/baz.go形式的以逗号分隔的元素列表,其中bar/baz.go是源文件,foo是-source文件使用的文件的包名。
  • -build_flags:(仅反射模式)一字不差地传递标志给go build
  • -mock_names:生成的模拟的自定义名称列表。这被指定为一个逗号分隔的元素列表,形式为Repository = MockSensorRepository,Endpoint=MockSensorEndpoint,其中Repository是接口名称,mockSensorrepository是所需的mock名称(mock工厂方法和mock记录器将以mock命名)。如果其中一个接口没有指定自定义名称,则将使用默认命名约定。
  • -self_package:生成的代码的完整包导入路径。使用此flag的目的是通过尝试包含自己的包来防止生成代码中的循环导入。如果mock的包被设置为它的一个输入(通常是主输入),并且输出是stdio,那么mockgen就无法检测到最终的输出包,这种情况就会发生。设置此标志将告诉 mockgen 排除哪个导入
  • -copyright_file:用于将版权标头添加到生成的源代码中的版权文件
  • -debug_parser:仅打印解析器结果
  • -exec_only:(反射模式) 如果设置,则执行此反射程序
  • -prog_only:(反射模式)只生成反射程序;将其写入标准输出并退出。
  • -write_package_comment:如果为true,则写入包文档注释 (godoc)。(默认为true)
打桩(stub)

软件测试中的打桩是指用一些代码(桩stub)代替目标代码,通常用来屏蔽或补齐业务逻辑中的关键代码方便进行单元测试。

屏蔽:不想在单元测试用引入数据库连接等重资源

补齐:依赖的上下游函数或方法还未实现

上面代码中就用到了打桩,当传入Get函数的参数为liwenzhou.com时就返回1, nil的返回值。

gomock支持针对参数、返回值、调用次数、调用顺序等进行打桩操作。

参数

参数相关的用法有: - gomock.Eq(value):表示一个等价于value值的参数 - gomock.Not(value):表示一个非value值的参数 - gomock.Any():表示任意值的参数 - gomock.Nil():表示空值的参数 - SetArg(n, value):设置第n(从0开始)个参数的值,通常用于指针参数或切片

具体示例如下:

m.EXPECT().Get(gomock.Not("q1mi")).Return(10, nil)
m.EXPECT().Get(gomock.Any()).Return(20, nil)
m.EXPECT().Get(gomock.Nil()).Return(-1, nil)

这里单独说一下SetArg的适用场景,假设你有一个需要mock的接口如下:

type YourInterface {
  SetValue(arg *int)
}

此时,打桩的时候就可以使用SetArg来修改参数的值。

m.EXPECT().SetValue(gomock.Any()).SetArg(0, 7)  // 将SetValue的第一个参数设置为7
返回值

gomock中跟返回值相关的用法有以下几个:

  • Return():返回指定值
  • Do(func):执行操作,忽略返回值
  • DoAndReturn(func):执行并返回指定值

例如:

m.EXPECT().Get(gomock.Any()).Return(20, nil)
m.EXPECT().Get(gomock.Any()).Do(func(key string) {
	t.Logf("input key is %v\n", key)
})
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string)(int, error) {
	t.Logf("input key is %v\n", key)
	return 10, nil
})
调用次数

使用gomock工具mock的方法都会有期望被调用的次数,默认每个mock方法只允许被调用一次。

m.
	EXPECT().
	Get(gomock.Eq("liwenzhou.com")). // 参数
	Return(1, nil).                  // 返回值
	Times(1)                         // 设置Get方法期望被调用次数为1

// 调用GetFromDB函数时传入上面的mock对象m
if v := GetFromDB(m, "liwenzhou.com"); v != 1 {
	t.Fatal()
}
// 再次调用上方mock的Get方法时不满足调用次数为1的期望
if v := GetFromDB(m, "liwenzhou.com"); v != 1 {
	t.Fatal()
}

gomock为我们提供了如下方法设置期望被调用的次数。

  • Times() 断言 Mock 方法被调用的次数。
  • MaxTimes() 最大次数。
  • MinTimes() 最小次数。
  • AnyTimes() 任意次数(包括 0 次)。
调用顺序

gomock还支持使用InOrder方法指定mock方法的调用顺序:

// 指定顺序
gomock.InOrder(
	m.EXPECT().Get("1"),
	m.EXPECT().Get("2"),
	m.EXPECT().Get("3"),
)

// 按顺序调用
GetFromDB(m, "1")
GetFromDB(m, "2")
GetFromDB(m, "3")

此外知名的Go测试库testify目前也提供类似的mock工具—testify/mockmockery

monkey

这是一个强大的打桩工具,支持为任意函数及方法进行打桩。

需要有两点注意:

  • monkey不支持内联函数,在测试的时候需要通过命令行参数-gcflags=-l关闭Go语言的内联优化。
  • monkey不是线程安全的,所以不要把它用到并发的单元测试中。

使用

假设你们公司中台提供了一个用户中心的库varys,使用这个库可以很方便的根据uid获取用户相关信息。但是当你编写代码的时候这个库还没实现,或者这个库要经过内网请求但你现在没这能力,这个时候要为MyFunc编写单元测试,就需要做一些mock工作。

// func.go

func MyFunc(uid int64)string{
	u, err := varys.GetInfoByUID(uid)
	if err != nil {
		return "welcome"
	}

	// 这里是一些逻辑代码...

	return fmt.Sprintf("hello %s\n", u.Name)
}

我们使用monkey库对varys.GetInfoByUID进行打桩。

// func_test.go

func TestMyFunc(t *testing.T) {
	// 对 varys.GetInfoByUID 进行打桩
	// 无论传入的uid是多少,都返回 &varys.UserInfo{Name: "liwenzhou"}, nil
	monkey.Patch(varys.GetInfoByUID, func(int64)(*varys.UserInfo, error) {
		return &varys.UserInfo{Name: "liwenzhou"}, nil
	})

	ret := MyFunc(123)
	if !strings.Contains(ret, "liwenzhou"){
		t.Fatal()
	}
}

执行单元测试:

注意:这里为防止内联优化添加了-gcflags=-l参数。

go test -run=TestMyFunc -v -gcflags=-l

输出:

=== RUN   TestMyFunc
--- PASS: TestMyFunc (0.00s)
PASS
ok      monkey_demo     0.009s

除了对函数进行mock外monkey也支持对方法进行mock。

// method.go

type User struct {
	Name string
	Birthday string
}

// CalcAge 计算用户年龄
func (u *User) CalcAge() int {
	t, err := time.Parse("2006-01-02", u.Birthday)
	if err != nil {
		return -1
	}
	return int(time.Now().Sub(t).Hours()/24.0)/365
}


// GetInfo 获取用户相关信息
func (u *User) GetInfo()string{
	age := u.CalcAge()
	if age <= 0 {
		return fmt.Sprintf("%s很神秘,我们还不了解ta。", u.Name)
	}
	return fmt.Sprintf("%s今年%d岁了,ta是我们的朋友。", u.Name, age)
}
// method_test.go

func TestUser_GetInfo(t *testing.T) {
	var u = &User{
		Name:     "q1mi",
		Birthday: "1990-12-20",
	}

	// 为对象方法打桩
	monkey.PatchInstanceMethod(reflect.TypeOf(u), "CalcAge", func(*User)int {
		return 18
	})

	ret := u.GetInfo()  // 内部调用u.CalcAge方法时会返回18
	if !strings.Contains(ret, "朋友"){
		t.Fatal()
	}
}
❯ go test -run=User -v
=== RUN   TestUser_GetInfo
--- PASS: TestUser_GetInfo (0.00s)
PASS
ok      monkey_demo     0.012s

monkey基本上能满足我们在单元测试中打桩的任何需求。

社区中还有一个参考monkey库实现的gomonkey库,原理和使用过程基本相似,这里就不再啰嗦了。除此之外社区里还有一些其他打桩工具如GoStub(上一篇介绍过为全局变量打桩)等。

熟练使用各种打桩工具能够让我们更快速地编写合格的单元测试,为我们的软件保驾护航。

httptest

使用httptest来测试HTTP服务器。

使用

func index(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "Hello World")
}

func greeting(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "welcome, %s", r.URL.Query().Get("name"))
}

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/", index)
  mux.HandleFunc("/greeting", greeting)

  server := &http.Server{
    Addr:    ":8080",
    Handler: mux,
  }

  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}
type MySuite struct {
  suite.Suite
  recorder *httptest.ResponseRecorder
  mux      *http.ServeMux
}

func (s *MySuite) SetupSuite() {
  s.recorder = httptest.NewRecorder()
  s.mux = http.NewServeMux()
  s.mux.HandleFunc("/", index)
  s.mux.HandleFunc("/greeting", greeting)
}

func (s *MySuite) TestIndex() {
  request, _ := http.NewRequest("GET", "/", nil)
  s.mux.ServeHTTP(s.recorder, request)

  s.Assert().Equal(s.recorder.Code, 200, "get index error")
  s.Assert().Contains(s.recorder.Body.String(), "Hello World", "body error")
}

func (s *MySuite) TestGreeting() {
  request, _ := http.NewRequest("GET", "/greeting", nil)
  request.URL.RawQuery = "name=dj"

  s.mux.ServeHTTP(s.recorder, request)

  s.Assert().Equal(s.recorder.Code, 200, "greeting error")
  s.Assert().Contains(s.recorder.Body.String(), "welcome, dj", "body error")
}
func TestHTTP(t *testing.T) {
  suite.Run(t, new(MySuite))
}

草稿收集

Go单元测试学习笔记 V1.0_第4张图片

Go单元测试学习笔记 V1.0_第5张图片

Go单元测试学习笔记 V1.0_第6张图片

Go单元测试学习笔记 V1.0_第7张图片

func TestMVC(t *testing.T) {
 var w *httptest.ResponseRecorder
 assert := assert.New(t)
 
 // 1.测试 index 请求
 urlIndex := "/user/index"
 w = Get(urlIndex, router)
 assert.Equal(200, w.Code)
 assert.Equal("当前参与抽奖的用户人数:0", w.Body.String())

 // 2.测试 import 请求,导入用户数
 var wg sync.WaitGroup // 定义wg, 用来阻塞 goroutine
 for i := 0; i < 100000; i++ {

  // 开一个等待
  wg.Add(1)
  go func(i int) { // i 不属于临界资源,是安全的
   defer wg.Done() // 一个 goroutine 跑完后要减1,

   // 测试 /user/import 请求,模拟从 form 表单中获取数据
   param := make(map[string]string)
   param["users"] = "user" + strconv.Itoa(i)
   urlImport := "/user/import"
   w = PostForm(urlImport, param, router)
   assert.Equal(200, w.Code)

  }(i)
 }
 // 等待上面的协程运行完,再接着测试
 wg.Wait()
 // 3.测试 urlIndex 请求,查看当前参与抽奖用户是否为 for 循环总数
 w = Get(urlIndex, router)
 assert.Equal(200, w.Code)
 assert.Equal("当前参与抽奖的用户人数:100000", w.Body.String())

 // 4.测试 抽奖
 urlLucky := "/user/lucky"
 w = Get(urlLucky, router)
 assert.Equal(200, w.Code)

 // 5.抽奖一次之后,再发起 index 请求,查看查看当前参与抽奖用户是否减 1
 w = Get(urlIndex, router)
 assert.Equal(200, w.Code)
 assert.Equal("当前参与抽奖的用户人数:99999", w.Body.String())
}
复制代码

Go单元测试学习笔记 V1.0_第8张图片

gin 控制层单元测试

Go单元测试学习笔记 V1.0_第9张图片

dao

Go单元测试学习笔记 V1.0_第10张图片

Go单元测试学习笔记 V1.0_第11张图片

参考资料

  • Go中文开发手册
  • Go中文说明文档
  • Go圣经
  • Go标准库中文文档
  • 李文周 | 博客
  • Go圣经

书籍&资料收集

Go单元测试学习笔记 V1.0_第12张图片

《单元测试的艺术》

《修改代码的艺术》

《重构,改善既有代码的设计》

《有效的单元测试》


欢迎评论区讨论,或指出问题。 如果觉得写的不错,欢迎点赞,转发,收藏。

你可能感兴趣的:(Go,单元测试,testing,mock,go,gin)