AICon全球人工智能与机器学习技术大会8折特惠,购票立减¥960! 了解详情
写点什么

《分布式对象存储》作者手把手教你写 GO 语言单元测试

2019 年 10 月 09 日

《分布式对象存储》作者手把手教你写 GO 语言单元测试

如何写 GO 语言单元测试

GO 语言内建了单元测试(Unit Test)框架。这是为了从语言层面规范写 UT 的方式。GO 语言的命名规则会将以 _test.go 结尾的 go 文件视作单元测试代码。


当我们用 go build 构建可执行程序时,这些 _test.go 文件被排除在构建范围之外。而当我们用 go test 来进行单元测试时,这些 _test.go 文件则会参与构建,且还会提供一个默认的 TestMain 函数作为 UT 的起始入口。


接下来,就让我们通过一个例子来看看如何写 GO 语言的单元测试。首先让我们来看这样一段代码:


package db//db包实现了一个DB结构体用来封装对某个数据库的访问
import ( "someDB" //someDB提供了对实际数据库的 //insert/get/delete等函数)...
type DB struct {//DB结构体的内部细节忽略...}
//DB结构体提供了Put/Get/Delete//三个方法,具体实现略func (d *DB)Put(key, value string) error {... someDB.insert(...)...}
func (d *DB)Get(key string) (string, error) {... return someDB.get(...)}
func (d *DB)Delete(key string) error {... someDB.delete(...)...}
复制代码


从上面的代码可以看到我们在 db.go 中实现了一个 DB 结构体用来抽象对某个数据库的访问。现在,要为 DB 写 UT,我们通常会将测试代码放在 db_test.go 中。(虽然 GO 语言本身并不要求文件名的一一对应,但是这种约定俗成的命名规则能带给我们更好的可读性。)


package db//UT的用例必须和代码在同一个包内
import ( "testing" //testing包提供了测试函数 //必须用到的数据结构 ...)
//我们会为DB结构体的每一个//方法都写一个测试函数//这里先列出各测试函数的签名//具体实现后面会给出func TestPut(t *testing.T) {...}
func TestGet(t *testing.T) {...}
func TestDelete(t *testing.T) {...}
复制代码


为了让 GO 语言的测试框架能够自动发现我们所有的测试用例,测试函数的签名也要遵循其特定的规则:


  • 函数名必须以 Test 开头,后面通常是待测方法的函数名

  • 参数必须是 *testing.T,它提供了 Error,Fatal 等方法用来报错和终止测试的运行


这些测试用例会在测试框架下并发地执行,并发度由 go test 时的 -parallel 参数指定。


具体的 UT 实现

  • TestPut


func TestPut(t *testing.T) {    //为了进行测试,我们首先要创建    //一个DB结构体的实例,具体参数略    d := NewDB(...)
//我们调用待测方法Put //将一些数据写入数据库 err := d.Put("testputkey", "value") //必须检查返回的错误,确保返回nil if err != nil { //用Error来打印错误信息 t.Error(err) }
//接下来我们用someDB的get接口 //来获取这些数据,这里注意尽量 //避免用待测的DB.Get方法 //原因见下 value, _ := someDB.get(...) //校验数据 if value != "value" { t.Error("some msg") }}
复制代码


在获取数据的时候不建议使用另一个待测方法 Get,这样可以避免测试污染。


所谓测试污染是指由非待测函数导致的失败,比如 TestPut 的待测函数是 DB.Put,如果我们使用 DB.Get 方法来获取数据,那么 DB.Get 如果出错就会导致测试用例失败,而此时我们需要额外的信息来判断究竟是 Put 出了问题还是 Get 出了问题。而 someDB.get 方法在 someDB 包里已经经过了测试,通常被认为是可信的。


我们会在后面的测试用例中看到类似的处理。


  • TestGet


func TestGet(t *testing.T) {    d := NewDB(...)    //首先测试Get不存在的key    //尽可能让参数名字自解释    _, err := d.Get("testgetnonexist")    if err != ErrNotFound {        t.Error("some msg")    }
//用someDB的insert接口 //来写入一些测试数据 err = someDB.insert(...) if err != nil { t.Fatal("some msg") }
//然后调用待测方法Get读取这些数据 value, err := d.Get("testgetkey") if err != nil { t.Error("some msg") }
//校验数据 if value != "value" { t.Error("some msg") }}
复制代码


Fatal 和 Error 的区别在于 Fatal 在报错后会立即终止当前用例继续运行,如果 insert 失败,则后续的 Get 也没有意义,所以用 Fatal 终止。


  • TestDelete


func TestDelete(t *testing.T) {    d := NewDB(...)    //首先用someDB的insert接口    //来写入一些测试数据    err := someDB.insert(...)    if err != nil {        t.Fatal("some msg")    }
//然后调用待测方法Delete //删除这些数据 err = d.Delete("testdeletekey") if err != nil { t.Error("some msg") }
//用someDB的Get接口 //来验证数据的删除 _, err := someDB.get(...) if err != ErrNotFound { t.Error("some msg") }}
复制代码


运行测试的常见命令

  • 运行 go test 命令即可在编译并执行当前目录下的所有测试用例

  • 如果需要执行当前目录以及所有子目录中的测试用例,则运行命令 go test ./…

  • 如果需要执行某个测试用例,比如单单执行 TestGet 用例则运行 go test -run TestGet

  • 运行 go test -help 可查看详细的参数列表,比如之前提到的 -parallel 参数等


如何写好 GO 语言单元测试

我们在第一部分已经见过了基本的单元测试框架,会写自己的单元测试了。可是要想写出好的单元测试还不是那么简单,有很多要素需要注意。


用断言来代替原生的报错函数

让我们看这样一个例子:


if XXX {    t.Error("msg")}
if AAA != BBB { t.Error("msg2")}
复制代码


GO 语言提供的 Error 太不友好了,判断的 if 需要写在前头。这对于我们这些写 UT 行数还要超过功能代码的 GO 语言程序员来说,增加的代码量是非常恐怖的。


使用断言可以让我们省略这个判断的 if 语句,增强代码的可读性。GO 语言本身没有提供 assert 包,不过有很多开源的选择。比如使用https://github.com/stretchr/testify,上面的例子可以简化为:


assert.True(t, XXX, "msg")assert.Equal(t, AAA, BBB, "msg2")
复制代码


除了 True 和 Equal 之外当然还有很多其它断言,这就需要我们自己看代码或文档去发现了


避免随机结果

让我们看这样一个例子:


a := rand.Intn(100)b := rand.Intn(10)result := div(a, b)assert.Equal(t, a/b, result)
复制代码


UT 的结果应当是决定性(decisive)的,当我们使用了随机的输入值来进行 UT 时,我们让自己的测试用例变得不可控。当一切正常时,我们还不会意识到这样的坏处,然而当糟糕的事情发生时,随机的结果让我们难以 debug。


比如,上例在大多数时候都能正常运行,唯有当 b 随机到 0 时会 crash。在上例,比较正确的做法是:


result := div(6, 3)assert.Equal(t, 2, result)
复制代码


避免无意义重复

让我们看这样一个例子:


n := 10000for i:=0; i<n; i++ {    doSomeThing()    assertSomeThing()}
复制代码


在设计 UT 时,我们要问问自己,重复执行 doSomeThing 多次会带来不同的结果吗,如果总是同样的结果,那么 doSomeThing 只做一次就足够了。如果确实会出现不同的结果,那简单重复 10000 次不仅浪费了有限的 CPU 等资源,也比不上精心设计的不同断言能给我们带来的更多好处。


在上例,比较正确的做法是:


doSomeThing()assertSomeThing()doSomeThing()//断言我们在第二次doSomeThing时//发生了不同的故事assertSomeThingElse()
复制代码


尽量避免断言时间的结果

让我们看这样一个例子:


start := time.Now()doSomeThing()assert.WithinDuration(t, time.Now(), start, time.Second)
复制代码


即便我们很笃定 doSomeThing() 一定确定以及肯定能在 1 秒内完成,这个测试用例依然有很大可能在某个性能很差的容器上跑失败。除非我们就是在测试 Sleep 之类跟时间有关的函数,否则对时间的断言通常总是能被转化为跟时间无关的断言。一定要断言时间的话,断言超时比断言及时更不容易出错。


比如上面的例子,我们没办法断言它一定在 1 秒内完成,但是大概能断言它在 10 微秒内完不成。


尽量避免依赖外部服务

即使我们十分确信某个公有云服务是在线的,在 UT 中依赖它也不是一个好主意。毕竟我们的 UT 不仅会跑在自己的开发机上,也会跑在一些沙盒容器里,我们可无法知道这些沙盒容器一定能访问到这个公有云服务。如果访问受限,那么测试用例就会失败。


要让我们的测试用例在任何情况下都能成功运行,写一个 mock 服务会是更好的选择。不过有些外部服务是必须依赖且无法 mock 的,比如测试数据库驱动时必须依赖具体的数据库服务,对于这样的情况,我们需要在开始 UT 之前设置好相应的环境。此时也有一些需要注意的地方,见下节。


优雅地实行前置和后置任务

为了设置环境或者为了避免测试数据污染,有时候有必要进行一定的前置和后置任务,比如在所有的测试开始的前后清空某个测试数据库中的内容等。


这样的任务如果在每个测试用例中都重复执行,那不仅是的代码冗余,也是资源的浪费。我们可以让 TestMain 来帮我们执行这些前置和后置任务:


func TestMain(m *testing.M) {    doSomeSetup()    r := m.Run()    doSomeClear()    os.Exit(r)}
复制代码


TestMain 函数是 GO 测试框架的入口点,运行 m.Run 会执行测试。TestMain 函数不是必须的,除非确实有必要在 m.Run 的前后执行一些任务,我们完全可以不实现这个函数。


测试用例之间相互隔离

TestA,TestB 这样的命名规则已经帮我们在一定程度上隔离了测试用例,但这样还不够。如果我们的测试会访问到外部的文件系统或数据库,那么最好确保不同的测试用例之间用到的文件名,数据库名,数据表名等资源的隔离。


用测试函数的名字来做前缀或后缀会是一个不错的方案,比如:


func TestA(t *testing.T) {    f, err := os.Open("somefilefortesta")    ...}
func TestB(t *testing.T) { f, err := os.Open("somefilefortestb") ...}
复制代码


这样隔离的原因是所有的测试用例会并发执行,我们不希望我们的用例由于试图在同一时间访问同一个文件而互相影响。


面向接口编程

这是典型的测试倒逼功能代码。


功能代码本身也许完全不需要面向接口编程,一个具体的结构体就足够完成任务。可是当我们去实现相应的单元测试时,有时候会发现构造这样一个具体的结构体会十分复杂。


这种情况下,我们会考虑在实际代码中使用接口(interface),并在单元测试中用一个 mock 组件来实现这个接口。考虑如下代码:


type someStruct struct {    ComplexInnerStruct}
复制代码


我们要为这个 someStruct 写 UT,就不得不先构造出一个 ComplexInnerStruct。而这个 ComplexInnerStruct 可能依赖了几十个外部服务,构造这样一个结构体会是一件十分麻烦的事情。


此时我们可以这样做,首先我们修改实际的代码,让 someStruct 依赖某个接口而不是某个具体的结构体:


type someStruct struct {    someInterface}type someInterface interface {    //只适配那些被用到的方法    someMethod()}
复制代码


接下来我们的 UT 就可以用一个 mock 结构体来代替那个 ComplexInnerStruct:


type mockStruct struct {}
func (m *mockStruct) someMethod() { ...}
s := &someStruct{ someInterface: &mockStruct{},}
复制代码


这样,我们就帮自己省去了在 UT 中创建一个 ComplexInnerStruct 的繁杂工作。


结语

在工作中,我们一般都会将 UT 加入编译 job 作为代码提交流程的一部分。有时我们会发现自己或其他同事写的 UT 换个环境就冒出一些难以调查的随机失败。重启编译 job 并向程序员之神祈祷有时候确实可以让一些随机失败不再重现,但这只是掩盖了失败背后真正的问题。


作为一个有钻研精神的程序员,我们不妨仔细调查错误的可能成因,改良代码和 UT 的写法,让自己的生活更美好。


本文转载自公众号七牛云(ID:qiniutek)。


原文链接:


https://mp.weixin.qq.com/s/JiF_sSj0rHk4sOsDoJ2Gpw


2019 年 10 月 09 日 23:13413

评论

发布
暂无评论
发现更多内容

Android | dagger细枝篇

哈利迪

android

linux入门系列19--数据库管理系统(DBMS)之MariaDB

黑马腾云

MySQL Linux centos linux运维 MariaDB

更新丢失、写偏、幻读:数据库事务从快照隔离到可序列化

青菜年糕汤

数据库 数据库事务 分布式数据库 数据库设计 分布式系统

Python作业留底--《菜鸟教程》Python 练习和习题

Geek_f6bfca

什么是分散式金融(DeFi)?

志学Python

去中心化金融 defi

Flink-有状态算子的扩缩容-12

小知识点

scala 大数据 flink

基于区块链的社会治理探索

CECBC区块链专委会

区块链 大数据 信息技术

JVM的早期优化与晚期优化

Edison

JVM JVM虚拟机原理

比特币挖矿到底挖的是什么?

CECBC区块链专委会

比特币 区块链 数字货币

linux入门系列18--Web服务之Apache服务1

黑马腾云

Linux centos apche linux运营 centos网站部署

linux入门系列20--Web服务之LNMP架构实战

黑马腾云

php MySQL Linux centos ngnix

SpringBoot+Tess4j实现牛逼的OCR识别工具

小隐乐乐

算法导论

华宇法律科技

算法

【持续更新~】常遗忘却可以变更好的心态

CoderJ

个人成长

linux入门系列17--邮件系统之Postfix和Dovecot

黑马腾云

Linux centos Dovecot Postfix 邮件系统

oeasy教您玩转linux010108到底哪个which

o

二叉树深度和大文件排序

escray

学习 面试 面试题 面试现场

ARTS Week9

丽子

ARTS 打卡计划

Go: ElasticSearch客户端学习

陈思敏捷

go golang elasticsearch elastic go-elasticsearch

架构师第十二周学习总结

傻傻的帅

linux入门系列16--文件共享之Samba和NFS

黑马腾云

Linux centos linux运维 Samba NFS

视频AI第一步-动作识别数据集

flow

图解Node(上)——直击灵魂的十条拷问

执鸢者

前端 原理 Node

搬家,又一次和过往告别

王磊

程序人生

架构师第十二周作业

傻傻的帅

程序的机器级表示-控制

引花眠

计算机基础

linux入门系列18--Web服务之Apache服务2

黑马腾云

Apache Linux centos linux运维

打通微信和钉钉服务是一种怎样的体验?

Ceelog

go 微信 钉钉 微信公众号

你真的懂 Java 的 main 方法吗

Rayjun

Java

巧用HashMap一行代码统计单词出现次数

程序那些事

jdk hashmap 统计字数

央行数字货币钱包上线后又秒关 兑换了的 DCEP 别担心

CECBC区块链专委会

数字货币 央行

《分布式对象存储》作者手把手教你写 GO 语言单元测试-InfoQ