测试和性能
作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。使用 Go 语言的测试 框架,可以在开发的过程中就进行单元测试和基准测试。和 go build 命令类似,go test 命 令可以用来执行写好的测试代码,需要做的就是遵守一些规则来写测试。而且,可以将测试无缝 地集成到代码工程和持续集成系统里。
单元测试
单元测试是用来测试包或者程序的一部分代码或者一组代码的函数。测试的目的是确认目标 代码在给定的场景下,有没有按照期望工作。一个场景是正向路经测试,就是在正常执行的情况 下,保证代码不产生错误的测试。这种测试可以用来确认代码可以成功地向数据库中插入一条工 作记录。
另外一些单元测试可能会测试负向路径的场景,保证代码不仅会产生错误,而且是预期的错 误。这种场景下的测试可能是对数据库进行查询时没有找到任何结果,或者对数据库做了无效的 更新。在这两种情况下,测试都要验证确实产生了错误,且产生的是预期的错误。总之,不管如 何调用或者执行代码,所写的代码行为都是可预期的。
在 Go 语言里有几种方法写单元测试。基础测试(basic test)只使用一组参数和结果来测试 一段代码。表组测试(table test)也会测试一段代码,但是会使用多组参数和结果进行测试。也 可以使用一些方法来模仿(mock)测试代码需要使用到的外部资源,如数据库或者网络服务器。 这有助于让测试在没有所需的外部资源可用的时候,模拟这些资源的行为使测试正常进行。最后, 9
在构建自己的网络服务时,有几种方法可以在不运行服务的情况下,调用服务的功能进行测试。
基础单元测试
// 这个示例程序展示如何写基础单元测试
package listing01
import (
"net/http"
"testing"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
// TestDownload 确认 http 包的 Get 函数可以下载内容
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)
}
}
}
}
Go 语言的测试工具只会认为以_test.go 结尾的文件是测试文件。如果没有遵从这个约定,在包里 运行 go test 的时候就可能会报告没有测试文件。一旦测试工具找到了测试文件,就会查找里 面的测试函数并执行。
可以看到测试函数的名字是 TestDownload。一个测试函数 必须是公开的函数,并且以 Test 单词开头。不但函数名字要以 Test 开头,而且函数的签名必 须接收一个指向 testing.T 类型的指针,并且不返回任何值。如果没有遵守这些约定,测试框 架就不会认为这个函数是一个测试函数,也不会让测试工具去执行它。
指向testing.T类型的指针很重要。这个指针提供的机制可以报告每个测试的输出和状态。 测试的输出格式没有标准要求。我更喜欢使用 Go 写文档的方式,输出容易读的测试结果。对我 来说,测试的输出是代码文档的一部分。测试的输出需使用完整易读的语句,来记录为什么需要 这个测试,具体测试了什么,以及测试的结果是什么。
表组测试
如果测试可以接受一组不同的输入并产生不同的输出的代码,那么应该使用表组测试的方法 进行测试。表组测试除了会有一组不同的输入值和期望结果之外,其余部分都很像基础单元测试。 测试会依次迭代不同的值,来运行要测试的代码。每次迭代的时候,都会检测返回的结果。这便 于在一个函数里测试不同的输入值和条件
// 这个示例程序展示如何写一个基本的表组测试
package listing08
import (
"net/http"
"testing"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
// TestDownload 确认 http 包的 Get 函数可以下载内容
// 并正确处理不同的状态
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)
}
}
}
}
}
模仿调用
不能总是假设运行测试的机器可以访问互联网。此外,依赖不属于你的或者你无法操作的服 务来进行测试,也不是一个好习惯。这两点会严重影响测试持续集成和部署的自动化。如果突然 断网,导致测试失败,就没办法部署新构建的程序。
为了修正这个问题,标准库包含一个名为 httptest 的包,它让开发人员可以模仿基于 HTTP 的网络调用。模仿(mocking)是一个很常用的技术手段,用来在运行测试时模拟访问不 可用的资源。包 httptest 可以让你能够模仿互联网资源的请求和响应。
// 这个示例程序展示如何内部模仿 HTTP GET 调用
// 与本书之前的例子有些差别
package listing12
import (
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
// feed 模仿了我们期望接收的 XML 文档
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>`
// mockServer 返回用来处理请求的服务器的指针
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))
}
测试服务端点
服务端点(endpoint)是指与服务宿主信息无关,用来分辨某个服务的地址,一般是不包含 宿主的一个路径。如果在构造网络 API,你会希望直接测试自己的服务的所有服务端点,而不用 启动整个网络服务。包 httptest 正好提供了做到这一点的机制。
// 这个示例程序实现了简单的网络服务
package main
import (
"log"
"net/http"
"github.com/goinaction/code/chapter9/listing17/handlers"
)
// main 是应用程序的入口
func main() {
handlers.Routes()
log.Println("listener : Started : Listening on :4000")
http.ListenAndServe(":4000", nil)
}
代码调用了内部 handlers 包的 Routes 函数。这个函数为托管的网络服务设置了一个服务端点。
// handlers 包提供了用于网络服务的服务端点
package handlers
import (
"encoding/json"
"net/http"
)
// Routes 为网络服务设置路由
func Routes() {
http.HandleFunc("/sendjson", SendJSON)
}
// SendJSON 返回一个简单的 JSON 文档
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"
"github.com/goinaction/code/chapter9/listing17/handlers"
)
const checkMark = "\u2713"
const ballotX = "\u2717"
func init() {
handlers.Routes()
}
// TestSendJSON 测试/sendjson 内部服务端点
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)
}
}
}
这次包的名字也使用_test 结尾。如果包使用这种方式命名,测试代码只能访问包里公开的标识符。即便测试代码文件和被测试的代码放在同一个文件夹中,也只能访问公开的标识符。
示例
Go 语言很重视给代码编写合适的文档。专门内置了 godoc 工具来从代码直接生成文档。这个工具的另一个特性是示例代码。示例代码给文档和测试都增加了一个可以扩展的维度。开发人员可以创建自己的示例,并且在包的 Go 文档里展示。
// 这个示例程序展示如何编写基础示例
package handlers_test
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
)
// ExampleSendJSON 提供了基础示例
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(w.Body).Decode(&u); err != nil {
log.Println("ERROR:", err)
}
// 使用 fmt 将结果写到 stdout 来检测输出
fmt.Println(u)
// Output:
// {Bill bill@ardanstudios.com}
}
对于示例代码,需要遵守一个规则。示例代码的函数名字必须基于已经存在的公开的函数或 者方法。我们的示例的名字基于 handlers 包里公开的 SendJSON 函数。如果没有使用已经存 在的函数或者方法,这个示例就不会显示在包的 Go 文档里。 写示例代码的目的是展示某个函数或者方法的特定使用方法。为了判断测试是成功还是失 败,需要将程序最终的输出和示例函数底部列出的输出做比较
基准测试
基准测试是一种测试代码性能的方法。想要测试解决同一问题的不同方案的性能,以及查看 哪种解决方案的性能更好时,基准测试就会很有用。基准测试也可以用来识别某段代码的 CPU 或者内存效率问题,而这段代码的效率可能会严重影响整个应用程序的性能。许多开发人员会用 基准测试来测试不同的并发模式,或者用基准测试来辅助配置工作池的数量,以保证能最大化系 统的吞吐量。和单元测试文件一样,基准测试的文件名也必须以_test.go 结尾。同时也必须导入 testing 包。
// BenchmarkSprintf 对 fmt.Sprintf 函数
// 进行基准测试
func BenchmarkSprintf(b *testing.B) {
number := 10
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Sprintf("%d", number)
}
}
可以看到第一个基准测试函数,名为 BenchmarkSprintf。 基准测试函数必须以 Benchmark 开头,接受一个指向 testing.B 类型的指针作为唯一参数。 为了让基准测试框架能准确测试性能,它必须在一段时间内反复运行这段代码,所以这里使用了 for 循环
基准测试框架默认会在持续 1 秒的时间内,反复调用需要测试的函数。测试框架每次调用测 试函数时,都会增加 b.N 的值。第一次调用时,b.N 的值为 1。需要注意,一定要将所有要进 行基准测试的代码都放到循环里,并且循环要使用 b.N 的值。否则,测试的结果是不可靠的。
在运行单元测试和基准测试时,存在很多参数,可以参阅官方文档进行查看