Skip to content

Go 语言实战 第 9 章 测试和性能

🏷️ Go 《Go 语言实战》

作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。

9.1 单元测试

单元测试是用来测试包或者程序的一部分代码或者一组代码的函数。
测试的目的是确认目标代码在给定的场景下,有没有按照期望工作。

  • 正向路径测试:正常执行的情况下,保证代码不产生错误的测试。

  • 负向路径测试:保证代码不仅会产生错误,而且是预期错误的测试。

  • 基础测试(basic test:只使用一组参数和结果来测试代码。

  • 表组测试(table test:使用多组参数和结果来测试代码。

可以使用一些方法来模仿(mock)测试代码需要使用到的外部资源,如数据库或者网络服务器。

基础单元测试示例:

go
// Sample test to show how to write a basic unit test.
package listing01

import (
    "net/http"
    "testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// TestDownload validates the http Get function can download content.
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 -v 运行测试(-v 表示提供冗余输出),运行结果如下:

go
=== RUN   TestDownload
    listing01_test.go:17: Given the need to test downloading content.
    listing01_test.go:19:     When checking "http://www.goinggo.net/feeds/posts/default?alt=rss" for status code "200"
    listing01_test.go:27:         Should be able to make the Get call. ✓
    listing01_test.go:36:         Should receive a "200" status. ✗ 404
--- FAIL: TestDownload (3.59s)

Go 语言测试工具只会认为以 _test.go 结尾的文件是测试文件。 如果没有遵循这个约定,在包里运行 go test 时可能会报告没有测试文件。

testing 包提供了从测试框架到报告测试的输出和状态的各种测试功能的支持。

一个测试函数必须是公开的函数,并且以 TEST 单词开头,而且函数的签名必须接收一个指向 testing.T 类型的指针,并且不返回任何值。

测试的输出格式没有标准要求。作者建议使用 Go 写文档的方式,输出容易读的测试结果。

使用 t.Logt.Logf 方法输出测试消息。如果执行 go test 没有加入 -v 选项,除非测试失败,否则看不到任何测试输出。

每个测试函数都应该通过解释这个测试的给定要求(given need ,来说明为什么应该存在这个测试。

t.Fatalt.Fatalf 方法报告这个单元测试已经失败,并且回想测试输出写一些消息,然后立即停止这个测试函数的执行。

如果需要报告测试失败,但并不想停止测试函数的执行,可以使用 t.Error 系列方法。

表组测试示例:

go
// Sample test to show how to write a basic unit table test.
package listing08

import (
    "net/http"
    "testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// TestDownload validates the http Get function can download
// content and handles different status conditions properly.
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)
                }
            }
        }
    }
}

表组测试除了会有一组不同的输入值和期望结果之外,其余部分都很像基础单元测试。

模仿(mocking 是一个很常用的技术手段,用来在运行测试时模拟访问不可用的资源。

标准库包含一个名为 httptest 的包,可以模仿基于 HTTP 的网络调用。

go
// Sample test to show how to mock an HTTP GET call internally.
// Differs slightly from the book to show more.
package listing12

import (
    "encoding/xml"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// feed is mocking the XML document we except to receive.
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 returns a pointer to a server to handle the get call.
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))
}

// TestDownload validates the http Get function can download content
// and the content can be unmarshaled and clean.
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.Fatalf("\t\tShould receive a \"%d\" status. %v %v",
                    statusCode, ballotX, resp.StatusCode)
            }
            t.Logf("\t\tShould receive a \"%d\" status. %v",
                statusCode, checkMark)

            var d Document
            if err := xml.NewDecoder(resp.Body).Decode(&d); err != nil {
                t.Fatal("\t\tShould be able to unmarshal the response.",
                    ballotX, err)
            }
            t.Log("\t\tShould be able to unmarshal the response.",
                checkMark)

            if len(d.Channel.Items) == 1 {
                t.Log("\t\tShould have \"1\" item in the feed.",
                    checkMark)
            } else {
                t.Error("\t\tShould have \"1\" item in the feed.",
                    ballotX, len(d.Channel.Items))
            }
        }
    }
}

// Item defines the fields associated with the item tag in
// the buoy RSS document.
type Item struct {
    XMLName     xml.Name `xml:"item"`
    Title       string   `xml:"title"`
    Description string   `xml:"description"`
    Link        string   `xml:"link"`
}

// Channel defines the fields associated with the channel tag in
// the buoy RSS document.
type Channel struct {
    XMLName     xml.Name `xml:"channel"`
    Title       string   `xml:"title"`
    Description string   `xml:"description"`
    Link        string   `xml:"link"`
    PubDate     string   `xml:"pubDate"`
    Items       []Item   `xml:"item"`
}

// Document defines the fields associated with the buoy RSS document.
type Document struct {
    XMLName xml.Name `xml:"rss"`
    Channel Channel  `xml:"channel"`
    URI     string
}

相当于在本地(localhost)启动了一个 HTTP 服务器,并提供了模拟的响应。

测试服务端点

服务端点(endpoint)是指与服务宿主信息无关,用来分辨某个服务的地址,一般是不包含宿主的一个路径。

JiaJia: 没理解错的话,这个指的就是常说的路由,或者叫映射。

main 方法中调用 handlers.Routes() 方法设置路由,并监听 4000 端口。

go
// This sample code implement a simple web service.
package main

import (
    "log"
    "net/http"
    "github.com/goinaction/code/chapter9/listing17/handlers"
)

// main is the entry point for the application.
func main() {
    handlers.Routes()

    log.Println("listener : Started : Listening on :4000")
    http.ListenAndServe(":4000", nil)
}

handlers.go

Routes() 方法设置了一个 /sendjson 的服务端点,并绑定到 SendJSON 函数。这个函数是一个端点方法的示例,向响应里写入了一段 JSON 数据。

go
// Package handlers provides the endpoints for the web service.
package handlers

import (
    "encoding/json"
    "net/http"
)

// Routes sets the routes for the web service.
func Routes() {
    http.HandleFunc("/sendjson", SendJSON)
}

// SendJSON returns a simple JSON document.
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)
}

handlers_test.go

仍然还是使用 httptest 包来测试自己服务的端点。

测试时需要先调用 handlers.Routes() 方法以初始化路由。

go
// Sample test to show how to test the execution of an
// internal endpoint.
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 testing the sendjson internal endpoint.
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 for Email.", ballotX, u.Email)
        }
    }
}

运行结果

bash
=== RUN   TestSendJSON
    handlers_test.go:23: Given the need to test the SendJSON endpoint.
    handlers_test.go:30: 	Should be able to create a request.
    handlers_test.go:39: 	Should receive "200"
    handlers_test.go:49: 	Should decode the response.
    handlers_test.go:52: 	Should have a Name.
    handlers_test.go:58: 	Should have an Email.
--- PASS: TestSendJSON (0.00s)
PASS

9.2 示例

godoc 除了可以用来生成文档之外,还可以用来展示示例代码。示例代码给文档和测试都增加了一个可以扩展的维度。

官方标准库的文档里也有代码示例,如 json.Marshal

开发人员可以创建自己的示例,并且在包的 Go 文档里展示。

handlers_example_test.go

go
// Sample test to show how to write a basic example.
package handlers_test

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/http/httptest"
)

// ExampleSendJSON provides a basic example.
func ExampleSendJSON() {
    r, _ := http.NewRequest("GET", "/sendjson", nil)
    w := httptest.NewRecorder()
    http.DefaultServeMux.ServeHTTP(w, r)

    var u struct {
        Name  string
        Email string
    }

    if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
        log.Println("ERROR:", err)
    }

    fmt.Println(u)
    // Output:
    // {Bill bill@ardanstudios.com}
}

示例代码的函数名必须以 Example 作为前缀,且必须基于已经存在的公开的函数或者方法。

写示例代码的目的是展示某个函数或者方法的特定使用方法。

Output: 标记用来在文档中标记出示例函数运行后期望的输出。

Go 的测试框架知道如何比较注释里的期望输出和标准输出的最终输出。如果两者匹配,这个示例作为测试就会通过,并加入到包的 Go 文档。如果输出不匹配,这个示例作为测试就会失败。

运行测试时,可以通过 -run 参数指定特定的函数。

bash
go test -v -run="ExampleSendJSON"

9.3 基准测试

基准测试是一种测试代码性能的方法。基准测试也可以用来识别某段代码的 CPU 或者内存效率问题,而这段代码的效率可能会严重影响整个应用程序的性能。

下面这组基准测试函数是为了找出将整数值转为字符串的最快方法。

go
// Sample benchmarks to test which function is better for converting
// an integer into a string. First using the fmt.Sprintf function,
// then the strconv.FormatInt function and then strconv.Itoa.
package listing05_test

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

// BenchmarkSprintf provides performance numbers for the
// fmt.Sprintf function.
func BenchmarkSprintf(b *testing.B) {
    number := 10

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        fmt.Sprintf("%d", number)
    }
}

// BenchmarkFormat provides performance numbers for the
// strconv.FormatInt function.
func BenchmarkFormat(b *testing.B) {
    number := int64(10)

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        strconv.FormatInt(number, 10)
    }
}

// BenchmarkItoa provides performance numbers for the
// strconv.Itoa function.
func BenchmarkItoa(b *testing.B) {
    number := 10

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        strconv.Itoa(number)
    }
}

基准测试文件名也必须以 _test.go 结尾,同时必须导入 testing 包。

基准测试函数必须以 Benchmark 开头,接受一个指向 testing.B 的指针作为唯一参数。

基准测试框架默认会在持续 1 秒的时间内,反复调用需要测试的函数。测试框架每次调用测试函数时,都会增加 b.N 的值。第一次调用时,b.N 的值为 1。
需要注意的是,一定要将所有要进行基准测试的代码都放到循环里,并且循环要使用 b.N 的值。否则,测试结果是不可靠的。

如果只希望运行基准测试函数,需要加入 -bench 选项。

bash
go test -v -run="none" -bench="BenchmarkSprintf"

有时候增加基准测试的时间,会得到更加精确的性能结果。
对大多数测试来说,超过 3 秒的基准测试并不会改变测试的精度。

一起运行 3 个基准测试的结果:

bash
goos: windows
goarch: amd64
BenchmarkSprintf
BenchmarkSprintf-4      12766038                83.1 ns/op
BenchmarkFormat
BenchmarkFormat-4       442571220                2.66 ns/op
BenchmarkItoa
BenchmarkItoa-4         462937690                2.58 ns/op
PASS

添加 -benchmem 选项可以看到每次操作分配内存的次数,以及总共分配内存的字节数。

  • B/op :每次操作分配的字节数。
  • allocs/op :每次操作从堆上分配内存的次数。

JiaJia:

实际执行的时候没有效果。不知道是不是哪里用错了,还是版本不一致导致的。

9.4 小结

  • 测试功能被内置到 Go 语言中,Go 语言提供了必要的测试工具。
  • go test 工具用来运行测试。
  • 测试文件总是以 _test.go 作为文件名的结尾。
  • 表组测试是利用一个测试函数测试多组值的好办法。
  • 包中的示例代码,既能用于测试,也能用于文档。
  • 基准测试提供了探查代码性能的机制。