 第17章 测试、基准测试和性能分析
第17章 测试、基准测试和性能分析
  # 第17章 测试、基准测试和性能分析
为代码编写测试和基准测试(benchmark)在多个方面对你有所帮助。在开发过程中,测试可以确保你正在开发的功能正常运行,并且在开发工作中不会破坏现有功能。基准测试可以确保你的程序在一定的资源和时间限制内运行。开发完成后,同样的测试和基准测试可以确保任何维护工作(如修复漏洞、增强功能等)不会在现有功能中引入新的漏洞。所以,你应该把编写测试和基准测试视为核心开发活动,在开发程序的同时开发其测试用例。
测试应该专注于测试一切正常时的预期行为(“正向路径测试”)以及出现问题时的情况,而不是覆盖所有的实现路径。为涵盖所有可能的实现选择而编写的测试很快就会变得比程序本身更难维护。你应该在实用性和测试覆盖率之间保持平衡。
本节展示了处理几种常见测试和基准测试场景的惯用方法。本章涵盖以下主题:
- 进行单元测试
- 编写单元测试
- 运行单元测试
- 在测试中记录日志
- 跳过测试
- 测试HTTP服务器
- 测试HTTP处理程序
- 检查测试覆盖率
- 基准测试
- 编写基准测试
- 编写不同输入大小的多个基准测试
- 运行基准测试
- 性能分析
# 进行单元测试
我们将以一个对 time.Time 值进行升序或降序排序的示例函数为例,代码如下:
package sort
import (
    "sort"
    "time"
)
// Sort times in ascending or descending order
func SortTimes(input []time.Time, asc bool) []time.Time {
    output := make([]time.Time, len(input))
    copy(output, input)
    if asc {
        sort.Slice(output, func(i, j int) bool {
            return output[i].Before(output[j])
        })
        return output
    }
    
    sort.Slice(output, func(i, j int) bool {
        return output[j].Before(output[i])
    })
    
    return output
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
我们将使用Go构建系统和标准库提供的内置测试工具。为此,假设我们将前面的函数存储在一个名为 sort.go 的文件中。那么,这个函数的单元测试将放在与 sort.go 同一目录下的 sort_test.go 文件中。Go构建系统会将以 _test.go 结尾的源文件识别为单元测试文件,并在常规构建时将其排除在外。
# 编写单元测试
理想情况下,单元测试用于测试单个单元(一个函数、一组相关函数或某个类型的方法)的行为是否符合预期。
# 操作方法……
- 创建以 _test.go为后缀的单元测试文件。对于sort.go,我们创建sort_test.go。以_test.go结尾的文件将被排除在常规构建之外:
package sort
| 提示 你也可以在一个以 _test结尾的单独测试包中编写测试。在这个例子中,包名变为package sort_test。在单独的包中编写测试,能让你从外部视角测试一个包的函数,因为你无法访问被测试包中未导出的名称。你还需要导入被测试的包。 | 
|---|
- Go测试系统将运行符合 Test<Feature>(*testing.T)模式的函数。声明一个符合此模式的测试函数,并编写一个测试行为的单元测试:
func TestSortTimesAscending(t *testing.T) {
    // 2.a Prepare input data
    input := []time.Time{
        time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local),
        time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local),
        time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local),
        time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local),
        time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local),
    }
    
    // 2.b Call the function under test
    output := SortTimes(input, true)
    
    // 2.c Make sure the output is what is expected
    for i := 1; i < len(output); i++ {
        if!output[i - 1].Before(output[i]) {
            t.Error("Wrong order")
        }
    }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 测试函数的结构通常遵循以下模式:
- 准备输入数据以及被测试函数运行所需的任何环境。
- 使用必要的输入调用被测试函数。
- 确保被测试函数返回了正确的结果或行为符合预期。
 
- 如果测试检测到错误,使用 t.Error系列函数通知测试系统测试失败。
# 运行单元测试
使用Go构建系统工具来运行单元测试。
# 操作方法……
- 要运行当前包中的所有单元测试,输入以下命令:
go test
PASS
ok  github.com/bserdar/go-recipes-book/chp15/sort/sort    0.001s
2
3
- 要运行某个包中的所有单元测试,输入以下命令:
go test <packageName>
或者,输入以下命令:
go test ./<folder>
以下是一个示例:
go test github.com/bserdar/go-recipes-book/chp15/sort/sort
或者,你可以输入以下命令:
go test ./sort
- 要递归地运行模块中所有包的所有单元测试,输入以下命令:
go test ./...
在模块的根目录下执行此命令。 4. 要运行当前包中的单个测试,输入以下命令:
go test -run TestSortTimesAscending
这种形式将 -run 标志后的测试名称视为正则表达式,并运行所有包含该字符串的测试。例如,go test -run Sort 将运行所有名称中包含 Sort 的测试。如果你只想运行特定的测试,可以相应地构造正则表达式:
go test -run ^TestSortTimesAscending$
这里,^ 表示正则表达式中字符串的开头,$ 表示字符串的结尾。
例如,以下命令将运行所有以 Ascending 结尾的测试:
go test -run Ascending$
# 在测试中记录日志
通常,额外的日志记录功能对测试很有用,特别是在测试失败时,可以显示关键变量的状态。默认情况下,如果测试通过,Go测试执行器不会打印任何日志信息,但如果测试失败,日志信息也会包含在输出中。
# 操作方法……
- 使用 testing.T.Log和testing.T.Logf函数在测试中记录日志消息:
func TestSortTimeAscending(t *testing.T) {
    ...
    t.Logf("Input: %v",input)
    output := SortTimes(input,true)
    t.Logf("Output: %v", output)
}
2
3
4
5
6
- 运行测试。如果测试通过,不会打印日志信息。如果测试失败,则会打印日志。
要在运行测试时显示日志,可以使用 -v 标志:
$ go test -v
=== RUN   TestSortTimesAscending
sort_test.go:17: Input: [2023-02-01 12:08:37 -0700 MST 2021- 05-06 09:48:11 -0600 MDT 2022-11-13 17:13:54 -0700 MST 2022-06-
23 22:29:28 -0600 MDT 2023-03-17 04:05:09 -0600 MDT]
sort_test.go:19: Output: [2021-05-06 09:48:11 -0600 MDT 2022-06-23 22:29:28 -0600 MDT 2022-11-13 17:13:54 -0700 MST
2023-02-01 12:08:37 -0700 MST 2023-03-17 04:05:09 -0600 MDT]
--- PASS: TestSortTimesAscending (0.00s)
2
3
4
5
6
7
# 跳过测试
你可以根据输入标志跳过某些测试。这个功能让你可以进行快速测试(只运行部分测试)和全面测试(运行所有测试)。
# 操作方法……
- 对于那些应该在简短测试运行中被排除的测试,检查 testing.Short()标志:
func TestService(t *testing.T) {
    if testing.Short() {
        t.Skip("Service")
    }
   ...
}
2
3
4
5
6
- 使用 test.short标志运行测试:
$ go test -test.short -v
=== RUN   TestService
service_test.go:15: Service --- SKIP: TestService (0.00s)
=== RUN   TestHandler
--- PASS: TestHandler (0.00s)
PASS
2
3
4
5
6
# 测试HTTP服务器
net/http/httptest 包是对 testing 包的补充,它提供了HTTP服务器测试工具,让你能够快速创建测试用的HTTP服务器。
在本节中,假设我们将排序函数扩展为一个HTTP服务,代码如下:
package service
import (
    "encoding/json"
    "io"
    "net/http"
    "time"
    "github.com/bserdar/go-recipes-book/chp15/sort/sort"
)
// Common handler function for parsing the input, sorting, and
// preparing the output
func HandleSort(w http.ResponseWriter, req *http.Request, ascending
bool) {
    var input []time.Time
    data, err := io.ReadAll(req.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    if err := json.Unmarshal(data, &input); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    output := sort.SortTimes(input, ascending)
    data, err = json.Marshal(output)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.Write(data)
}
// Prepares a multiplexer that handles POST /sort/asc and POST /sort/
// desc endpoints
func GetServeMux() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /sort/asc", func(w http.ResponseWriter, req
    *http.Request) {
        HandleSort(w, req, true)
    })
    mux.HandleFunc("POST /sort/desc", func(w http.ResponseWriter, req
    *http.Request) {
        HandleSort(w, req, false)
    })
    return mux
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
GetServeMux 函数准备了一个请求多路复用器,分别处理 POST /sort/asc 和 POST /sort/desc 这两个HTTP端点,用于处理升序和降序排序请求。输入是一个时间值的JSON数组,处理程序返回一个已排序的JSON数组。
# 操作方法……
- 使用net/http/httptest包,该包支持测试服务器:
import (
    "net/http/httptest"
    "testing"
   ...
)
2
3
4
5
- 在测试函数中,创建一个处理器或多路复用器,并使用它创建一个测试服务器。确保测试结束时服务器关闭——使用defer server.Close():
func TestService(t *testing.T) {
    mux := GetServeMux()
    server := httptest.NewServer(mux)
    defer server.Close()
2
3
4
- 使用server.URL调用服务器。httptest.NewServer函数会将其初始化为使用一个未被占用的本地端口。在以下示例中,我们向服务器发送无效输入,以验证服务器是否返回错误:
rsp, err := http.Post(server.URL+"/sort/asc", "application/json", strings.NewReader("test"))
if err != nil {
    t.Error(err)
    return
}
// 必须返回HTTP错误
if rsp.StatusCode/100 == 2 {
    t.Errorf("Error was expected")
    return
}
2
3
4
5
6
7
8
9
10
11
注意,http.Post函数不会返回错误。http.Post返回错误意味着POST操作失败。在这种情况下,POST操作成功,但返回了一个HTTP错误状态码。
4. 你可以多次调用服务器,以测试不同的输入并检查输出:
data, err := json.Marshal([]time.Time{
    time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local),
    time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local),
    time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local),
    time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local),
    time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local),
})
if err != nil {
    t.Error(err)
    return
}
rsp, err = http.Post(server.URL+"/sort/asc", "application/json", bytes.NewReader(data))
if err != nil {
    t.Error(err)
    return
}
defer rsp.Body.Close()
if rsp.StatusCode != 200 {
    t.Errorf("Expected status code 200, got %d", rsp.StatusCode)
    return
}
var output []time.Time
if err := json.NewDecoder(rsp.Body).Decode(&output); err != nil {
    t.Error(err)
    return
}
for i := 1; i < len(output); i++ {
    if!output[i - 1].Before(output[i]) {
        t.Errorf("Wrong order")
    }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 测试HTTP处理器
net/http/httptest包还包含ResponseRecorder,它可以作为HTTP处理器的http.ResponseWriter,用于在不创建服务器的情况下测试单个处理器。
# 操作方法……
- 创建ResponseRecorder:
func TestHandler(t *testing.T) {
    w := httptest.NewRecorder()
2
- 调用处理器,传递响应记录器而不是http.ResponseWriter:
data, err := json.Marshal([]time.Time{
    time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local),
    time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local),
    time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local),
    time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local),
    time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local),
})
if err != nil {
    t.Error(err)
    return
}
req, _ := http.NewRequest("POST", "localhost/sort/asc", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
HandleSort(w, req, true)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 响应记录器存储处理器构建的HTTP响应。验证响应是否正确:
if w.Result().StatusCode != 200 {
    t.Errorf("Expecting HTTP 200, got %d", w.Result().StatusCode)
    return
}
var output []time.Time
if err := json.NewDecoder(w.Result().Body).Decode(&output); err != nil {
    t.Error(err)
    return
}
for i := 1; i < len(output); i++ {
    if!output[i - 1].Before(output[i]) {
        t.Errorf("Wrong order")
    }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 检查测试覆盖率
测试覆盖率报告展示了哪些源代码行被测试覆盖到了。
# 操作方法……
- 要快速获得覆盖率结果,可以使用cover标志运行测试:
$ go test -cover
PASS
coverage: 76.2% of statements
2
3
- 要将测试覆盖率配置文件写入一个单独的文件,以便获取详细报告,可以在运行测试时指定一个覆盖率配置文件名:
$ go test -coverprofile=cover.out
PASS
coverage: 76.2% of statements
2
3
然后,你可以使用以下命令在浏览器中查看覆盖率报告:
$ go tool cover -html=cover.out
这个命令会打开浏览器,让你查看哪些代码行被测试覆盖到了。
# 基准测试
单元测试检查代码的正确性,而基准测试检查性能和内存使用情况。
# 编写基准测试
与单元测试类似,基准测试存储在_test.go文件中,但这些函数以Benchmark开头,而不是Test。基准测试会给定一个数字N,在运行时测量性能的过程中,你需要重复相同的操作N次。
# 操作方法……
- 在其中一个_test.go文件中创建一个基准测试函数。以下示例在sort_test.go文件中:
func BenchmarkSortAscending(b *testing.B) {
- 在基准测试循环之前进行设置,否则,你测量的将是设置代码的性能,而不是实际算法的性能:
input := []time.Time{
    time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local),
    time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local),
    time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local),
    time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local),
    time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local),
}
2
3
4
5
6
7
- 编写一个循环,迭代b.N次,并执行要进行基准测试的操作:
for i := 0; i < b.N; i++ {
    SortTimes(input, true)
}
2
3
| 提示 避免在基准测试循环中记录或打印数据。 | 
|---|
# 编写具有不同输入大小的多个基准测试
通常你会希望了解算法在不同输入大小下的表现。Go测试框架只提供了基准测试应运行的次数,并没有提供输入大小。可以使用以下模式来测试不同的输入大小。
# 操作方法……
- 定义一个未导出的参数化基准测试函数,该函数接受输入大小信息或不同大小的输入。以下示例将元素数量和排序方向作为参数,并在执行基准测试之前创建一个具有给定大小的随机打乱的输入切片:
func benchmarkSort(b *testing.B, nItems int, asc bool) {
    input := make([]time.Time, nItems)
    t := time.Now().UnixNano()
    for i := 0; i < nItems; i++ {
        input[i] = time.Unix(0, t - int64(i))
    }
    rand.Shuffle(len(input), func(i, j int) { input[i], input[j] = input[j], input[i] })
    for i := 0; i < b.N; i++ {
        SortTimes(input, asc)
    }
}
2
3
4
5
6
7
8
9
10
11
- 通过使用不同的值调用通用基准测试函数来定义导出的基准测试函数:
func BenchmarkSort1000Ascending(b *testing.B) {
    benchmarkSort(b, 1000, true)
}
func BenchmarkSort100Ascending(b *testing.B) {
    benchmarkSort(b, 100, true)
}
func BenchmarkSort10Ascending(b *testing.B) {
    benchmarkSort(b, 10, true)
}
func BenchmarkSort1000Descending(b *testing.B) {
    benchmarkSort(b, 1000, false)
}
func BenchmarkSort100Descending(b *testing.B) {
    benchmarkSort(b, 100, false)
}
func BenchmarkSort10Descending(b *testing.B) {
    benchmarkSort(b, 10, false)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 运行基准测试
Go工具会在运行基准测试之前先运行单元测试——对失败的代码进行基准测试没有意义。
# 操作方法……
- 使用go test -bench=<regexp>工具。要运行所有基准测试,可以使用以下命令:
go test -bench=.
- 如果你只想运行部分基准测试,可以输入基准测试的正则表达式。以下命令仅运行名称中包含1000的基准测试:
go test -bench=1000
goos: linux
goarch: amd64
pkg: github.com/bserdar/go-recipes-book/chp15/sort/sort
cpu: AMD Ryzen 5 7530U with Radeon Graphics
BenchmarkSort1000Ascending-12             9753        105997 ns/op
BenchmarkSort1000Descending-12             9813        105192 ns/op
PASS
2
3
4
5
6
7
8
# 性能分析
性能分析器(Profiler)对正在运行的程序进行采样,以确定在某些函数上花费了多少时间。你可以对基准测试进行性能分析,创建一个分析文件,然后检查该文件,以找出程序中的瓶颈。
# 操作方法……
要获取CPU性能分析文件并进行分析,请按照以下步骤操作:
- 使用cpuprofile标志运行基准测试:
$ go test -bench=1000Ascending --cpuprofile=profile
goos: linux
goarch: amd64
pkg: github.com/bserdar/go-recipes-book/chp15/sort/sort
cpu: AMD Ryzen 5 7530U with Radeon Graphics
BenchmarkSort1000Ascending-12           10000        106509 ns/op
2
3
4
5
6
- 使用分析文件启动pprof工具:
$ go tool pprof profile
File: sort.test
Type: cpu
2
3
- 使用topN命令查看分析文件中排名前N的采样:
(pprof) top5
Showing nodes accounting for 780ms, 71.56% of 1090ms total
Showing top 5 nodes out of 47
flat  flat%   sum%        cum   cum%
250ms 22.94% 22.94%      360ms 33.03%  github.com/bserdar/go-recipes-book/chp15/sort/sort.SortTimes.func1
230ms 21.10% 44.04%      620ms 56.88%  sort.partition_func
120ms 11.01% 55.05%      120ms 11.01%  runtime.memmove
90ms  8.26% 63.30%      340ms 31.19%  internal/reflectlite.Swapper.func9
90ms  8.26% 71.56%      230ms 21.10%  internal/reflectlite.typedmemmove
2
3
4
5
6
7
8
9
这表明大部分时间花在了比较两个时间值的匿名函数上。flat列显示了在一个函数中花费的时间,不包括在该函数调用的其他函数中花费的时间。cum代表累积时间,包括在一个函数中花费的时间,定义为函数返回的时间点减去函数开始运行的时间点。也就是说,累积值包括在该函数调用的其他函数中花费的时间。例如,sort.partition_func运行了620毫秒,但其中只有230毫秒花在了sort.partition_func本身,其余时间花在了sort.partition_func调用的其他函数上。
4. 使用web命令查看调用图的可视化表示,以及每个函数花费的时间。
要获取内存性能分析文件并进行分析,请按照以下步骤操作:
- 使用memprofile标志运行基准测试:
$ go test -bench=1000Ascending --memprofile=mem
goos: linux
goarch: amd64
pkg: github.com/bserdar/go-recipes-book/chp15/sort/sort
cpu: AMD Ryzen 5 7530U with Radeon Graphics
BenchmarkSort1000Ascending-12           10000        106509 ns/op
2
3
4
5
6
- 使用分析文件启动pprof工具:
$ go tool pprof mem
File: sort.test
Type: alloc_space
2
3
- 使用topN命令查看分析文件中排名前N的采样:
(pprof) top5
Showing nodes accounting for 493.37MB, 99.90% of 493.87MB total
Dropped 2 nodes (cum <= 2.47MB)
flat  flat%   sum%        cum   cum%
492.86MB 99.80% 99.80%   493.36MB 99.90%  github.com/bserdar/go-recipes-book/chp15/sort/sort.SortTimes
0.51MB   0.1% 99.90%   493.87MB   100%  github.com/bserdar/go-recipes-book/chp15/sort/sort.benchmarkSort
0     0% 99.90%   493.87MB   100%  github.com/bserdar/go-recipes-book/chp15/sort/sort.BenchmarkSort1000Ascending
0     0% 99.90%   493.87MB   100%  testing. (*B).launch
0     0% 99.90%   493.87MB   100%  testing. (*B).runN
2
3
4
5
6
7
8
9
与CPU性能分析文件的输出类似,这个表格显示了每个函数分配的内存量。同样,flat仅指在该函数中分配的内存,cum指在该函数及其调用的任何函数中分配的内存。在这里,你可以看到sort.SortTimes是分配大部分内存的函数。这是因为它首先创建了切片的副本,然后对其进行排序。
4. 使用web命令查看内存分配的可视化表示。
# 另请参阅
- 关于Go程序性能分析的权威指南可在https://go.dev/blog/pprof (opens new window)上获取。
- pprof的README文件解释了节点和边的表示形式:https://github.com/google/pprof/blob/main/doc/README.md (opens new window) 。
