跳过正文

GO单元测试实践

·585 字·3 分钟
Remy
作者
Remy
Bug的设计师,故障的制造机,P0的背锅侠。代码里的隐秘问题总能被我创造性地解锁。写代码如同解谜,有时谜底是惊喜,有时是惊吓。

前言
#

很多时候我们在编写测试的时候都会又很多争论,无论你是什么TDD还是BSD或者说单元测试没有用等等。所我在讨论如何对 GO 进行单元测试之前,我应该先解释一下我是如何看待单元测试的

  • Fast 单元测试要快
  • Independent 测试要相互独立,一次只测一个分支
  • Repeatable 测试不包含逻辑,可在任何环境下重复
  • Self-validating 只关注输入输出,不关注内部实现
  • Timely 测试应该及时编写,表达能力强,且容易阅读

以上就是我对单元测试理解的最基本的原则,在保证上述的原则后,我往往发现在真正进行单元测试时,同事们经常不知所措。我们接下来简单地讨论一下

一些 Go 语言当中实用的技巧
#

1. 不要使用任何测试框架
#

很多人喜欢上来在 Go 语言中使用一大堆单元测试框架,他们会帮助你使用什么 setup/teardown 或者一些BDD测试的帮助函数,有的还整了一些WEB界面。不要去使用他们,GO语言框架中内置了非常好的框架了。这些框架是让他人阅读你的测试时候造成非常大的障碍。

但你说 Go 中内建的这些东西没有类似 assert() ok() equals() 这些非常好用的断言,非常不方便。其实他们框架也没做什么,只需要简单的几行代码即可搞定他们

import (
	"fmt"
	"path/filepath"
	"runtime"
	"reflect"
	"testing"
)

// assert fails the test if the condition is false.
func assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
	if !condition {
		_, file, line, _ := runtime.Caller(1)
		fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...)
		tb.FailNow()
	}
}

// ok fails the test if an err is not nil.
func ok(tb testing.TB, err error) {
	if err != nil {
		_, file, line, _ := runtime.Caller(1)
		fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error())
		tb.FailNow()
	}
}

// equals fails the test if exp is not equal to act.
func equals(tb testing.TB, exp, act interface{}) {
	if !reflect.DeepEqual(exp, act) {
		_, file, line, _ := runtime.Caller(1)
		fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act)
		tb.FailNow()
	}
}

他们用起来也非常简单

func TestSometing(t *test.T) {
	value, err := DoSomething()
	ok(t, err)
	equals(t, 100, value)
}

这些代码无需任何依赖,让你的测试非常干净,不会让他们产生困扰

使用外部测试包进行单元测试
#

Go 当中推荐的单元测试是,每个测试使用 _test 后缀的文件书写,很多时候这样做不好,这让我们的项目结构非常的混乱。我们可以这么做

user.go

type User struct {
    id int
    Name string
}
func (u *User) Save() error {
    if u.id == 0 {
        return u.create()
    }
    return u.update()

}
func (u *User) create() error { ... }
func (u *User) update() error { ... }

tests/user_test.go

package myapp_test
import (
    "testing"
    . "app/user"
)
func TestUser_Save(t *testing.T) {
    u := &User{Name: "Susy Queue"}
    ok(t, u.Save())
}

建立测试依赖类型
#

在上一篇文章当中,我里面阐述了对与外部依赖的处理方法如何组织Go语言中的程序,这里我们就可以说一下测试时使用依赖注入的办法为什么能够有效的方便我们进行测试

比方说,我们业务依赖一个数据库, 我们首先对真正的数据库进行包装

type DB struct {
    *bolt.DB
}
func Open(path string, mode os.FileMode) (*DB, error) {
    db, err := bolt.Open(path, mode)
    if err != nil {
        return nil, err
    }
    return &DB{db}, nil
}

当我们需要进行单元测试时,我们可以建立一个假的数据库

type TestDB struct {
    *DB
}
// NewTestDB returns a TestDB using a temporary path.
func NewTestDB() *TestDB {
    // Retrieve a temporary path.
    f, err := ioutil.TempFile("", "")
    if err != nil {
        panic("temp file: %s", err)
    }
    path := f.Name()
    f.Close()
    os.Remove(path)
    // Open the database.
    db, err := Open(path, 0600)
    if err != nil {
        panic("open: %s", err)
    }
    // Return wrapped type.
    return &TestDB{db}
}
// Close and delete Bolt database.
func (db *TestDB) Close() {
    defer os.Remove(db.Path())
    db.DB.Close()
}

现在呢,在我们单元测试当中,我们就可以用假的数据库进行一系列的测试了

func TestDB_DoSomething(t *testing.T) {
    db := NewTestDB()
    defer db.Close()
    ...
}

由于我们架空了真实数据库,我们可以随意更改我们想要的数据而不是去依赖一个真实的数据库,我们可以专注测试我们业务逻辑,且我们的测试执行的非常快。这对我们测试是一个非常有用的技巧,我们避开了程序当中各种外部依赖。从而轻松的去测试我们的代码。

使用内联 interface
#

我经常使用 interface 对外部依赖接口化,但他们经常使我的代码异常复杂,因为绝大多数情况下,我的 interface 只有一个实现。后来我放弃了,却让我模拟外部依赖变得异常困难,我可能会使用 gomonkey 来当个拐棍。后来发现这样我的测试变得异常复杂。

直到我意识到,调用这应该创建接口,而不是被调用这提供接口。因为调用者可以准确声明它究竟想要什么。这彻底转变了我声明 interface 的思路

举个例子

package yo
type Client struct {}
// Send sends a "yo" to someone.
func (c *Client) Send(recipient string) error
// Yos retrieves a list of my yo's.
func (c *Client) Yos() ([]*Yo, error)

如果我的应用程序只需要 Send 那么只需要这样

package myapp
type MyApplication struct {
    YoClient interface {
        Send(string) error
    }
}
func (a *MyApplication) Yo(recipient string) error {
    return a.YoClient.Send(recipient)
}

当我使用它时

package main
func main() {
    c := yo.NewClient()
    a := myapp.MyApplication{}
    a.YoClient = c
    ...
}

当我们需要进行测试时,我们只需要实现我们需要的 Mock 实现

package myapp_test
// TestYoClient provides mockable implementation of yo.Client.
type TestYoClient struct {
    SendFunc func(string) error
}
func (c *TestYoClient) Send(recipient string) error {
    return c.SendFunc(recipient)
}
func TestMyApplication_SendYo(t *testing.T) {
    c := &TestYoClient{}
    a := &MyApplication{YoClient: c}
    // Mock our send function to capture the argument.
    var recipient string
    c.SendFunc = func(s string) error {
        recipient = s
        return nil
    }
    // Send the yo and verify the recipient.
    err := a.Yo("susy")
    ok(t, err)
    equals(t, "susy", recipient)
}

一些关于单元测试的思考
#

何时写?怎么写?
#

每次讨论关于单元测试的问题时,很多人都是人云亦云,最典型的就是TDD的告诉你,先写测试再写代码。话是没错,我们应该先思考如何使用,再思考如何实现。这样能帮助设计的代码更解耦。这是个非常好的习惯。但单元测试的本质仅仅就是一个工具,而非验收的标准。其本质是帮助更好的写代码和思考。很多人写的单元测试非常复杂,依赖了无数基础设施,配置等等。这种测试毫无意义甚至不如不写。上面只是介绍了再 Go 中写单元测试的一些小技巧。下面我介绍下我写单元测试时的一些方法,帮助你到底要不要写这个测试

写一个测试时,应该问问自己以下几个问题,如果你回答不是,拿你写的就不是单元测试

  1. 它是可以重复执行的
  2. 它是不依赖第三方包括数据库依赖的
  3. 任何人都可以一键执行的
  4. 它是完全隔离的,不受其他测试影响的
  5. 他的结果是稳定的,无论时间空间结果都是一样的
  6. 他的运行速度是非常快的

写一个单元测试后,你应该问问这个测试以下几个问题

  1. 我两周前写的一个测试,今天或几年后还能运行并获得相同的结果吗?
  2. 我两个月前写的测试,团队里其他成员还能运行他并能获取结果吗?
  3. 我写了一个测试,其他成员运行他需要了解基础设施如何设置吗?例如数据库/缓存/配置/dbconfig/config…..
  4. 我几分钟内能跑完所有测试吗?
  5. 我能几分钟写出一个基本测试吗?

如果你说不能,一样也不是单元测试,你可能需要重构你的代码和你的测试代码。你写的这个其实是个集成测试,集成测试不是不重要,而是应该尽量少。

设计大于测试
#

往往很多人从来没想过怎么设计你的系统就上手写代码,这种习惯间接导致了你无法书写单元测试,从而放弃进行单元测试。我以前也这样。现在我都往往先设计好再去写代码并测试。至于你说到底是先写测试还是先写代码,如果你先设计,谁先写其实都不是那么重要。

单元测试不是验收工具
#

很多管理者觉得单元测试就是工程师自测,是用来验收你的代码是否可以上限的。实际上单元测试从来都不能保障你的代码没有问题。它只是一个工具,甚至是一个协作工具。绝对不能当作验收来用?

为什么?

  • 单元测试是由工程师主观自己写的,既然是自己写的就一定会自己骗自己。
  • 单元测试只是测试一部分,仅仅编写单元测试是无法保障系统是可维护,设计完善的。

总结
#

这里我只是阐述了我在 Go 单元测试的实践当中一些总结。以上