图源:
软件测试也是软件开发的重要组成部分,本篇文章将探讨如何使用Go的标准库和第三方库对程序进行测试。
Go的标准库提供一个简单的包testing
用于构建测试用例,这里来看一个简单的程序:
package main
import "fmt"
func Add(a int, b int) int {
return a + b
}
func main() {
fmt.Printf("%d+%d=%d\n", 1, 2, Add(1, 2))
}
虽然可以在main
函数中对Add
方法进行一些简单的测试,但是并非总可以这么做,比如当前程序文件是入口文件,那么main
函数就是有意义的,无法用于测试。此外,将测试代码和正常代码混合在一起也不是个好主意,会产生一些额外问题。
要使用testing
包构建对应的测试用例也很简单,只要在对应代码的同一个目录下创建xxx_test.go
文件。在这个例子中,对应的测试用例代码如下:
package main
import "testing"
func TestAdd(t *testing.T) {
a := 1
b := 2
result := Add(a, b)
if result != (a + b) {
t.Error("result is not a + b")
}
}
测试用例以函数的形式定义,且以TestXXX
作为函数名。也就是说执行测试时,xxx_test.go
文件中所有以TestXXX
命名的函数都会被执行。
测试用例所在的函数会接收一个testing.T
类型的指针,可以通过该指针报告测试过程中产生的错误。就像上边示例中的t.Error
所作的那样。
testing
包本身并没有其它语言测试框架中常会采用的断言,所以只是使用简单的比较语句判断函数执行结果是否正确。
最后可以在代码所在目录下使用go test
命令执行测试:
❯ go test
PASS
ok github.com/icexmoon/go-notebook/ch18/sample 0.286s
但这样的测试结果信息太少,可以使用-v
参数输出详细信息:
❯ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok github.com/icexmoon/go-notebook/ch18/sample 0.040s
除了命令行的方式以外,VSCode中可以识别并直接执行测试用例:
点击测试用例左侧的小三角就可以执行相应的用例,并在下方输出结果。并且执行成功后左侧的小三角会变成√。
在一个用例中可以使用多组参数对目标函数进行验证:
package main
import "testing"
func TestAdd(t *testing.T) {
a := 1
b := 2
result := Add(a, b)
if result != 3 {
t.Error("1 + 2 is not 3")
}
a = -1
b = -3
result = Add(a, b)
if result != -4 {
t.Error("-1 + -3 is not -4")
}
}
为了验证结果,这里将Add
函数故意改错:
...
func Add(a int, b int) int {
return a * b
}
...
测试结果:
❯ go test -v
=== RUN TestAdd
main_test.go:10: 1 + 2 is not 3
main_test.go:16: -1 + -3 is not -4
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL github.com/icexmoon/go-notebook/ch18/sample2 0.286s
可以看到用例中所有的参数/返回值都得到了验证,如果要让用例在某个检测未通过后直接结束运行,可以使用Fatal
报告错误而不是Error
:
package main
import "testing"
func TestAdd(t *testing.T) {
a := 1
b := 2
result := Add(a, b)
if result != 3 {
t.Fatal("1 + 2 is not 3")
}
a = -1
b = -3
result = Add(a, b)
if result != -4 {
t.Fatal("-1 + -3 is not -4")
}
}
用例执行结果:
❯ go test -v
=== RUN TestAdd
main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL github.com/icexmoon/go-notebook/ch18/sample3 0.270s
跳过测试用例
有时候我们要编写测试用例的目标代码中可能包含还未完成编写的代码,比如:
...
func Mul(a int, b int) int {
//is developing...
return 0
}
...
此时如果编写对应的用例就会让用例执行结果出现Fail
,当然这种结果也可能是你需要的,这具体取决于项目对测试用例和版本控制的管理,但如果你不想要这种结果,可以使用Skip
方法跳过:
...
func TestMul(t *testing.T) {
t.Skip("is not finished yet.")
}
用例执行结果:
❯ go test -v
=== RUN TestAdd
main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
=== RUN TestMul
main_test.go:21: is not finished yet.
--- SKIP: TestMul (0.00s)
FAIL
exit status 1
FAIL github.com/icexmoon/go-notebook/ch18/sample4 0.303s
除了使用Skip
函数跳过整个用例以外,还可以结合go test
的"short模式"来精确控制,以实现在"short"模式下才跳过某些用例。
比如说测试目标代码如下:
...
func LongTimeAdd(a int, b int) int {
time.Sleep(3 * time.Second)
return a + b
}
...
用例:
package main
import "testing"
func TestLongTimeAdd(t *testing.T) {
if testing.Short() {
t.Skip("skipping LongTime func test in short mode")
}
if LongTimeAdd(1, 5) != 6 {
t.Error("result of LongTimeAdd(1, 5) is not 6")
}
}
用例中的testing.Short()
函数在测试时使用“short模式”时返回为true,所以可以用于判断是否处于“short模式”。
用一般模式执行用例:
❯ go test -v
=== RUN TestLongTimeAdd
--- PASS: TestLongTimeAdd (3.01s)
PASS
ok github.com/icexmoon/go-notebook/ch18/short 3.326s
"short模式":
❯ go test -v -short
=== RUN TestLongTimeAdd
main_test.go:7: skipping LongTime func test in short mode
--- SKIP: TestLongTimeAdd (0.00s)
PASS
ok github.com/icexmoon/go-notebook/ch18/short 0.049s
覆盖率
此外,执行用例时可以使用-cover
参数让结果输出用例的覆盖率:
❯ go test -v -cover
=== RUN TestAdd
main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
=== RUN TestMul
main_test.go:21: is not finished yet.
--- SKIP: TestMul (0.00s)
FAIL
coverage: 33.3% of statements
exit status 1
FAIL github.com/icexmoon/go-notebook/ch18/sample4 0.285s
并发
普通情况下,用例是顺序执行的,比如如果对下面的程序进行测试:
package main
import (
"time"
)
func LangTimeAdd1(a int, b int) int {
time.Sleep(1 * time.Second)
return a + b
}
func LangTimeAdd2(a int, b int) int {
time.Sleep(2 * time.Second)
return a + b
}
func LangTimeAdd3(a int, b int) int {
time.Sleep(3 * time.Second)
return a + b
}
func main() {
}
用例:
package main
import "testing"
func TestLangTimeAdd1(t *testing.T) {
if LangTimeAdd1(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
func TestLangTimeAdd2(t *testing.T) {
if LangTimeAdd2(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
func TestLangTimeAdd3(t *testing.T) {
if LangTimeAdd3(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
执行结果:
❯ go test -v
=== RUN TestLangTimeAdd1
--- PASS: TestLangTimeAdd1 (1.01s)
=== RUN TestLangTimeAdd2
--- PASS: TestLangTimeAdd2 (2.01s)
=== RUN TestLangTimeAdd3
--- PASS: TestLangTimeAdd3 (3.00s)
PASS
ok github.com/icexmoon/go-notebook/ch18/sample5 6.306s
三个用例顺序执行,执行时间分别是1、2、3秒,所以总共运行了6秒。
如同在中说过的,Go对并发有着很好的支持,而大多数用例显然也是不会互相影响,可以并行执行的。所以在testing
包中也可以使用并发来提高用例的执行效率。
要使用并发执行用例,也很简单,只要在用例中添加t.Parallel()
调用即可:
package main
import "testing"
func TestLangTimeAdd1(t *testing.T) {
t.Parallel()
if LangTimeAdd1(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
func TestLangTimeAdd2(t *testing.T) {
t.Parallel()
if LangTimeAdd2(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
func TestLangTimeAdd3(t *testing.T) {
t.Parallel()
if LangTimeAdd3(1, 2) != 3 {
t.Error("result of 1+2 is not 3")
}
}
执行用例时需要添加--parallel
参数:
❯ go test -v -parallel 3
=== RUN TestLangTimeAdd1
=== PAUSE TestLangTimeAdd1
=== RUN TestLangTimeAdd2
=== PAUSE TestLangTimeAdd2
=== RUN TestLangTimeAdd3
=== PAUSE TestLangTimeAdd3
=== CONT TestLangTimeAdd1
=== CONT TestLangTimeAdd3
=== CONT TestLangTimeAdd2
--- PASS: TestLangTimeAdd1 (1.01s)
--- PASS: TestLangTimeAdd2 (2.01s)
--- PASS: TestLangTimeAdd3 (3.00s)
PASS
ok github.com/icexmoon/go-notebook/ch18/sample6 3.293s
该参数用于指定启用的goroutine数目。
Parallel这个单词的意思是“并行、平行”。
基准测试
上面的测试用例实际上进行的都是功能测试(functional testing),实际上使用testing
包还可以实现基准测试(benchmarking),或者说性能测试。
这里使用之前在中介绍过的JSON解析的示例来说明,假如测试目标代码如下:
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
)
type Article struct {
Id int `json:"id"`
Content string `json:"content"`
Comments []Comment `json:"contents"`
Uid int `json:"uid"`
}
func (a *Article) String() string {
var comments []string
for _, c := range a.Comments {
comments = append(comments, c.String())
}
scs := strings.Join(comments, ",")
return fmt.Sprintf("Article(Id:%d,Content:'%s',Comments:[%s],Uid:%d)", a.Id, a.Content, scs, a.Uid)
}
type Comment struct {
Id int `json:"id"`
Content string `json:"content"`
Uid int `json:"uid"`
}
func (c *Comment) String() string {
return fmt.Sprintf("Comment(Id:%d,Content:'%s',Uid:%d)", c.Id, c.Content, c.Uid)
}
func StreamDecode(fileName string) Article {
fopen, err := os.Open(fileName)
if err != nil {
panic(err)
}
defer fopen.Close()
decoder := json.NewDecoder(fopen)
art := Article{}
err = decoder.Decode(&art)
if err != nil {
panic(err)
}
return art
}
func MemoryDecode(fileName string) Article {
fopen, err := os.Open(fileName)
if err != nil {
panic(err)
}
defer fopen.Close()
content, err := ioutil.ReadAll(fopen)
if err != nil {
panic(err)
}
art := Article{}
err = json.Unmarshal(content, &art)
if err != nil {
panic(err)
}
return art
}
func main() {
}
需要对其进行性能测试,可以编写如下的测试用例:
package main
import "testing"
func BenchmarkStreamDecode(b *testing.B) {
for i := 0; i < b.N; i++ {
StreamDecode("art.json")
}
}
func BenchmarkMemoryDecode(b *testing.B) {
for i := 0; i < b.N; i++ {
MemoryDecode("art.json")
}
}
可以看到,性能测试的用例以BenchmarkXXX
进行命名,且接收的是testing.B
类型的指针。
内容并不复杂,使用for
循环执行目标函数,循环次数由b.N
这个变量决定。该变量由go test
工具根据测试目标和平台情况自行决定具体数值,无法由用户直接指定。
测试执行结果:
❯ go test -v -bench .
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4 8784 131952 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4 8478 138802 ns/op
PASS
ok github.com/icexmoon/go-notebook/ch18/benchmark 4.106s
执行基准测试需要使用-bench
参数,并指定一个正则表达式来过滤当前目录下需要执行基准测试的文件,.
表示执行所有测试代码。
两者执行结果相似,流处理的方式使用了131952
纳秒,全部读入内存解析的方式使用了138802
纳秒。
一般情况下测试文件中不仅包含基准测试用例,还会包含功能测试用例,比如:
...
func TestStreamDecode(t *testing.T) {
art := StreamDecode("art.json")
art2 := Article{
Id: 1,
Content: "this is a art's content.",
Comments: []Comment{
{Id: 1, Content: "first comment content.", Uid: 1},
{Id: 2, Content: "second comment content.", Uid: 1},
{Id: 3, Content: "third comment content.", Uid: 2},
},
Uid: 1,
}
if !reflect.DeepEqual(art, art2) {
t.Error("decode result is not ok")
t.Log(art.String())
t.Log(art2.String())
}
}
func TestMemoryDecode(t *testing.T) {
art := MemoryDecode("art.json")
art2 := Article{
Id: 1,
Content: "this is a art's content.",
Comments: []Comment{
{Id: 1, Content: "first comment content.", Uid: 1},
{Id: 2, Content: "second comment content.", Uid: 1},
{Id: 3, Content: "third comment content.", Uid: 2},
},
Uid: 1,
}
if !reflect.DeepEqual(art, art2) {
t.Error("decode result is not ok")
}
}
此时执行基准测试的同时会执行功能测试:
❯ go test -v -bench .
=== RUN TestStreamDecode
--- PASS: TestStreamDecode (0.00s)
=== RUN TestMemoryDecode
--- PASS: TestMemoryDecode (0.00s)
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark2
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4 9204 127897 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4 8547 134553 ns/op
PASS
ok github.com/icexmoon/go-notebook/ch18/benchmark2 3.614s
如果不希望一同执行功能测试,可以使用-run
参数指定一个不存在的文件名,这样就不会执行功能测试:
❯ go test -v -bench . -run x
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark2
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4 9080 129010 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4 9211 136069 ns/op
PASS
ok github.com/icexmoon/go-notebook/ch18/benchmark2 3.246s
我们可以使用基准测试结果来对比两种采用不同方式实现的功能孰优孰劣。
Web测试
上边说的都是对普通应用进行的测试,对于Web应用,其核心是URL请求,所以测试方式有些不太一样。
这里使用中构建的Web Service进行测试说明。
这里针对登录接口创建测试用例:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/icexmoon/go-notebook/ch18/web-test/api"
"github.com/julienschmidt/httprouter"
)
func TestApiLogin(t *testing.T) {
router := httprouter.New()
router.POST("/api/login", api.ApiLogin)
recorder := httptest.NewRecorder()
reader := strings.NewReader(`
{
"data": {
"name": "111",
"password": "111"
}
}
`)
r, err := http.NewRequest("POST", "/api/login", reader)
if err != nil {
t.Fatal(err)
}
router.ServeHTTP(recorder, r)
if recorder.Code != 200 {
t.Fatal("http status is not 200")
}
data := struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}{}
err = json.Unmarshal(recorder.Body.Bytes(), &data)
if err != nil {
t.Fatal(err)
}
if len(data.Data.Token) == 0 {
t.Fatal("the token returned is empty")
}
t.Logf("the token returned is %s\n", data.Data.Token)
}
func TestLoginFail(t *testing.T) {
router := httprouter.New()
router.POST("/api/login", api.ApiLogin)
recorder := httptest.NewRecorder()
reader := strings.NewReader(`
{
"data": {
"name": "111",
"password": "222"
}
}
`)
r, err := http.NewRequest("POST", "/api/login", reader)
if err != nil {
t.Fatal(err)
}
router.ServeHTTP(recorder, r)
if recorder.Code == 200 {
t.Fatal("http status is 200")
}
rs := recorder.Result().Status
t.Logf("the status message is %s\n", rs)
}
其中一个是正向测试用例,一个是反向测试用例。
执行情况如下:
❯ go test -v
=== RUN TestApiLogin
main_test.go:46: the token returned is eyJpZCI6MSwiZXhwaXJlIjoiMjAyMi0wMS0wM1QxNzowMzozNi44NDQ1ODE2KzA4OjAwIiwic2NvZGUiOiI5MGU1Y2JkYmU2NDFiMTk0ZDI5YTY0NWZlNWUwOGNkZCJ9
--- PASS: TestApiLogin (0.04s)
=== RUN TestLoginFail
main_test.go:70: the status message is 500 Internal Server Error
--- PASS: TestLoginFail (0.00s)
PASS
ok github.com/icexmoon/go-notebook/ch18/web-test 0.889s
可以看到在正常登录时,应用正确返回了访问令牌,密码错误时,返回的响应报文状态码是500。这和我们设计的接口是一致的。
这里并没有像正式代码中那样创建一个Server
,并执行ListenAndServe
方法对主机的端口监听。实际上这里仅创建了一个多路复用器,并绑定相应的处理器,然后直接调用多路复用器的ServeHttp
方法来转发HTTP请求。并直接从转发时传入的httptest.ResponseRecorder
结构获取结果,该结构和http.ResponseWriter
是相似的。
httptest
包是在testing
基础上发展来的,提供一些用于Web测试的额外组件。
一般情形下的Web应用信息流转可以用下图表示:
但对于测试用例来说,其实是这样:
也就是说测试用例直接“生造”了一个http.Request
结构,通过多路复用器转发给具体的处理器,然后通过Recorder
结构获取处理结果,这个过程中并不存在真正的网络报文发送和接收。但Web编程的关键环节都可以得到充分测试,所以这样做是没有问题的。
TestMain
testing
包允许用户给测试代码添加一个TestMain
函数,通过这个函数可以在所有用例的执行前后执行一些额外处理:
...
func TestMain(m *testing.M) {
setup()
code := m.Run()
tearDown()
os.Exit(code)
}
func setup() {}
func tearDown() {}
m.Run
的作用就是执行该代码文件所包含的全部测试用例。
我们可以利用这种特性来将用例中的一些重复性的准备工作集中在setUp
函数中完成:
...
var router *httprouter.Router
var recorder *httptest.ResponseRecorder
func setup() {
router = httprouter.New()
recorder = httptest.NewRecorder()
router.POST("/api/login", api.ApiLogin)
}
...
Gocheck
Gocheck是在testing
包基础扩展而来的一个第三方测试框架,其提供更丰富的功能:
-
以套件(suite)为单位对测试用例进行分组。
-
可以为测试用例或套件设置夹具。
-
支持断言。
-
更多的报错辅助函数。
-
兼容
testing
包。
安装Gocheck
:
❯ go get gopkg.in/check.v1
这里使用之前简单的Add
函数来构建测试用例:
package main
import (
"testing"
. "gopkg.in/check.v1"
)
type MainTestSuite struct{}
func init() {
Suite(&MainTestSuite{})
}
func Test(t *testing.T) {
TestingT(t)
}
func (s *MainTestSuite) TestAdd(c *C) {
c.Check(Add(1, 5), Equals, 6)
c.Check(Add(-1, -2), Equals, -3)
}
使用Gocheck需要经过以下几个步骤:
-
创建测试套件结构,习惯以
XXXTestSuite
命名。 -
在
init
方法中调用Suite
函数绑定结构体到Gocheck。 -
用任意一个
Test
开头的函数调用TestingT()
函数以启用Gocheck。这样做是为了用go test
来调用Gocheck
。 -
用测试套件结构的方法来创建测试用例。
为了说明Web测试中如何使用Gocheck,这里使用Gocheck构建Web Service登录相关的测试用例:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/icexmoon/go-notebook/ch18/web-check/api"
"github.com/julienschmidt/httprouter"
. "gopkg.in/check.v1"
)
type LoginTestSuite struct {
Recoder *httptest.ResponseRecorder
Router *httprouter.Router
}
func (s *LoginTestSuite) SetUpTest(c *C) {
s.Router = httprouter.New()
s.Recoder = httptest.NewRecorder()
s.Router.POST("/api/login", api.ApiLogin)
}
func (s *LoginTestSuite) TestLogin(c *C) {
reader := strings.NewReader(`
{
"data": {
"name": "111",
"password": "111"
}
}
`)
r, err := http.NewRequest("POST", "/api/login", reader)
c.Check(err, Equals, nil)
s.Router.ServeHTTP(s.Recoder, r)
c.Check(s.Recoder.Code, Equals, 200)
data := struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}{}
err = json.Unmarshal(s.Recoder.Body.Bytes(), &data)
c.Check(err, Equals, nil)
c.Check(len(data.Data.Token) > 0, Equals, true)
}
func (s *LoginTestSuite) TestLoginFail(c *C) {
reader := strings.NewReader(`
{
"data": {
"name": "111",
"password": "222"
}
}
`)
r, err := http.NewRequest("POST", "/api/login", reader)
c.Check(err, Equals, nil)
s.Router.ServeHTTP(s.Recoder, r)
c.Check(s.Recoder.Code, Not(Equals), 200)
// c.Log(s.Recoder.Result().Status)
}
func init() {
Suite(&LoginTestSuite{})
}
func Test(t *testing.T) {
TestingT(t)
}
测试套件的SetUpTest
方法会在每个测试用例执行前进行调用。相应的,SetUpSuite
方法会在整个测试套件执行前调用。
比较奇怪的是,这里只能使用
SetUpTest
而不能使用SetUpSuite
,否则第二个测试用例的c.Check(s.Recoder.Code, Not(Equals), 200)
检查会失败,原因我还不清楚。
执行结果:
❯ go test '-check.vv'
START: main_test.go:26: LoginTestSuite.TestLogin
START: main_test.go:20: LoginTestSuite.SetUpTest
PASS: main_test.go:20: LoginTestSuite.SetUpTest 0.001s
PASS: main_test.go:26: LoginTestSuite.TestLogin 0.134s
START: main_test.go:49: LoginTestSuite.TestLoginFail
START: main_test.go:20: LoginTestSuite.SetUpTest
PASS: main_test.go:20: LoginTestSuite.SetUpTest 0.001s
PASS: main_test.go:49: LoginTestSuite.TestLoginFail 0.001s
OK: 2 passed
PASS
ok github.com/icexmoon/go-notebook/ch18/web-check 2.803s
要观察详细输出需要添加-check.vv
参数,但因为.
在Windows命令行下会被认为是参数分隔符,所以需要用引号将整个参数包裹。
《Go Web编程》还介绍了测试框架Ginkgo,但我不打算继续写下去了,就这样吧。
本篇文章的所有代码见:
谢谢阅读。
文章评论