Bootstrap

Go语言——测试与性能

篇幅较长,建议先收藏

测试与性能

​ 作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。使用 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函数)

1.2 基准测试

​ 基准测试(benchmarking)是一种测量和评估软件性能指标的活动。在某个时候通过基准测试建立一个已知的性能水平(称为基准线),当系统的软硬件环境发生变化之后再进行一次基准测试,以确定那些变化对性能的影响,这是基准测试最常见的用途。其他用途包括测定某种负载水平下的性能极限,管理系统或环境的变化、发现可能导致性能问题的条件等。

​ 在_test.go结尾的测试文件中,如下形式的函数(基准测试的函数必须以 Benchmark (基准)开头,必须是可导出的:

func BenchmarkXxxx(*testing.B)

​ 被认为是基准测试,通过go test命令,加上-bench选项来执行。多个基准测试按照顺序运行。

基准测试函数样例如下:

./02_testing/test04/test04_test.go

package test04

import (
	"fmt"
	"testing"
)

func BenchmarkHello(b *testing.B) {
    // 基准函数会运行目标代码b.N次。
	for i := 0; i < b.N; i++ {
		fmt.Sprintf("hello")
	}
}

执行测试命令(在基准执行期间,会调整 b.N 直到基准测试函数持续足够长的时间,输出):

go test -v -run="none" -bench="." ./02_testing/test04
# go test -v -run="none" -bench="BenchmarkHello"

BenchmarkHello
BenchmarkHello-8        21805216                55.71 ns/op
PASS
ok      go.standard.library.study/02_testing/test04     1.308s

意味着循环执行了21805216次,每次循环花费了55.71ns。

​ 运行基准测试也要使用go test命令,不过要加上-bench标记,他接受一个表达式作为参数,匹配基准测试的函数,这里使用的-bench="."代表运行所有基准测试,如果改成-bench="BenchmarkHello",就代表只执行这一个基准测试。

​ 因为默认情况go test会运行单元测试,为了防止单元测试的输出影响查看基准测试的结果,可以使用-run="none"匹配一个不存在的 单元测试,过滤掉单元测试的输出,这里使用的none,因为基本不会创建这个名字的单元测试。

下面着重解释一下输出的结果:

  • BenchmarkHello-8后面的-8表示运行对应的GOMAXPROCS的值;
  • 21805216代表着运行for循环的次数,也就是调用被测试代码的次数;
  • 55.71 ns/op代表每次循环需要花费55.71ns;

以上测试时间默认是1s,也就是说1s的时间调用了21805216次,每次花费55.71ns。如果想让测试时间更长,可以通过-benchtime指定,比如3s:

$ go test -bench="." -benchtime="3s" -run="none"

​ 当然,在一般情况下,加长测试时间,只会导致测试的次数变 ,但是最终的性能结果并没有太大变化 建议测试时间不要超过 3s ,当然,具体情况具体分析。

​ 如果在运行前基准测试需要进行一些耗时的配置,则可以先重置定时器,再进入for循环(例如避免for循环之前的初始化代码的干扰):

func BenchmarkBigLen(b *testing.B){
    ... // 初始化代码
    b.ResetTimer() // 重置时间
    ... // 通常是一个for循环
}
  • b.ResetTimer():将运行的基准时间和内存分配计数器归零,并删除用户报告的指标。

​ 如果基准测试需要在 并行设置中测试性能,则可以使用RunParallel 辅助函数,这样的基准测试一般与go test -cpu标志一起使用:

func BenchmarkTemplateParallel(b *testing.B) {
	templ:=template.Must(template.New("test").Parse("hello,{{.}}!"))
	b.RunParallel(func(pb *testing.PB) {
		// 每一个goroutine都有属于自己的bytes.Buffer
		var buf bytes.Buffer
		for pb.Next() {
			// 所有 goroutine一起,循环一共执行b.N次( N指默认的 CPU 核心数)
			buf.Reset()
			templ.Execute(&buf,"world")
		}
	})
}

​ 基准测试是一种测试代码性能的方法。想要测试解决同一问题的不同方案的性能,以及查看 哪种解决方案的性能更好时,基准测试就会很有用。基准测试也可以用来识别某段代码的 CPU 或者内存效率问题,而这段代码的效率可能会严重影响整个应用程序的性能。许多开发人员会用 基准测试来测试不同的并发模式,或者用基准测试来辅助配置工作池的数量,以保证能最大化系统的吞吐量。

1. 性能对比

最上面那个基准测试的例子,其实是一个int 类型转为string类型的例子,标准库里还有几种方法,我们来看看哪一种性能更加高效:
./02_testing/test06/test06_test.go

package test06

import (
	"fmt"
	"strconv"
	"testing"
)

func BenchmarkSprintf(b *testing.B) {
	num := 10
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		fmt.Sprintf("%d", num)
	}
}

func BenchmarkFormat(b *testing.B) {
	num := int64(10)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		strconv.FormatInt(num, 10)
	}
}

func BenchmarkItoa(b *testing.B) {
	num := 10
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		strconv.Itoa(num)
	}
}

执行测试命令:

$ go test -run="none" -bench="." ./02_testing/test06

goos: windows
goarch: amd64
pkg: go.standard.library.study/02_testing/test06
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkSprintf
BenchmarkSprintf-8      15552018                76.09 ns/op
BenchmarkFormat
BenchmarkFormat-8       443492250                2.719 ns/op
BenchmarkItoa
BenchmarkItoa-8         446563802                2.709 ns/op
PASS
ok      go.standard.library.study/02_testing/test06     4.265s

从结果来看,strconv.FormatInt函数最快,其次是strconv.Itoa,而fmt.Sprintf最慢。

​ 为了进一步分析三个函数的快慢的根源,可以通过-benchmem分析内存的使用情况。-benchmem可以提供每次操作分配内存的次数,以及每次操作分配的字节数(B)。

$ go test -run="none" -bench="." -benchmem ./02_testing/test06
goos: windows
goarch: amd64
pkg: go.standard.library.study/02_testing/test06
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkSprintf-8      14670589                76.02 ns/op            2 B/op          1 allocs/op
BenchmarkFormat-8       449164497                2.679 ns/op           0 B/op          0 allocs/op
BenchmarkItoa-8         442082088                2.694 ns/op           0 B/op          0 allocs/op
PASS
ok      go.standard.library.study/02_testing/test06     4.180s

​ 这次输出的结果会多出两组新的数值,一组数值的单位是B/op,另一组的单位是allocs/op

  • allocs/op的值表示每次操作从堆上分配内存的次数。可以看到Sprinf函数每次操作都会从堆上分配1个值,而另外两个函数都是0个值。
  • B/op的值表示每次操作分配的B字节数,你可以看到Sprintf1次分配内存消耗了2B的内存,而另外两个都是0B。

从这个数据就可以知道为什么它这么慢了,内存分配和占用都太高。

​ 在运行单元测试和基准测试的时候,还有很多选项可以使用,建议都查看一遍,以便在编写自己的包和工程时,充分利用测试框架。

​ 在代码开发中,对与要求性能的地方,编写基准测试非常重要,这有助于开发出性能更好的代码。不过性能、可用性、复用性等也要有一个相对的取舍,不能为了追求性能而过度优化。

2. pprof

​ 在之前的例子中,只能查看函数的执行时间,如果想进一步分析函数的具体执行状况可以配合其他选项使用。上面曾经使用到一些基准测试的选项,其中常用的测试选项如下:

  1. -bench="regexp":regexp可以为任何正则表达式,表示需要运行的基准测试函数,一般可以用-bench="."来执行当前目录下的所有的基准测试;
  2. -benchmem:在输出内容中包含基准测试的内存分配统计信息;
  3. -benchtime="ts" t表示执行单个参数函数的累计耗时上线,默认是1s;
  4. cpuprofile="out path":输出cpu profile到指定路径,可以使用pprof查看;
  5. memprofile="out path":输出内存profile到指定路径,可以使用pprof查看

执行一个基准测试的时候,可以指定相关的选项,例如下面这样:

$ go test -bench="." -benchmem -cpuprofile="./02_testing/test06/cpu.prof" -memprofile="./02_testing/test06/memory.prof"  ./02_testing/test06

生成两个文件是无法直接查看的,需要使用工具去解析。

​ 这就需要本节的主角 pprof 登场了,这是 Go 语言提供的性能分析工具,可以分 cpu profilememory profile heap profileblock profile 等信息。

​ 在上面的基准测试中,己经知道怎样生成 cpu.prof 文件了,然后可以利用 go tool pprof 工具来查看:

$  go tool pprof bench_test.test ,./02_testing/test06/cpu.prof
bench_test.test: open bench_test.test: The system cannot find the file specified.
Fetched 1 source profiles out of 2
Type: cpu
Time: Jul 17, 2022 at 4:38pm (CST)
Duration: 4.46s, Total samples = 3.41s (76.52%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

这时会进入一个可交互环境,输入 help 可以查看所有的交互命令,现在可以使用 top 10 来查看测试过程中最耗 CPU 资源的函数:

(pprof) top10

Showing nodes accounting for 2390ms, 70.09% of 3410ms total
显示节点占2390ms,占3410ms总数的70.09%
Dropped 61 nodes (cum <= 17.05ms) 
缺失61个节点(cum <= 17.05ms)
Showing top 10 nodes out of 78
显示78个节点中的前10个节点
      flat  flat%   sum%        cum   cum%
    1030ms 30.21% 30.21%     1330ms 39.00%  strconv.FormatInt
     300ms  8.80% 39.00%      300ms  8.80%  strconv.small (inline)
     280ms  8.21% 47.21%     1000ms 29.33%  go.standard.library.study/02_testing/test06.BenchmarkFormat
     250ms  7.33% 54.55%      940ms 27.57%  go.standard.library.study/02_testing/test06.BenchmarkItoa
     130ms  3.81% 58.36%      130ms  3.81%  runtime.stdcall3
     100ms  2.93% 61.29%      150ms  4.40%  runtime.mallocgc
      90ms  2.64% 63.93%      170ms  4.99%  fmt.(*fmt).fmtInteger
      80ms  2.35% 66.28%       80ms  2.35%  runtime.stdcall1
      80ms  2.35% 68.62%      690ms 20.23%  strconv.Itoa (inline)
      50ms  1.47% 70.09%      310ms  9.09%  fmt.(*pp).doPrintf

​ 现在解释每一列的含义,在默认情况下,Go语言的运行时系统会以100Hz的频率对CPU使用情况进行取样。也就是说,每1s取样100次,即每10ms取样一次(100Hz 即足够产生有用的数据,又不至于让系统产生停顿)。

​ 实际上,这里所说的对CPU使用情况的取样就是对当前的goroutine 的堆栈上的 程序计数器的取样,由此就可以从样本记录中分析哪些代码是计算时间最长,或者最耗CPU资源的部分了。

  • 第一列``flat` :

    表示取样点落在该函数里的总数(不包括调用其他函数),比如strconv.FormatInt这个函数,总执行时间为1030ms,那么总抽样点数为103次;

  • 第二列flat%

    表示落在该函数里取样点占总取样点的百分比,strconv.FormatInt这个函数占用总共的30.21%;

  • 第三列sum%

    列表示的是前几行加起来的执行时间占总共执行时间的多少,top10 命令的默 认排序是按第一列(执行时间〉排序。例如第三行的第三列表示的是前三行函数的执行时间加起来占总共执行时间的 47.21%;

  • 第四列cum

    表示取样点落在该函数里和它直接调用、间接调用的函数里的总数。比如strconv.FormatInt这个函数,总时间为 1330ms ,表示该函数的执行时间加上函数调用的其它函数的执行时间,共1330ms;

  • 第五列cum%
    表示第四列的时间占总时间的百分比。

​ 从top10的输出里,至少可以知道每个函数的执行时间占比,以及每个函数的调用栈的执行时间的占比。如果某个函数自身执行时间过长,那说明这个函数的是否有逻辑错误,是否需要拆分。如果某个函数的调用栈的执行时间过长,是否是因为调用了过多的不需要的函数。

;