如何在go中写好单元测试

当你还在用postman测试你的api时,那表明你还没找到使用go的最佳姿势,阅读这篇文章,一起来了解下go内置的测试框架,这会对你有所帮助。

单元测试

单元测试是我们项目开发中不可缺少的一部分,如果一个go项目没有单元测试,且刚好项目交接到你手里,由你来维护,那会很棘手,没有测试,意味着无法掌控这个项目,它就像一个定时炸弹,随时会产生bug。作为程序员,我们必须好好了解下单元测试。

单元测试基本概念

  • 单元测试:应用中最小可测试部分,能够单独运行,用于被检测代码是否按照预期工作
  • 测试用例:是一组测试,包括输入,执行条件,以及预期结果等
  • 覆盖率:测试的度量,用来衡量代码被测试的比例
  • 测试驱动开发:先有测试,后再通过修改代码使测试通过的开发方式

单元测试的优点

  1. 易于调试
  2. 提前发现问题
  3. 短代码,简洁且高质量

可能有人会觉得写单元测试是一件很麻烦的事,认为浪费时间。但也许你写了单元测试,他能减少你项目出问题排错的时间,也能让你更好的运行指定的代码,更精准的找到问题。写单元测试其实为你带来了效率上的提升,并且在go中,为项目增加单元测试非常简单。

go内置测试框架

go官方包自带了测试框架,这不仅仅是go官方为了所有gopher能更方便的写测试,也直接证明了测试的重要性,官方直接把他丢进了std里,可见一斑。 在最新版本的go中,go团队加入了模糊测试,不过本篇文章只涉及单元测试,不会讲解基准测试以及模糊测试。

testing.T

在go中写单元测试,我们先写了解下 testing.T 这个类型以及其持有的方法

// TB is the interface common to T and B.
type TB interface {
    Cleanup(func())
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    Fail()
    FailNow()
    Failed() bool
    Fatal(args ...interface{})
    Fatalf(format string, args ...interface{})
    Helper()
    Log(args ...interface{})
    Logf(format string, args ...interface{})
    Name() string
    Skip(args ...interface{})
    SkipNow()
    Skipf(format string, args ...interface{})
    Skipped() bool
    TempDir() string
    
    // A private method to prevent users implementing the
    // interface and so future additions to it will not
    // violate Go 1 compatibility.
    private()
}

type T struct {
    common
    isParallel bool
    context    *testContext // For running tests and subtests.
}

var _ TB = (*T)(nil)

这里顺便给大家科普下,var _ TB = (*T)(nil) 这行语句,使用了编译时断言,如果 T 没有实现 TB 里定义的方法,那么编译就会报错,这样能让开发者及时发现问题,避免错误的发生。大家平常写代码也可以使用编译时断言来让自己的项目更加健壮。

常用方法

  • Logf:记录日志,提供代码测试时运行信息
  • Errorf:记录日志,但会让测试不能通过
  • Fatalf:记录日志,测试立即停止且测试失败
  • Skipf:记录日志,并跳过该测试函数
  • Cleanup:清理函数,资源的释放
  • Helper:辅助函数,打印文件行信息

官方例子

testing.T 看起来比较简单,老规矩,先上官方例子

package greetings

import (
    "testing"
    "regexp"
)

// TestHelloName calls greetings.Hello with a name, checking
// for a valid return value.
func TestHelloName(t *testing.T) {
    name := "Gladys"
    want := regexp.MustCompile(`\b`+name+`\b`)
    msg, err := Hello("Gladys")
    if !want.MatchString(msg) || err != nil {
        t.Fatalf(`Hello("Gladys") = %q, %v, want match for %#q, nil`, msg, err, want)
    }
}

// TestHelloEmpty calls greetings.Hello with an empty string,
// checking for an error.
func TestHelloEmpty(t *testing.T) {
    msg, err := Hello("")
    if msg != "" || err == nil {
        t.Fatalf(`Hello("") = %q, %v, want "", error`, msg, err)
    }
}

上面的例子大家应该都看得懂,我就不总结具体的测试流程了,这里主要是为了给大家展示在go中写单元测试是多么方便。

最佳实践

starting

在开始之前,我们要先了解go的测试规范

  • 文件名:前缀为测试代码的文件名,以 _test.go 结尾(go build 会忽略这些文件)
  • 文件位置:位于测试的代码同一 package
  • 函数名:Test 为前缀,后面是测试函数名,函数参数为 *testing.T

table test

table test 是一种很棒的写法,它能让你的测试代码足够清晰,让你的测试用例易于维护,该写法可以在各种库中见到。其大体流程为:

  1. 定义tests 为测试用例,其结构为匿名结构体切片 []struct{}
  2. 补充匿名结构体变量,定义好输入输出,丰富测试用例
  3. 遍历测试用例,调用测试方法,判断测试结果是否符合预期
  4. 使用 testing.T 里的方法记录日志或让测试失败

go源码 encoding/json/encode_test.go 里就采用了这种测试方式

func TestRoundtripStringTag(t *testing.T) {
	tests := []struct {
		name string
		in   StringTag
		want string // empty to just test that we roundtrip
	}{
		{
			name: "AllTypes",
			in: StringTag{
				BoolStr:    true,
				IntStr:     42,
				UintptrStr: 44,
				StrStr:     "xzbit",
				NumberStr:  "46",
			},
			want: `{
				"BoolStr": "true",
				"IntStr": "42",
				"UintptrStr": "44",
				"StrStr": "\"xzbit\"",
				"NumberStr": "46"
			}`,
		},
		{
			// See golang.org/issues/38173.
			name: "StringDoubleEscapes",
			in: StringTag{
				StrStr:    "\b\f\n\r\t\"\\",
				NumberStr: "0", // just to satisfy the roundtrip
			},
			want: `{
				"BoolStr": "false",
				"IntStr": "0",
				"UintptrStr": "0",
				"StrStr": "\"\\u0008\\u000c\\n\\r\\t\\\"\\\\\"",
				"NumberStr": "0"
			}`,
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			// Indent with a tab prefix to make the multi-line string
			// literals in the table nicer to read.
			got, err := MarshalIndent(&test.in, "\t\t\t", "\t")
			if err != nil {
				t.Fatal(err)
			}
			if got := string(got); got != test.want {
				t.Fatalf(" got: %s\nwant: %s\n", got, test.want)
			}

			// Verify that it round-trips.
			var s2 StringTag
			if err := Unmarshal(got, &s2); err != nil {
				t.Fatalf("Decode: %v", err)
			}
			if !reflect.DeepEqual(test.in, s2) {
				t.Fatalf("decode didn't match.\nsource: %#v\nEncoded as:\n%s\ndecode: %#v", test.in, string(got), s2)
			}
		})
	}
}

mock test

当我们由于某些原因,不好直接调用我们的函数去做测试时,我们应该如何做呢?答案就是 interface ,如果我们的测试函数输入刚好是 interface 时,那很棒,如果不是呢,考虑下将函数参数抽象为 interfae ,是否你的代码会更好。
直接看下面的例子,这也是来自go源码 io/io_test.go

type zeroErrReader struct {
	err error
}

func (r zeroErrReader) Read(p []byte) (int, error) {
	return copy(p, []byte{0}), r.err
}

type errWriter struct {
	err error
}

func (w errWriter) Write([]byte) (int, error) {
	return 0, w.err
}

// In case a Read results in an error with non-zero bytes read, and
// the subsequent Write also results in an error, the error from Write
// is returned, as it is the one that prevented progressing further.
func TestCopyReadErrWriteErr(t *testing.T) {
	er, ew := errors.New("readError"), errors.New("writeError")
	r, w := zeroErrReader{err: er}, errWriter{err: ew}
	n, err := Copy(w, r)
	if n != 0 || err != ew {
		t.Errorf("Copy(zeroErrReader, errWriter) = %d, %v; want 0, writeError", n, err)
	}
}

这里通过 zeroErrReadererrWriter mock数据,分别实现了 io.Reader 以及 io.Writer ,当我们写测试时,具体怎样mock取决于你想测试的东西。

dependency injection

有些时候,我们的测试需要外部依赖,例如我们需要数据库实例或者http server,这时候我们可以利用 TestMain 的特性
来看看go源码 net/http/main_test.go

func TestMain(m *testing.M) {
	setupTestData()
	installTestHooks()

	st := m.Run()

	testHookUninstaller.Do(uninstallTestHooks)
	if testing.Verbose() {
		printRunningGoroutines()
		printInflightSockets()
		printSocketStats()
	}
	forceCloseSockets()
	os.Exit(st)
}

执行测试的时候,会优先执行 TestMain ,然后再通过 m.Run() 执行其他的测试,最好释放我们的资源,这样就解决了我们的资源依赖问题。这里给出一个模板参考,具体的 setup()teardown() 的实现由自己的项目代码所决定。

func setup() {
    fmt.Printf("Setup")
}

func teardown() {
    fmt.Printf("Teardown")
}

func TestMain(m *testing.M) {
    setup()
    code := m.Run()
    teardown()
    os.Exit(code)
}

结语

这篇文章所讲的东西都是自己最近写单元测试的一些感悟,如果有错误可在下方评论指出,如果对你有帮助,我也很希望在评论区看到你的评论。 好了,到这里就结束了,感谢阅读!