测试与性能
作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。使用 Go 语言的测试 框架,可以在开发的过程中就进行单元测试和基准测试。和 go build 命令类似,go test 命 令可以用来执行写好的测试代码,需要做的就是遵守一些规则来写测试。而且,可以将测试无缝 地集成到代码工程和持续集成系统里。
1. 测试
在计算机编程中,单元测试(Unit Testing )又称为模块测试,是针对程序模块(软件 设计的最小单位 )来进行正确性检验的测试工作。程序单元是应用的最小可测试部件,在 过程化编程中,一个单元就是单个程序,包括函数、过程等;对于面向对象编程, 最小单元就是方法,包括 基类(超类)、抽象类或者派生类 (子类)中的方法。
单元测试是用来测试包或者程序的一部分代码或者一组代码的函数。测试的目的是确认目标 代码在给定的场景下,有没有按照期望工作。
测试的目的自然是确认代码是否正常工作,例如测试代码是否可以成功地向数据库中 插入一条记录,这种测试叫作“正向路径”测试,就是在正常执行的情况下,保证代码不产生错误的测试
另外一种情况是测试代码是否会产生预期的错误,例如程序对数据库进行查询时没有找到任何结果,或者对数据库做了无效的更新,那么应该返回一个可以控制的错误,而不是导致程序崩渍,这种测试即为“负向路径”的测试场景,保证代码不仅会产生错误,而 且是预期的错误。
总之,不管如何调用或者执行代码,所写的代码行为都是可预期的测试才算通过。
在 Go 语言里有几种方法写单元测试:
基础测试(basic test)只使用一组参数和结果来测试 一段代码。
表组测试(table test)也会测试一段代码,但是会使用多组参数和结果进行测试。
也可以使用 些方法来模仿( mock )测试代码 要使用到的外部资源,如数据库或 者网络服务器 例如当外部资源不可用的时候,模拟这些资源的行为可以使测试正常进行。
最后, 在构建自己的网络服务时,有几种方法可以在不运行服务的情况下,调用服务的功能进行测试。
1.1 单元测试
testing是Go语言的一个Package,它提供自动化测试功能,通过 go test 命令能够自动执行如下形式的函数:
func TestXxx( *testing .T)
其中 Xxx 可以是任何字母、数字、字符串,但是 Xxx 的第一个字母不能是小写字母(即对外可访问)。 在这些函数中,使用 Error、Fail或相关方法来返回失败信号。
要编写 个新的测试模块,需要创建 个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数,最后将该文件放在与被测试的包相同的包目录中 。该文件将被排除在正常的程序包之外,但在运行go test命令时将被调用。运行go help test 或go help testflag可以了解更详细的信息。
1. 第一个测试函数
./02_testing/test01/test01.go
要测试的代码如下,下面的Age函数中,如果输入的参数值小于0则返回0,如果输入大于0则返回相应数值:
package main func Age(n int) int { if n > 0 { return n } n = 0 return n }
测试代码如下所示,现在测试的是如果输入小于0的数字,程序是否会返回相应的数字:
./02_testing/test01/test01_test.go
package main import "testing" func TestAge(t *testing.T) { var ( input = -100 exected = 0 ) actual:=Age(input) if actual!=exected { t.Errorf("Age(%d) = %d, 预期为 %d",input,actual,exected) } }
执行测试命令:
go test ./02_testing
$ go test ./02_testing/test01 ok go.standard.library.study/02_testing (cached)
这时候如果我们把Age函数修改了,当输入的参数小于0的时候,应该返回-1,测试函数不动,我们可以看到:
$ go test ./02_testing/test01 --- FAIL: TestAge (0.00s) test01_test.go:12: Age(-100) = -1, 预期为 0 FAIL FAIL go.standard.library.study/02_testing/test01 0.022s FAIL
这就是基础测试,下面来看表组测试,可以提供多组数据的测试方式。
2. 表组测试
测试讲究覆盖率,按照上面的方法,当要覆盖更多情况的时候,显然通过修改代码的方式很笨拙。这时候可以采用表组测试的方法写测试代码,标准库中有很多测试是使用这种方式写的。
例如,以下程序的作用是判断一个数字是否为素数:
./02_testing/test02/test02.go
package main // 大于1的自然数中,除了1和它本身以外不再有其他因数的数称为质数 func isPrime(value int) bool { if value <= 3 { return value >= 2 } if value%2 == 0 || value%3 == 0 { return false } for i := 5; i*i < value; i += 6 { if value%i==0||value%(i+2)==0 { return false } } return true }
./02_testing/test02/test02_test.go
package main import "testing" func TestIsPrime(t *testing.T) { var primeTests = []struct { input int // 输入 expected bool // 期望结果 }{ {1, false}, {2, true}, {3, true}, {4, false}, {5, true}, {6, false}, {7, false}, // 这个是错误用例 } for _, tt := range primeTests { actual := IsPrime(tt.input) if actual != tt.expected { t.Errorf("IsPrime(%d)=%v,预期为 %v",tt.input,actual,tt.expected) } } }
执行测试命令:
$ go test ./02_testing/test02 --- FAIL: TestIsPrime (0.00s) test02_test.go:25: IsPrime(7)=true,预期为 false FAIL FAIL go.standard.library.study/02_testing/test02 0.037s FAIL
上面测试中最后一个测试用例 错误的,所以执行测试时会返回测试不通过的结果,当然错误的原因并不是程序问题,而是测试用例的错误。
因为测试中使用的是t.Errorf,其中某个情况测试失败,并不会中止测试,其他测试用例会继续执行下去。在单元测试中,传递给测试函数的参数是*testing.T类型,它用于管理测试状态并支持格式化测试日志(测试日志会在执行测试的过程中不断累积,并在测试完成时输出到标准输出上)。
在一次测试中,测试函数执行结束返回,或者测试函数调用FailNow,Fatal,Fatalf,SkipNow,Skip,Skipf中的任意一个的时候,这次测试宣告结束,与Parallel方法一样,以上提及的这些方法只能在运行测试函数的goroutine中调用,而其他打印方法,比如Log,以及Error的变种,则可以在多个goroutine中同时调用。
下面总结了测试的几个方法的含义,当某个测试用例测试失败的时候,这些方法的后续动作分别如下:
Fail:记录失败信息,然后继续执行后续用例;
Failf:相比于前者多了个格式化输出;
FailNow:记录失败信息,所有测试中断;
Fatal:相当于Log+FailNow,会中断后续测试;
Fatalf:相比于前者多了个格式化输出;
Skip:不记录失败信息,中断后续测试;
Skipf:相比于前者多了个格式化输出;
SkipNow:不会记录失败的用例信息,然后终止测试;
Log:输出错误信息,在单元测试中,默认不输出成功的用例信息,不会中断后续测试;
Logf:相比于前者多了个格式化输出;
Error:相当于Log+Fail,不会中断后续测试;
Errorf:相比于前者多了个格式化输出;
在默认情况下,单元测试成功时,他们打印的信息不会输出,可以通过加上-v选项。
$ go test -v ./02_testing/test02 === RUN TestIsPrime test02_test.go:25: IsPrime(7)=true,预期为 false --- FAIL: TestIsPrime (0.00s) FAIL FAIL go.standard.library.study/02_testing/test02 0.022s FAIL
3. 模拟测试 △
单元测试的原则,就是你所测试的函数方法,不受依赖环境的影响,比如网络访问等。但有时候运行单元测试的时候需要联网,而由于开发环境限制,不能联网,此时就需要进行模拟网络访问来完成测试了。
1. HTTP mock
针对模拟网络访问,标准库提供了一个httptest包,可以模拟HTTP 的网络调用,下面举个例子了解如何使用:
./testing/test03/test03.go
package main import ( "encoding/json" "net/http" ) func Routers() { http.HandleFunc("/sendjson", SendJson) } // SendJson 发送JSON信息 func SendJson(rw http.ResponseWriter, r *http.Request) { u := struct { Name string }{"张三"} rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) json.NewEncoder(rw).Encode(u) }
非常简单,这里是一个/sendjson API,当访问这个API 的时候,会返回一个JSON 字符串。
现在对这个API服务进行测试,但又不能时时刻刻都启动服务,所以这里就用到了外部终端对API的网络访问请求:
./testing/test03/test03_test.go
package main import ( "log" "net/http" "net/http/httptest" "testing" ) func init() { Routers() } func TestSendJson(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/sendjson", nil) if err != nil { t.Fatal("创建Request失败!") } // ResponseRecorder 是 ResponseWriter的一个实现, // 它记录其突变,以便稍后在测试中检查。 rw := httptest.NewRecorder() http.DefaultServeMux.ServeHTTP(rw, req) log.Println("code: ",rw.Code) log.Println("body: ",rw.Body.String()) }
执行测试命令:
$ go test -v ./02_testing/test03 === RUN TestSendJson 2022/07/16 10:54:24 code: 200 2022/07/16 10:54:24 body:{"Name":"张三"} --- PASS: TestSendJson (0.02s) PASS ok go.standard.library.study/02_testing/test03 0.041s
可以看到程序自动访问/sendjson API 的结果,并且没有启动任何HTTP服务就达到了目的。
这里主要利用·httptest.NewRecorder()创建一个http.ResponseWriter,模拟了真实服务端的响应,这种响应是通过调用http.DefaultServerMux.ServerHttp()方法触发的。
还有一个模拟调用的方法,是真的在测试机上模拟一个服务器,然后进行调试:
./testing/test03_test.go
package main import ( "io/ioutil" "log" "net/http" "net/http/httptest" "testing" ) //... func mockServer() *httptest.Server { sendJson := SendJson // 适配器转换 return httptest.NewServer(http.HandlerFunc(sendJson)) } func TestSendJson(t *testing.T) { // 创建一个模拟的服务器 server := mockServer() defer server.Close() log.Println("server.URL: ", server.URL) // Get请求发往模拟服务器的地址 resp, err := http.Get(server.URL) if err != nil { t.Fatal("创建Get请求失败!") } defer resp.Body.Close() log.Println("code: ", resp.StatusCode) json, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatal(err) } log.Printf("body:%s \n", json) }
模拟服务器的创建使用的是httptest.NewServer函数,他接收一个http.Handler处理API请求的接口。代码示例中使用Handler的适配器模式,http.HandlerFunc是一个函数类型,实现了http.Hanler接口,注意这里的http.HandlerFunc(sendJson)是强制类型转换,不是函数的调用!!!
这个创建的模拟服务器,监听的是本机IP:127.0.0.1 ,端口时随机的。
接着发送Get请求的时候,不再发往/sendjson,而是模拟服务器的地址server.URL,剩下的就和访问正常的URL一样了,打印出结果即可;
执行测试命令:
$ go test -v ./02_testing/test03 === RUN TestSendJson 2022/07/16 10:54:24 code: 200 2022/07/16 10:54:24 body:{"Name":"张三"} --- PASS: TestSendJson (0.02s) PASS ok go.standard.library.study/02_testing/test03 (cached)
2. 数据库 mock
除了网络依赖之外,我们在开发中也会经常用到各种数据库,比如常见的MySQL和Redis等。该部分就分别举例来演示如何在编写单元测试的时候对MySQL和Redis进行mock。
mysql:go-sqlmock
sqlmock 是一个实现 sql/driver 的mock库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql语句的执行结果。
1.安装
go get github.com/DATA-DOG/go-sqlmock
2.使用示例:
这里使用的是go-sqlmock官方文档中提供的基础示例代码。
在下面的代码中,我们实现了一个recordStats函数用来记录用户浏览商品时产生的相关数据。
具体实现的功能是在一个事务中进行以下两次SQL操作:
在products表中将当前商品的浏览次数+1
在product_viewers表中记录浏览当前商品的用户id
./02_testing/test07/test07.go
package main import ( "database/sql" "log" ) // recordStats 记录用户浏览产品信息 func recordStats(db *sql.DB, userID, productID int64) (err error) { // 开启事务, 操作views和product_viewers两张表 tx, err := db.Begin() if err != nil { log.Println(err) return err } // 没有错误就提交,否则回滚 defer func() { switch err { case nil: err = tx.Commit() default: tx.Rollback() } }() // 更新products表 updataSql := "UPDATE products SET views = views + 1" if _, err = tx.Exec(updataSql); err != nil { return err } // product_viewers表中插入一条数据 insertSql := "INSERT INTO product_viewers (user_id,product_id) VALUES (?,?))" if _, err = tx.Exec(insertSql, userID, productID); err != nil { log.Printf("SQL Exec error:%v", err) return err } return err }
./02_testing/test07/test07_test.go
package main import ( "fmt" "github.com/DATA-DOG/go-sqlmock" "testing" ) // TestShouldUpdateStats sql执行成功的测试用例 func TestShouldUpdateStats(t *testing.T) { // mock一个*sql.DB对象,不需要连接真实的数据库 db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error %s was not expected when opening a stub database connection", err.Error()) } defer db.Close() mock.ExpectBegin() // 指定你期望(Expectations)执行的语句,以及假定的返回结果(WillReturnResult)。 // 这里假定会返回(1, 1),也就是自增主键为1,1条影响结果 mock.ExpectExec("UPDATE products"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO product_viewers"). WithArgs(2, 3). // 使用2 3作为参数 WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() // 将mock的DB对象传入我们的函数中 if err = recordStats(db, 2, 3); err != nil { t.Errorf("error was not expected while updating stats: %s", err) } // 确保期望的结果都满足 if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } } // TestShouldRollbackStatUpdatesOnFailure sql执行失败回滚的测试用例 func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer db.Close() mock.ExpectBegin() mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1)) // 与上面的成功测试不同 在于这里是 WillReturnError mock.ExpectExec("INSERT INTO product_viewers"). WithArgs(2, 3). // 使用2 3作为参数 WillReturnError(fmt.Errorf("some error")) // 允许为预期的数据库执行操作设置错误 mock.ExpectRollback() // 将mock的DB对象传入我们的函数中 if err = recordStats(db, 2, 3); err == nil { t.Errorf("was expecting an error, but there was none") } // 确保期望的结果都满足 if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
上面的代码中,定义了一个执行成功的测试用例和一个执行失败回滚的测试用例,确保我们代码中的每个逻辑分支都能被测试到,提高单元测试覆盖率的同时也保证了代码的健壮性。
执行单元测试,看一下最终的测试结果:
$ go test -v ./02_testing/test07 === RUN TestShouldUpdateStats --- PASS: TestShouldUpdateStats (0.00s) === RUN TestShouldRollbackStatUpdatesOnFailure 2022/07/17 18:03:01 SQL Exec error:some error --- PASS: TestShouldRollbackStatUpdatesOnFailure (0.01s) PASS ok go.standard.library.study/02_testing/test07 (cached)
可以看到两个测试用例的结果都符合预期,单元测试通过。
在很多使用ORM工具的场景下,也可以使用go-sqlmock库mock数据库操作进行测试。
redis:miniredis
除了经常用到MySQL外,Redis在日常开发中也会经常用到。
**miniredis**是一个纯go实现的用于单元测试的redis server。它是一个简单易用的、基于内存的redis替代品,它具有真正的TCP接口,你可以把它当成是redis版本的net/http/httptest。
当我们为一些包含Redis操作的代码编写单元测试时就可以使用它来mock Redis操作。
1.安装:
go get -u github.com/alicebob/miniredis
2.使用示例:
这里以github.com/go-redis/redis库为例,编写了一个包含若干Redis操作的DoSomethingWithRedis函数。
02_testing/test08/test08.go
package test08 import ( "github.com/go-redis/redis" "log" "strings" "time" ) const ( KeyValidWebsite = "app:valid:website:list" ) func DoSomethingWithRedis(rdb *redis.Client, key string) bool { // 这里可以是对redis操作的一些逻辑 // TODO返回一个非空的上下文。 // 代码应该在不清楚要使用哪个上下文或者它还不可用的时候使用这个函数。 //ctx:=context.TODO() // 判断成员元素是否是集合的成员 if !rdb.SIsMember(KeyValidWebsite, key).Val() { return false } val, err := rdb.Get(key).Result() if err != nil { log.Println("no such key") return false } if !strings.HasPrefix(val, "https://") { val = "https://" + val } // 设置 blog key 5秒过期 if err := rdb.Set("blog", val, 5*time.Second).Err(); err != nil { return false } return true }
下面的代码是使用miniredis库为DoSomethingWithRedis函数编写的单元测试代码,其中miniredis不仅支持mock常用的Redis操作,还提供了很多实用的帮助函数,例如检查key的值是否与预期相等的s.CheckGet()和帮助检查key过期时间的s.FastForward()。
./02_testing/test08/test08_test.go
package test08 import ( "github.com/alicebob/miniredis" "github.com/go-redis/redis" "testing" "time" ) func TestDoSomethingWithRedis(t *testing.T) { // mock一个redis server mockRedisServer, err := miniredis.Run() if err != nil { t.Errorf("mock redis server error: %v", err) } defer mockRedisServer.Close() // 准备数据 mockRedisServer.Set("q1mi", "liwenzhou.com") mockRedisServer.SetAdd(KeyValidWebsite, "q1mi") // 连接mock的redis server rdb := redis.NewClient(&redis.Options{ Addr: mockRedisServer.Addr(), // mock redis server的地址 }) // 调用函数 ok:=DoSomethingWithRedis(rdb,"qimi") if !ok { t.Fatal() } // 可以手动检查redis中的值是否复合预期 if got, err := mockRedisServer.Get("blog"); err != nil || got != "https://liwenzhou.com" { t.Fatalf("'blog' has the wrong value") } // 也可以使用帮助工具检查 mockRedisServer.CheckGet(t, "blog", "https://liwenzhou.com") // 过期检查 mockRedisServer.FastForward(5 * time.Second) // 快进5秒 if mockRedisServer.Exists("blog") { t.Fatal("'blog' should not have existed anymore") } }
执行执行测试,查看单元测试结果:
$ go test -v ./02_testing/test08 === RUN TestDoSomethingWithRedis --- PASS: TestDoSomethingWithRedis (0.00s) PASS ok go.standard.library.study/02_testing/test08 0.559s
miniredis基本上支持绝大多数的Redis命令,大家可以通过查看文档了解更多用法。
当然除了使用miniredis搭建本地redis server这种方法外,还可以使用各种打桩工具对具体方法进行打桩。在编写单元测试时具体使用哪种mock方式还是要根据实际情况来决定。
4. 测试覆盖率
尽可能模拟更多的情况来测试代码的不同情况,但是有时候的确也有忘记测试的代码,这时候就需要测试覆盖率作为参考了。
由单元测试的代码,触发运行的被测试代码的占所有代码行数的比例,被称为测试覆盖率,代码覆盖率不一定完全精准,但是可以作为参考,可以有助于测试和预计覆盖率之间的差距,go test工具就提供了这样一个度量测试覆盖率的能力。
依旧使用之前的素数判断的程序./02_testing/test02.go(注意把7对应的值修改为true,不然无法通过测试),现在使用go test工具运行单元测试,和前几次不一样的是,要显示测试覆盖率,所以要多加一个参数-coverprofile,完整的命令为go test -v -coverprofile="./02_testing/c.out" ./02_testing,-coverprofile是指定生成的覆盖率文件,例子中是c.out,这个文件稍后会用到。
现在看终端输出,已经比刚才多出了一个覆盖率了:
$ go test -v -coverprofile="./02_testing/test02/c.out" ./02_testing/test02 === RUN TestIsPrime --- PASS: TestIsPrime (0.00s) PASS coverage: 75.0% of statements ok go.standard.library.study/02_testing/test02 0.037s coverage: 75.0% of statements
现在测试覆盖率为75.0%,还没有到100%,那么看看还有那些代码没有被测试到。
这就需要刚刚生成的测试覆盖率文件c.out生成的测试覆盖率报告了。生成报告使用Go提供的工具go tool cover -html=./02_testing/test02/c.out -o=tag.html,即可生成一个名字为tag.html的HTML格式的测试覆盖率报告,使用浏览器打开之后如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WXSBuTzM-1658054513453)(images/image-20220716110358669.png)]
这里有详细的信息告诉我们哪行代码被测试了,哪行代码没有被测试到。
可以看到标记为绿色的代码表示已经被测试了,标记为红色的表示还没有被测试到,现在根据没有被测试到的代码逻辑,完善单元测试代码即可(例如新增一个测试实例:{25, false},即可100%覆盖isPrime函数)