码字不易,转载请附原链,搬砖繁忙回复不及时见谅。
目录
本文源码已上传 Github
什么是单测
单元测试通常是由软件开发⼈员编写和运⾏的⾃动测试,以确保应⽤程序的某个部分(称为“单元”)符合其设计并按预期运⾏。在过程式编程中,⼀个单元可以是⼀个完整的模块,但它更常⻅的是⼀个单独的函数或过程。在⾯向对象的编程中,单元通常是整个接⼝,例如类或单个⽅法。通过⾸先为最⼩的可测试单元编写测试,然后为它们之间的复合⾏为编写测试,可以为复杂的应⽤程序建⽴全⾯的测试。
在开发过程中,软件开发⼈员可能会将标准或已知良好的结果编码到测试中,以验证单元的正确性。在测试⽤例执⾏期间,框架会记录不符合任何条件的测试,并在摘要中报告这些测试。为此,最常⽤的⽅法是测试 - 函数 - 期望值。
概括:通过编写测试代码,并对函数返回结果进行断言,对函数和方法功能进行自动测试,并报告测试结果。
编写单测的好处
-
发现单元代码逻辑问题
-
强迫开发人员降低单个函数大小
-
方便对迭代/重构后的代码进行快速自动化回归测试
-
提升开发人员自信!
何为有效单测
-
原则上单测覆盖率应不少于 80%,核心模块或底层逻辑应保证 95% 以上甚至100%
-
一个 case 一个用例,特别是 || 场景
-
一定要对结果进行断言
单测函数
testing
为 Go 语言 package 提供自动化测试的支持。通过 go 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 界面特性。
源码和使用文档参考:https://github.com/smartystreets/goconvey/wiki
我们需要在代码中加上包引入代码:
import . "github.com/smartystreets/goconvey/convey"
使用 GoConvey 书写单元测试,每个测试用例需要使用 Convey
函数包裹起来。它接受的第一个参数为 string 类型的描述;第二个参数一般为 *testing.T
,即本例中的变量 t;第三个参数为不接收任何参数也不返回任何值的函数(习惯以闭包的形式书写)。
我们用 Table-Driven Test 进行多用例覆盖,循环内包裹 Convey。
最后,需要使用 So
语句来对条件进行判断。在本例中,我们只使用了 ShouldEqual,表示
值应该相等。有关详细的条件列表,可以参见 官方文档。
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
标志来执行,多个基准测试按照顺序运行,基准函数会运行目标代码 b.N 次。在基准执行期间,程序会自动调整 b.N 直到基准测试函数持续足够长的时间。
最关键几个参数如下:
- -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
对上述结果中的每一项,含义如下:
- -8:表示运行时对应的GOMAXPROCS的值
- 2124:基准测试的迭代总次数 b.N
545671 ns/op
:平均每次迭代所消耗的纳秒数4654351 B/op
:平均每次迭代内存所分配的字节数30 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,并且尽量减少开发量、尽量减少业务代码侵入,下一期我们将针对这几方面详细介绍,关注我,学习更多后端技术~
参考文章: