《Go语言实战》入门实战系列 09:测试与性能——单元测试、基准测试与示例代码
开篇引导
在软件开发中,测试不仅是保障代码质量的最后一道防线,更是设计优良接口、提升代码可维护性的有力工具。Go语言从诞生之初就将测试作为语言的一部分,内置了testing包和go test命令,让开发者能够轻松地编写单元测试、基准测试和示例代码。无论你是在开发一个库还是一个完整的应用,掌握Go的测试技巧都是成为合格Go开发者的必经之路。
本篇将带你系统学习Go的测试体系:从基础的单元测试、表组测试,到使用httptest模拟网络调用,再到编写示例代码和基准测试。你将学会如何为你的代码建立完整的测试集,并通过基准测试找到性能瓶颈。学完本篇,你将能够自信地写出健壮、高效且文档完善的Go程序。
【本篇核心收获】
- 掌握单元测试的编写规范:函数命名、文件命名、测试输出风格
- 学会使用表组测试(table-driven tests)高效覆盖多组输入输出
- 利用
httptest包模拟HTTP请求和响应,实现无外部依赖的测试 - 通过示例代码(
Example函数)为包生成文档,并验证代码正确性 - 编写基准测试(
Benchmark函数)来评估代码性能,理解b.N和-benchmem的含义 - 掌握
go test的常用选项:-v、-run、-bench、-benchtime、-benchmem
1. 单元测试
单元测试用于验证代码在特定场景下是否按预期工作。Go的测试工具go test会查找并执行所有以_test.go结尾的文件中的测试函数。
1.1 基础单元测试
一个基础单元测试函数必须以Test开头,接收一个*testing.T参数,无返回值。
代码清单1:基础单元测试示例
package listing01
import (
"net/http"
"testing"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
func TestDownload(t *testing.T) {
url := "http://www.goinggo.net/feeds/posts/default?alt=rss"
statusCode := 200
t.Log("Given the need to test downloading content.")
{
t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
url, statusCode)
{
resp, err := http.Get(url)
if err != nil {
t.Fatal("\t\tShould be able to make the Get call.",
ballotX, err)
}
t.Log("\t\tShould be able to make the Get call.",
checkMark)
defer resp.Body.Close()
if resp.StatusCode == statusCode {
t.Logf("\t\tShould receive a \"%d\" status. %v",
statusCode, checkMark)
} else {
t.Errorf("\t\tShould receive a \"%d\" status. %v %v",
statusCode, ballotX, resp.StatusCode)
}
}
}
}图1:基础单元测试的输出
关键点:
- 测试文件必须以
_test.go结尾。 - 测试函数名以
Test开头。 t.Log和t.Logf用于输出信息(仅在-v选项下显示)。t.Fatal和t.Fatalf用于报告致命错误并终止当前测试。t.Error和t.Errorf用于报告错误但继续执行。
图2:基础单元测试的输出细节
1.2 表组测试
当需要测试多组输入输出时,可以使用表组测试(table-driven tests)。表组测试将测试数据定义在一个切片中,然后循环执行。
代码清单2:表组测试示例
func TestDownload(t *testing.T) {
var urls = []struct {
url string
statusCode int
}{
{
"http://www.goinggo.net/feeds/posts/default?alt=rss",
http.StatusOK,
},
{
"http://rss.cnn.com/rss/cnn_topstbadurl.rss",
http.StatusNotFound,
},
}
t.Log("Given the need to test downloading different content.")
{
for _, u := range urls {
t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
u.url, u.statusCode)
{
resp, err := http.Get(u.url)
if err != nil {
t.Fatal("\t\tShould be able to Get the url.",
ballotX, err)
}
t.Log("\t\tShould be able to Get the url",
checkMark)
defer resp.Body.Close()
if resp.StatusCode == u.statusCode {
t.Logf("\t\tShould have a \"%d\" status. %v",
u.statusCode, checkMark)
} else {
t.Errorf("\t\tShould have a \"%d\" status. %v %v",
u.statusCode, ballotX, resp.StatusCode)
}
}
}
}
}图3:表组测试的输出
表组测试的优点:
- 增加新测试用例只需在表中添加数据,无需重复代码。
- 清晰展示测试的输入和预期输出。
1.3 使用httptest模拟网络调用
依赖外部网络服务的测试不可靠且难以复现。net/http/httptest包允许我们模拟HTTP服务器,使测试脱离外部网络。
代码清单3:使用httptest模拟服务器
package listing12
import (
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
var feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<title>Going Go Programming</title>
<description>Golang : https://github.com/goinggo</description>
<link>http://www.goinggo.net</link>
<item>
<pubDate>Sun, 15 Mar 2015 15:04:00 +0000</pubDate>
<title>Object Oriented Programming Mechanics</title>
<description>Go is an object oriented language.</description>
<link>http://www.goinggo.net/2015/03/object-oriented</link>
</item>
</channel>
</rss>`
func mockServer() *httptest.Server {
f := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/xml")
fmt.Fprintln(w, feed)
}
return httptest.NewServer(http.HandlerFunc(f))
}
func TestDownload(t *testing.T) {
statusCode := http.StatusOK
server := mockServer()
defer server.Close()
t.Log("Given the need to test downloading content.")
{
t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
server.URL, statusCode)
{
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal("\t\tShould be able to make the Get call.",
ballotX, err)
}
t.Log("\t\tShould be able to make the Get call.",
checkMark)
defer resp.Body.Close()
if resp.StatusCode != statusCode {
t.Fatal("\t\tShould receive a \"%d\" status. %v %v",
statusCode, ballotX, resp.StatusCode)
}
t.Logf("\t\tShould receive a \"%d\" status. %v",
statusCode, checkMark)
}
}
}图4:没有互联网连接时测试仍能通过
模拟服务器的核心:
httptest.NewServer创建一个本地HTTP服务器,其URL字段指向该服务器。- 传入的
http.HandlerFunc会在每次请求时被调用,我们可以自定义响应。 - 通过
defer server.Close()确保服务器在测试结束后关闭。
1.4 测试服务端点
对于Web应用,我们经常需要直接测试处理函数(handler)而无需启动整个服务。httptest包也提供了httptest.NewRecorder来捕获响应。
代码清单4:测试服务端点的示例(handlers包)
package handlers
import (
"encoding/json"
"net/http"
)
func Routes() {
http.HandleFunc("/sendjson", SendJSON)
}
func SendJSON(rw http.ResponseWriter, r *http.Request) {
u := struct {
Name string
Email string
}{
Name: "Bill",
Email: "bill@ardanstudios.com",
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(200)
json.NewEncoder(rw).Encode(&u)
}测试代码:
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"beth.com/goinaction/code/chapter9/listing17/handlers"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
func init() {
handlers.Routes()
}
func TestSendJSON(t *testing.T) {
t.Log("Given the need to test the SendJSON endpoint.")
{
req, err := http.NewRequest("GET", "/sendjson", nil)
if err != nil {
t.Fatal("\tShould be able to create a request.", ballotX, err)
}
t.Log("\tShould be able to create a request.", checkMark)
rw := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(rw, req)
if rw.Code != 200 {
t.Fatal("\tShould receive \"200\"", ballotX, rw.Code)
}
t.Log("\tShould receive \"200\"", checkMark)
u := struct {
Name string
Email string
}{}
if err := json.NewDecoder(rw.Body).Decode(&u); err != nil {
t.Fatal("\tShould decode the response.", ballotX)
}
t.Log("\tShould decode the response.", checkMark)
if u.Name == "Bill" {
t.Log("\tShould have a Name.", checkMark)
} else {
t.Error("\tShould have a Name.", ballotX, u.Name)
}
if u.Email == "bill@ardanstudios.com" {
t.Log("\tShould have an Email.", checkMark)
} else {
t.Error("\tShould have an Email.", ballotX, u.Email)
}
}
}测试端点的关键步骤:
- 使用
http.NewRequest创建请求。 - 使用
httptest.NewRecorder创建响应记录器。 - 通过
http.DefaultServeMux.ServeHTTP将请求交给默认的多路复用器处理(前提是已经注册了路由)。 - 检查响应记录器中的状态码、头信息和Body。
模块小结:单元测试是Go开发的基础,通过
go test可以轻松运行。表组测试让测试更简洁,httptest则让我们能模拟外部依赖,保证测试的可靠性和可重复性。
2. 示例代码
Go的godoc工具支持从代码中的示例函数生成文档。示例函数不仅展示了如何使用API,还可以作为测试被go test执行。
示例函数的规则:
- 函数名以
Example开头,后跟要展示的公开函数或类型名。 - 示例函数可以包含一个结尾的
// Output:注释,用来声明期望的标准输出。 - 如果提供了
Output:,go test会捕获标准输出并与注释内容比较,不一致则测试失败。
代码清单5:示例代码(handlers_example_test.go)
package handlers_test
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
)
func ExampleSendJSON() {
r, _ := http.NewRequest("GET", "/sendjson", nil)
rw := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(rw, r)
var u struct {
Name string
Email string
}
if err := json.NewDecoder(rw.Body).Decode(&u); err != nil {
log.Println("ERROR:", err)
}
fmt.Println(u)
// Output:
// {Bill bill@ardanstudios.com}
}图5:包json的示例代码列表
图6:Go文档里显示的Decoder示例视图
图7:handlers包的godoc视图
图8:在godoc里显示完整的示例代码
运行示例:
go test -run ExampleSendJSON图9:运行示例代码
如果输出与期望不匹配,go test会显示差异(图10)。
图10:示例运行失败
模块小结:示例代码是Go文档的重要组成部分,既能帮助用户理解API,又能作为测试验证代码的正确性。
3. 基准测试
基准测试用于测量代码的性能。基准测试函数以Benchmark开头,接收*testing.B参数,并在循环中执行待测代码,循环次数由b.N控制。
代码清单6:三种整数转字符串的基准测试
package listing28_test
import (
"fmt"
"strconv"
"testing"
)
func BenchmarkSprintf(b *testing.B) {
number := 10
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Sprintf("%d", number)
}
}
func BenchmarkFormat(b *testing.B) {
number := int64(10)
b.ResetTimer()
for i := 0; i < b.N; i++ {
strconv.FormatInt(number, 10)
}
}
func BenchmarkItoa(b *testing.B) {
number := 10
b.ResetTimer()
for i := 0; i < b.N; i++ {
strconv.Itoa(number)
}
}运行基准测试:
go test -v -run="none" -bench="BenchmarkSprintf"图11:运行单个基准测试
- 输出中的
5000000表示执行次数。 258 ns/op表示每次操作耗时258纳秒。
使用-benchtime选项增加运行时间:
go test -bench="BenchmarkSprintf" -benchtime=3s图12:使用-benchtime选项运行基准测试
同时运行三个基准测试:
go test -bench="."图13:运行所有三个基准测试
查看内存分配情况:
go test -bench="." -benchmem图14:使用-benchmem选项运行基准测试
B/op:每次操作分配的字节数。allocs/op:每次操作分配内存的次数。
通过基准测试,我们发现strconv.FormatInt最快,strconv.Itoa次之,fmt.Sprintf最慢且内存分配最多。这提示我们在性能敏感的场景下应优先使用strconv包。
模块小结:基准测试是性能优化的利器。通过
-bench、-benchtime和-benchmem选项,我们可以精确评估代码的耗时和内存占用,从而做出正确的优化决策。
4. 本篇核心知识点速记
- 单元测试:
- 文件名:
*_test.go - 函数名:
TestXxx(t *testing.T) - 输出:
t.Log、t.Error、t.Fatal
- 文件名:
- 表组测试:定义测试数据表,循环执行,便于扩展。
- httptest:
httptest.NewServer:模拟HTTP服务器,返回*httptest.Server。httptest.NewRecorder:记录响应,用于测试handler。
- 示例代码:
- 函数名:
ExampleXxx - 可选
// Output:注释,用于测试和文档。
- 函数名:
- 基准测试:
- 函数名:
BenchmarkXxx(b *testing.B) - 循环使用
b.N,通过b.ResetTimer()排除初始化时间。 - 选项:
-bench、-benchtime、-benchmem。
- 函数名:
文末小结
本章我们完整学习了Go语言的测试体系。单元测试保证了代码的正确性;表组测试让测试更加简洁高效;httptest让我们可以模拟网络依赖,实现可靠的测试;示例代码既提供了文档,又充当了测试;基准测试则帮助我们度量性能,找到最佳实现。通过go test的强大工具链,我们可以在开发过程中持续验证代码质量,并生成高质量的文档。
测试是编写可靠软件不可或缺的一环。在后续的实战项目中,请将测试作为开发流程的一部分,让测试驱动你的代码设计。
