目录

本文源码已上传 Github

什么是单测

单元测试通常是由软件开发⼈员编写和运⾏的⾃动测试,以确保应⽤程序的某个部分(称为“单元”)符合其设计并按预期运⾏。在过程式编程中,⼀个单元可以是⼀个完整的模块,但它更常⻅的是⼀个单独的函数或过程。在⾯向对象的编程中,单元通常是整个接⼝,例如类或单个⽅法。通过⾸先为最⼩的可测试单元编写测试,然后为它们之间的复合⾏为编写测试,可以为复杂的应⽤程序建⽴全⾯的测试。

在开发过程中,软件开发⼈员可能会将标准或已知良好的结果编码到测试中,以验证单元的正确性。在测试⽤例执⾏期间,框架会记录不符合任何条件的测试,并在摘要中报告这些测试。为此,最常⽤的⽅法是测试 - 函数 - 期望值。

概括:通过编写测试代码,并对函数返回结果进行断言,对函数和方法功能进行自动测试,并报告测试结果。

编写单测的好处
  • 发现单元代码逻辑问题

  • 强迫开发人员降低单个函数大小

  • 方便对迭代/重构后的代码进行快速自动化回归测试

  • 提升开发人员自信!

何为有效单测
  • 原则上单测覆盖率应不少于 80%,核心模块或底层逻辑应保证 95% 以上甚至100%

  • 一个 case 一个用例,特别是 || 场景

  • 一定要对结果进行断言

单测函数
testinggo test
func TestXxx(*testing.T)

注意:Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。

关于测试日志打印,有以下几个方法(src/testing/testing.go):

  • Log:调用 fmt.Sprintln 打印信息
  • Logf:调用 fmt.Sprintf 打印信息
  • Error:调用 fmt.Sprintln 打印日志,标记错误,执行后续代码
  • Errorf:调用 fmt.Sprintf 打印日志,标记错误,执行后续代码
  • Fatal:调用 fmt.Sprintln 打印日志,标记错误,停止执行
  • Fatalf:调用 fmt.Sprintf 打印日志,标记错误,停止执行

当某些测试我们不想执行,但又不想注释代码,可以使用 Ship 方法在当前测试中标记为不执行:

测试命令

测试命令有很多参数,比如 -v 打印详细信息:

go test -v

-cover 打印测试覆盖度:

go test -cover

即打印详情又打印测试覆盖度:

go test -v -cover

-coverprofile=cover.out 保存测试信息到文件,并用不同形式打印

go test -coverprofile=cover.out

go tool cover -func=cover.out

go tool cover -html=cover.out

组和命令生成 html 文件:

go test -gcflags=-l -coverprofile=cover.out && go tool cover -html=cover.out -o cover.html

指定函数:

go test -count=1 -race -v -cover -run ^TestFunc

递归执行整个项目:

go test -count=1 -race -gcflags=-l -coverpkg=./... -coverprofile=cover.out ./... && go tool cover -func=cover.out -o cover.txt && cat cover.txt
go test -count=1 -race -gcflags=-l -cover $(go list ./...) -coverprofile=cover.out ./... && go tool cover -func=cover.out -o cover.txt && cat cover.txt

递归执行整个项目,并忽略 mock 和 vendor 目录:

go test -count=1 -race -gcflags=-l -cover $(go list ./... ) -coverprofile=cover.out ./... && go tool cover -func=cover.out -o cover.txt && cat cover.txt

单用例

unit.go

package unit

func Compare(a, b int) int {
	if a > b {
		return 1
	} else if a == b {
		return 0
	} else {
		return -1
	}
}

unit_test.go

package unit

import "testing"

func TestCompare(t *testing.T) {
	a := 1
	b := 2
	want := -1

	actual := Compare(a, b)
	if actual != want {
		t.Errorf("want %d, actual %d", want, actual)
	}
}

命令行打印

$ go test -v -cover
=== RUN   TestCompare
--- PASS: TestCompare (0.00s)
PASS
coverage: 60.0% of statements
ok      gotest/unit     0.559s

html直观展示

$ go test -coverprofile=cover.out
PASS
coverage: 60.0% of statements
ok      gotest/unit     0.350s

$ ll
total 24
-rw-r--r--  1 weihaoyu  staff  176 Jan  7 21:53 cover.out
-rw-r--r--  1 weihaoyu  staff  124 Jan  7 20:55 unit.go
-rw-r--r--  1 weihaoyu  staff  191 Jan  7 21:18 unit_test.go

$go tool cover -html=cover.out

会自动在浏览器弹出如下页面:

灰色代表不计入覆盖度统计,红色代表未覆盖,绿色代表已覆盖。

服务器直观展示

假如我们在服务器上在线开发,没有浏览器,想要在浏览器直观地看覆盖率,有办法么?

答案肯定是有的,我们可以先用下面的组合命令生成 html 文件:

go test -coverprofile=cover.out && go tool cover -html=cover.out -o cover.html

然后在对应目录通过 python 的 SimpleHTTPServer 将向目目录作为 http 服务暴露出来:

python -m SimpleHTTPServer 8888

浏览器输入地址+端口号:

www.airgo.com:8888

点击 cover.html 文件即可

我们可以看到上面覆盖度只有 60%,好的单元测试是要覆盖到 80% 以上,那我们需要为每个用力写测试代码么?

有没有一种方法可以定义一个用例组,然后通过循环遍历执行呢?

没错,有!我们可以用下面的 Table-Driven Test

多用例(Table-Driven Test)

思路就是我们上面的思路,定义一个包含如下信息的结构体:

  • name 用来描述当前用例场景
  • args 代表测试的函数参数
  • want 代表我们想要的结果

然后用 range 遍历所有用例,去覆盖到所有情况,我们的 unit_test.go 文件变成如下:

package gotest

import "testing"

func TestCompare(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{name:"a > b", args:args{2, 1}, want:1},
		{name:"a == b", args:args{2, 2}, want:0},
		{name:"a < b", args:args{1, 2}, want:-1},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Compare(tt.args.a, tt.args.b); got != tt.want {
				t.Errorf("Compare() = %v, want %v", got, tt.want)
			}
		})
	}
}

我们运行看一下结果,这里为了提高阅读体验,只贴命令行的结果:

$ go test -v -cover
=== RUN   TestCompare
=== RUN   TestCompare/a_>_b
=== RUN   TestCompare/a_==_b
=== RUN   TestCompare/a_<_b
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/a_>_b (0.00s)
    --- PASS: TestCompare/a_==_b (0.00s)
    --- PASS: TestCompare/a_<_b (0.00s)
PASS
coverage: 100.0% of statements

可以看到,覆盖度已经达到了 100% !

有的同学说了,这么多代码,那我每次测试写的代码比我函数还多,岂不是效率很低,有没有办法自动生成测试代码,答案是有,运行如下命令,会将生成的测试代码打印出来:

$ gotests -all unit.go
Generated TestCompare

package gotest

import "testing"

func TestCompare(t *testing.T) {
        type args struct {
                a int
                b int
        }
        tests := []struct {
                name string
                args args
                want int
        }{
                // TODO: Add test cases.
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        if got := Compare(tt.args.a, tt.args.b); got != tt.want {
                                t.Errorf("Compare() = %v, want %v", got, tt.want)
                        }
                })
        }
}

把文件复制到当前目录下创建好的 unit_test.go 文件即可,也可以用 -w 参数直接写入到指定文件中,自己再补全用例:

 gotests -all -w unit.go unit_test.go

如果用的是 GoLand 工具,可以用 Command + Shift + t 选择自动生成。

如果用的是 VSCode,可以用 Command + T 搜索 "> GO GUT"

goconvey测试工具

goconvey 是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多 Web 界面特性。

我们需要在代码中加上包引入代码:

import . "github.com/smartystreets/goconvey/convey"
Convey*testing.T

我们用 Table-Driven Test 进行多用例覆盖,循环内包裹 Convey。

SoShouldEqual,表示
Convey

最终 unit_test.go 代码如下:

package gotests

import (
	"testing"

	. "github.com/smartystreets/goconvey/convey"
)

func TestCompare(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{name:"a > b", args:args{2, 1}, want:1},
		{name:"a == b", args:args{2, 2}, want:0},
		{name:"a < b", args:args{1, 2}, want:-1},
	}
	for _, tt := range tests {
		Convey(tt.name, t, func(){
			want := Compare(tt.args.a, tt.args.b)
			So(want, ShouldEqual, tt.want)
		})
	}
}

在命令行运行一下:

$go test -v -cover
=== RUN   TestCompare

  a > b ✔


1 total assertion


  a == b ✔


2 total assertions


  a < b ✔


3 total assertions

--- PASS: TestCompare (0.00s)
PASS
coverage: 45.5% of statements
ok      gotest 0.297s

我们上面说了,goconvey 是一个强大的测试框架,支持很多 Web 界面特性,我们可以在测试的包目录内直接执行 goconvey 命令,会自动弹出  Web 页面执行测试:

TestMain

包级别方法,当前包内单测执行前会先执行该方法一次,典型应用场景:加载全局资源变量,比如下面的日志组件:

package test
 
import (
    "testing"
 
    "xxx/log"
    "xxx/resource"
)
 
func TestMain(m *testing.M) {
    resource.Logger = log.New(os.Stdout)
    m.Run()
}
基准测试

在 _test.go 结尾的测试文件中,如下形式的函数:

func BenchmarkXxx(*testing.B)
go test-bench

最关键几个参数如下:

  • -bench:后接正则表达式,用于匹配压测的函数,如果测试当前目录内所有函数,则用 -bench=.
  • -benchtime:后接每个函数执行多久,比如 -benchtime=3s 代表每个函数执行3秒
  • -benchmem:输出内存分配

代码如下:

package benchmark

import (
	"testing"
)

func MakeSliceWithoutAlloc() []int {
	var newSlice []int

	for i := 0; i < 100000; i++ {
		newSlice = append(newSlice, i)
	}
	return newSlice
}

func MakeSliceWithPreAlloc() []int{
	var newSlice []int

	newSlice = make([]int, 0, 100000)
	for i := 0; i < 100000; i++ {
		newSlice = append(newSlice, i)
	}
	return newSlice
}

func BenchmarkMakeSliceWithoutAlloc(b *testing.B) {
	for i := 0; i < b.N; i++{
		MakeSliceWithoutAlloc()
	}
}

func BenchmarkMakeSliceWithoutAlloc2(b *testing.B) {
	for i := 0; i < b.N; i++{
		MakeSliceWithPreAlloc()
	}
}

执行结果:

$ go test -bench=. -benchtime=1s -benchmem          
goos: darwin
goarch: amd64
pkg: gotest/benchmark
BenchmarkMakeSliceWithoutAlloc-8            2124            545671 ns/op         4654351 B/op         30 allocs/op
BenchmarkMakeSliceWithoutAlloc2-8           8560            141009 ns/op          802818 B/op          1 allocs/op
PASS
ok     benchmark        2.563s

对上述结果中的每一项,含义如下:

545671 ns/op4654351 B/op30 allocs/op
Example测试

主要通过对包内函数的举例,起到文档的作用,用 OutPut 标注输出, Unordered output 标注随即输出。

需要特殊注意,如果没有 OutPut 注释,运行 go test 则会跳过该 example。

一般我们用的很少,所以直接给出代码和运行结果:

example.go:

package example

import "fmt"

func SayHello() {
	fmt.Println("Hello World")
}

func SayGoodbye() {
	fmt.Println("Hello,")
	fmt.Println("goodbye")
}

func PrintNames() {
	students := make(map[int]string, 4)
	students[1] = "Jim"
	students[2] = "Bob"
	students[3] = "Tom"
	students[4] = "Sue"
	for _, value := range students {
		fmt.Println(value)
	}
}

example_test.go:

package example

func ExampleSayHello() {
	SayHello()
	// OutPut: Hello World
}

func ExampleSayGoodbye() {
	SayGoodbye()
	// 这个没有OutPut哦~
}

//乱序输出
func ExamplePrintNames() {
	PrintNames()
	// Unordered output:
	// Jim
	// Bob
	// Tom
	// Sue
}

运行如下,可以看到 SayGoodbye 未执行:

$ go test -v -cover
=== RUN   ExampleSayHello
--- PASS: ExampleSayHello (0.00s)
=== RUN   ExamplePrintNames
--- PASS: ExamplePrintNames (0.00s)
PASS
coverage: 80.0% of statements
ok     example  0.533s
预告

本文提到了基础的几个测试场景和使用,但我们真实环境中的测试远比这个复杂,涉及到函数的层级调用、MySQL 操作,Redis 操作、HTTP 调用等,优质的测试是不应该受环境和第三方依赖的,所以我们需要使用一些方法和工具进行 Mock,并且尽量减少开发量、尽量减少业务代码侵入,下一期我们将针对这几方面详细介绍,关注我,学习更多后端技术~

参考文章: