《分布式对象存储》作者手把手教你写 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 := 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 语言单元测试!的更多相关文章
- [原创]手把手教你写网络爬虫(4):Scrapy入门
手把手教你写网络爬虫(4) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 上期我们理性的分析了为什么要学习Scrapy,理由只有一个,那就是免费,一分钱都不用花! 咦?怎么有人扔西红柿 ...
- [原创]手把手教你写网络爬虫(5):PhantomJS实战
手把手教你写网络爬虫(5) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 大家好!从今天开始,我要与大家一起打造一个属于我们自己的分布式爬虫平台,同时也会对涉及到的技术进行详细介绍.大 ...
- 只有20行Javascript代码!手把手教你写一个页面模板引擎
http://www.toobug.net/article/how_to_design_front_end_template_engine.html http://barretlee.com/webs ...
- [原创]手把手教你写网络爬虫(7):URL去重
手把手教你写网络爬虫(7) 作者:拓海 摘要:从零开始写爬虫,初学者的速成指南! 封面: 本期我们来聊聊URL去重那些事儿.以前我们曾使用Python的字典来保存抓取过的URL,目的是将重复抓取的UR ...
- 手把手教你写Kafka Streams程序
本文从以下四个方面手把手教你写Kafka Streams程序: 一. 设置Maven项目 二. 编写第一个Streams应用程序:Pipe 三. 编写第二个Streams应用程序:Line Split ...
- 手把手教你写DI_0_DI是什么?
DI是什么? Dependency Injection 常常简称为:DI. 它是实现控制反转(Inversion of Control – IoC)的一个模式. fowler 大大大神 "几 ...
- 手把手教你写Sublime中的Snippet
手把手教你写Sublime中的Snippet Sublime Text号称最性感的编辑器, 并且越来越多人使用, 美观, 高效 关于如何使用Sublime text可以参考我的另一篇文章, 相信你会喜 ...
- 手把手教你写电商爬虫-第三课 实战尚妆网AJAX请求处理和内容提取
版权声明:本文为博主原创文章,未经博主允许不得转载. 系列教程: 手把手教你写电商爬虫-第一课 找个软柿子捏捏 手把手教你写电商爬虫-第二课 实战尚妆网分页商品采集爬虫 看完两篇,相信大家已经从开始的 ...
- 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接
本文原作者:“水晶虾饺”,原文由“玉刚说”写作平台提供写作赞助,原文版权归“玉刚说”微信公众号所有,即时通讯网收录时有改动. 1.引言 好多小白初次接触即时通讯(比如:IM或者消息推送应用)时,总是不 ...
随机推荐
- Red Hat Enterprise Linux(RHEL)中yum的repo文件详解
Yum(全称为 Yellow dog Updater, Modified)是一个在Fedora和RedHat以及CentOS中的Shell前端软件包管理器.基于RPM包管理,能够从指定的服务器自动下载 ...
- [学习总结] python语言学习总结 (一)
还是不多说话了.. 1.eval函数 用法:eval(expression, globals=None, locals=None) 解释:将字符串str当成有效的表达式来求值并返回计算结果. 就是可以 ...
- 手写IOC框架
1.IOC框架的设计思路 ① 哪些类需要我们的容器进行管理 ②完成对象的别名和对应实例的映射装配 ③完成运行期对象所需要的依赖对象的依赖
- 禁止MySQL开机自动启动的方法
这几天发现电脑卡机变慢了,还有一些卡,发现每次开机MySQL都会自动启动(明明我安装的时候选择了不开机自启,任务管理器启动列表中也没有,但就是自启了...) 1.打开服务列表 有两种方法,一是快捷键 ...
- 前端安全系列(二):如何防止CSRF攻击?
前端安全系列(二):如何防止CSRF攻击? 背景 随着互联网的高速发展,信息安全问题已经成为企业最为关注的焦点之一,而前端又是引发企业安全问题的高危据点.在移动互联网时代,前端人员除了传统的 XS ...
- k8s1.13.0二进制部署-node节点(四)
Master apiserver启用TLS认证后,Node节点kubelet组件想要加入集群,必须使用CA签发的有效证书才能与apiserver通信,当Node节点很多时,签署证书是一件很繁琐的事情, ...
- iOS开发遇到的坑之五--解决工程已存在plist表,数据却不能存入的问题
想写这篇博客其实在一两个月前开发遇见的时候就想把这个问题写成博客的,奈何自己一直懒外加一直没有时间,就把这个事情给耽搁了,好在当时知道下自己一定要把这个问题给描述出来,免得以后其他人遇到这个问题会纠结 ...
- cocos2dx通过ndk编译c++库
ndk编译c++库,然后通过jni调用实现重要代码封装,是安卓应用中最常用的技术,一方面可以将重要的代码实现隐藏,防止泄漏,也可以提高打包速度. ndk里面的sample文件夹中有很多实用的例子,其中 ...
- HTTP无状态协议和session原理(access_token原理)
无状态协议是指协议对务处理没有记忆能力.缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大.另一方面,在服务器不需要先前信息时它的应答就较快. Http协议不 ...
- C语言实现两数相加2018-09-23
/*给定两个非空链表来表示两个非负整数.位数按照逆序方式存储,它们的每个节点只存储单个数字.将两数相加返回一个新的链表. 你可以假设除了数字 0 之外,这两个数字都不会以零开头. 示例: 输入:(2 ...