Go语言如何在没有实现功能的情况下写出完善的单元测试代码

背景

最近在研究用Go写一个自己的解释型语言,有一本书叫《Writing An Interpreter In Go》, 作者在讲解如何编写解释器的时候,都是从写一个_test.go开始的,也就是说作者习惯于先写单元测试,以测试驱动开发,其实这是一个非常好的习惯,不过,作者在写_test.go文件的时候,都是先假设这个结构体、函数已经存在了,并且没有把关键的对象抽象成接口,因此,作者在运行go test的时候,是无法完成测试的,因为连编译都过不了,必须一边完善代码,一边重复运行go test,一直到完成开发。

基于这种开发模式下,其实我更期望能有一个Mock实现,写测试代码的时候畅通无阻,即使是没有实现,也能把各个测试用例覆盖到,当真实的实现完成后,我们只需要把mock实现替换成真实的实现就好了。

这么做还带来另一个好处,如果公司有SDET岗位,则可以直接让测试人员编写单元测试,开发任务和测试任务可以并行。


gomock 框架

昨天闲着没事逛了逛 https://github.com/golang, 发现了一个非常有意思的框架: gomock, 官方的描述是,这是一个mocking framework, 在使用上也很简单,大致的步骤如下:

1、定义一个待实现的接口.

  type MyInterface interface {
    SomeMethod(x int64, y string)
    GetSomething() string
  }

2、使用mockgen生成mock代码.

3、测试:

func TestMyThing(t *testing.T) {
    mockCtrl := gomock.NewController(t)
    defer mockCtrl.Finish()
    
    mockObj := something.NewMockMyInterface(mockCtrl)
    mockObj.EXPECT().SomeMethod(4, "blah")
    mockObj.EXPECT().GetSomething.Return("haha")
}

看了这个步骤,想必大家应该猜到了,mocking framework 使用的是根据你的接口定义来自动生成一个Mock实现,我们还可以往这个实现里注入数据。

期望接口调用参数

我们可以通过 .EXPECT() 为这个mock对象注入期望值

mockObj.EXPECT().SomeMethod(4, "blah")
  • 测试通过:
mockObj.SomeMethod(4, "blah")
  • 测试不通过:
mockObj.SomeMethod(5, "bldah") // 此处调用时直接会抛出错误

对于参数类型的期望,在调用这个Mock函数的时候会直接抛错异常

期望返回值

.EXPECT() 同样可以为某个函数注入返回值

mockObj.EXPECT().GetSomething().Return("haha")
  • 测试通过:
if "haha" == mockObj.GetSomething() {
    // -> 执行到这里
    // ...
}
// ...
  • 测试不通过:
if "haha" == mockObj.GetSomething() {
    // -> 不会执行到这里
    // ...
}
// ...

功能强的还不止这个,如果我们在测试一个循环,希望的是每次调用 GetSomething() 都返回不同的值,该怎么办?

答案很简单,依次调用

gomock.InOrder(
    mockObj.EXPECT().GetSomething().Return("A"),
    mockObj.EXPECT().GetSomething().Return("B"),
    mockObj.EXPECT().GetSomething().Return("C"),
)

接下来,让我们来实战一下吧。


gomock 实战

我们以《Writing An Interpreter In Go》这本书中的 monkey 语言的 lexer 作为例子

我们看一下 monkey 的目录结构:

monkey> tree .
.
├── lexer
│   ├── lexer.go
│   ├── lexer_test.go
│   └── mock_lexer
│       └── mock_lexer.go
└── token
    └── token.go

Lexer和Token

type Lexer interface {
    NextToken() token.Token
}

lexer的功能很简单,每次调用NextToken(),都是返回下一个Token
Token的结构

type TokenType string

type Token struct {
    Type    TokenType   // 类型
    Literal string      // 内容
}

比如下面的go语句

var a = 1

lexter 在调用三次NextToken()后会得到三个Token, 依次是:

Token{VAR, var}
Token{IDENT, a}
Token{INT, 1}

测试思路

其实测试方法就是:给定一段代码,用Lexer解析后,能得到指定顺序的Token,而gomock是完全可以实现的。

使用gomock

安装gomock

go get github.com/golang/mock/gomock
go get github.com/golang/mock/mockgen

生成mock代码

mockgen -source lexer.go -destination  mock_lexer/mock_lexer.go

编写lexer_test.go

测试数据

input: 输入的语句
tokens: 期望的Token

func getTestData() (input string, tokens []token.Token) {
    input = `
let five = 5;
let ten = 10;
`

    tokens = []token.Token{
        {token.LET, "let"},
        {token.IDENT, "five"},
        {token.ASSIGN, "="},
        {token.INT, "5"},
        {token.SEMICOLON, ";"},
        {token.LET, "let"},
        {token.IDENT, "ten"},
        {token.ASSIGN, "="},
        {token.INT, "10"},
        {token.SEMICOLON, ";"},
        {token.EOF, ""},
    }

    return
}

生成一个真实的MonkeyLexer实例

当然,这里我们没有实现,所以返回是nil

func newMonkeyLexer(input string, excepts []token.Token, t *testing.T) (l Lexer, deferFN func()) {
    return nil, func() {}
    // return NewMonkeyLexer(input), func() {}
}

构建MockLexer实例

由于没写完真正的lexer, 那么我们就开始Mock吧

func newMockLexer(input string, excepts []token.Token, t *testing.T) (l Lexer, deferFN func()) {

    ctrl := gomock.NewController(t)

    // 生成一个Mock实例
    mockLexter := mock_lexer.NewMockLexer(ctrl)

    // 将期望值一次传递给 NextToken()
    // 每次调用 NextToken() 也会依次获得期望值
    for i := 0; i < len(excepts); i++ {
        mockLexter.EXPECT().NextToken().Return(excepts[i])
    }

    l = mockLexter
    
    // 用于清理
    deferFN = func() { ctrl.Finish() }

    return
}

为了方便在mock实例和真实实例之间进行切换,我们可以通过环境变量来控制当前的测试实例是什么,如果要使用mock进行测试,我们只需要在运行 go test 前执行:

> export GO_MOCK_TEST=1

> GO_MOCK_TEST=1 go test -v
func newLexer(input string, excepts []token.Token, t *testing.T) (l Lexer, deferFN func()) {
    env := os.Getenv("GO_MOCK_TEST")
    if env == "1" {
        t.Log("MOCK TEST ENABLED!!!")
        return newMockLexer(input, excepts, t)
    }

    return newMonkeyLexer(input, excepts, t)
}

以下是真正的测试代码,在没真实实现monkey lexer的情况下,我们可以写测试代码了,而且如果运行 go test -v 也是能通过的。

func TestNextToken(t *testing.T) {

    input, excepts := getTestData()

    l, fn := newLexer(input, excepts, t) // 生成 Lexer 对象

    defer fn() // 清理

    for i, tt := range excepts {
        tok := l.NextToken()  // 获取下一个Token

        if tok.Type != tt.Type {
            t.Fatalf("tests[%d] - tokentype wrong. expected=%q, got=%q",
                i, tt.Type, tok.Type)
        }

        if tok.Literal != tt.Literal {
            t.Fatalf("tests[%d] - literal wrong. expected=%q, got=%q",
                i, tt.Literal, tok.Literal)
        }
    }
}

下载完整的代码:
https://github.com/xujinzheng/monkey

测试

使用mock实例进行测试

monkey> cd lexer
lexer> GO_MOCK_TEST=1 go test -v

=== RUN   TestNextToken
--- PASS: TestNextToken (0.00s)
PASS
ok      github.com/xujinzheng/monkey/lexer  0.007s

使用真实实例测试

func newMonkeyLexer(input string, excepts []token.Token, t *testing.T) (l Lexer, deferFN func()) {
    return NewMonkeyLexer(input), func() {}
}

我们将测试数据修改一下,假设 ten=666, 但不修改期望值,让测试报错

input = `
let five = 5;
let ten = 666;
`

再次运行测试

monkey> cd lexer
lexer> go test -v

=== RUN   TestNextToken
--- FAIL: TestNextToken (0.00s)
    lexer_test.go:52: tests[8] - literal wrong. expected="10", got="666"
FAIL
exit status 1
FAIL    github.com/xujinzheng/monkey/lexer  0.008s

这里就报错了,说明我们的真实实例实现得有问题,需要修复这个BUG

gomock 的使用到这里就结束了,除了上面介绍到的一些功能,gomock 还有很多其他丰富的方法,大家可以去 GoDoc 获取更详细的接口信息。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,165评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,503评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,295评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,589评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,439评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,342评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,749评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,397评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,700评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,740评论 2 313
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,523评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,364评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,755评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,024评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,297评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,721评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,918评论 2 336

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,517评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,140评论 25 707
  • 1.Creating mock objects 1.1Class mocks idclassMock=OCMCla...
    奔跑的小小鱼阅读 2,572评论 0 0
  • 序言 要写出好的测试代码,必须精通相关的测试框架。对于Golang的程序员来说,至少需要掌握下面四个测试框架: G...
    Aedan阅读 2,275评论 0 0
  • 《盼夏》 萧萧场地无人笑, 瑟瑟春风落网摇。 踩踏忽如球友跳, 婀娜夏日任逍遥。
    有球有爱有生活阅读 247评论 0 1