第一部分:如何写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语言的测试框架能够自动发现我们所有的测试用例,

测试函数的签名也要遵循其特定的规则:

  1. 函数名必须以Test开头,后面通常是待测方法的函数名
  2. 参数必须是*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 := 10000
for 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) {
doSomSetup()
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的写法,让自己的生活更美好。

《分布式对象存储》作者手把手教你写 GO 语言单元测试!的更多相关文章

  1. [原创]手把手教你写网络爬虫(4):Scrapy入门

    手把手教你写网络爬虫(4) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 上期我们理性的分析了为什么要学习Scrapy,理由只有一个,那就是免费,一分钱都不用花! 咦?怎么有人扔西红柿 ...

  2. [原创]手把手教你写网络爬虫(5):PhantomJS实战

    手把手教你写网络爬虫(5) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 大家好!从今天开始,我要与大家一起打造一个属于我们自己的分布式爬虫平台,同时也会对涉及到的技术进行详细介绍.大 ...

  3. 只有20行Javascript代码!手把手教你写一个页面模板引擎

    http://www.toobug.net/article/how_to_design_front_end_template_engine.html http://barretlee.com/webs ...

  4. [原创]手把手教你写网络爬虫(7):URL去重

    手把手教你写网络爬虫(7) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 本期我们来聊聊URL去重那些事儿.以前我们曾使用Python的字典来保存抓取过的URL,目的是将重复抓取的UR ...

  5. 手把手教你写Kafka Streams程序

    本文从以下四个方面手把手教你写Kafka Streams程序: 一. 设置Maven项目 二. 编写第一个Streams应用程序:Pipe 三. 编写第二个Streams应用程序:Line Split ...

  6. 手把手教你写DI_0_DI是什么?

    DI是什么? Dependency Injection 常常简称为:DI. 它是实现控制反转(Inversion of Control – IoC)的一个模式. fowler 大大大神 "几 ...

  7. 手把手教你写Sublime中的Snippet

    手把手教你写Sublime中的Snippet Sublime Text号称最性感的编辑器, 并且越来越多人使用, 美观, 高效 关于如何使用Sublime text可以参考我的另一篇文章, 相信你会喜 ...

  8. 手把手教你写电商爬虫-第三课 实战尚妆网AJAX请求处理和内容提取

    版权声明:本文为博主原创文章,未经博主允许不得转载. 系列教程: 手把手教你写电商爬虫-第一课 找个软柿子捏捏 手把手教你写电商爬虫-第二课 实战尚妆网分页商品采集爬虫 看完两篇,相信大家已经从开始的 ...

  9. 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接

    本文原作者:“水晶虾饺”,原文由“玉刚说”写作平台提供写作赞助,原文版权归“玉刚说”微信公众号所有,即时通讯网收录时有改动. 1.引言 好多小白初次接触即时通讯(比如:IM或者消息推送应用)时,总是不 ...

随机推荐

  1. 51nod 1431 快乐排队

    题目来源: CodeForces 基准时间限制:1 秒 空间限制:131072 KB 分值: 80 难度:5级算法题  收藏  关注 有一群人在排队,如果某个人想排到前面去,可以花一元钱给直接站在他前 ...

  2. This implementation is not part of the Windows Platform FIPS validated cryptographic algorithms while caching

    今天运行自己的网站时报了这样一个错误,很是纳闷,这个网站运行了这么久,怎么报这个错呢,原来是做缓存的时候用到了基于windows平台的加密算法.解决方法如下: 删除注册表下的这个节点即可.删除HKEY ...

  3. 总结一下自己脑海里的JavaScript吧(一)--DOM模型

    今天是2019年6月25日,闲来无事,写一篇文章来看看自己脑袋里装了多少JavaScript知识! 这儿就第一章: 说起JavaScript,它是什么?后端脚本语言?前端编程语言?还是在网站浏览器上运 ...

  4. JavaScript中面向对象的三大特性(一个菜鸟的不正经日常)

    经过几天的学习,把jQuery给啃会了,但是运用的还不算特别熟练,总感觉自己在JavaScript方面的基础十分欠缺,所以继续拾起JavaScript,开始更好的编程之旅~ 今天学的是JavaScri ...

  5. 03等待多个线程返回WaitForMultipleObject

    二. WaitForMultipleObject 等待单个线程返回 1. 函数原型 DWORD WINAPI WaitForMultipleObjects( _In_ DWORD nCount, _I ...

  6. wdcp 使用说明总结(持续更新中。。。)

    wdcp 使用说明总结(持续更新中...) 1.移动文件时,如果是上一层,直接填写../即可

  7. GoF23种设计模式之行为型模式之中介者模式

    一.概述 使用一个中介对象来封装一系列的对象交互.中介者让各个对象无需显式地相互引用,从而达到解耦的效果.并且可以独立地改变它们之间的交互.二.适用性1.当一组对象以定义良好但复杂通信的时候.产生的相 ...

  8. 安装openstack同步数据库时出错解决方法

    错误提示:(2003, "Can't connect to MySQL server on 'controller' ([Errno -2] Name or service not know ...

  9. Markdown 使用锚点

    首先是建立一个跳转的连接: [说明文字](#jump) 然后标记要跳转到什么位置即可: <span id = "jump">跳转到这里:</span>

  10. Python之PyFrameObject动态执行环境

    Python虚拟机中的执行环境 Python的虚拟机实际上是在模拟操作系统运行可执行文件的过程,首先,我们先来讲一下普通的x86的机器上,可执行文件是以一种什么方式运行的. 图1-1 图1-1所展示的 ...