Go中的高级单元测试模式

427 阅读8分钟

一个好的开发者总是测试他们的代码,然而,普通的测试方法在某些情况下可能太简单了。根据项目的复杂程度,你可能需要运行高级测试来准确评估代码的性能。

在这篇文章中,我们将研究Go中的一些测试模式,这将帮助你为任何项目编写有效的测试。我们将介绍嘲弄、测试夹具、测试助手和黄金文件等概念,你将看到如何在实际场景中应用每种技术。

要跟上这篇文章,你应该有Go中单元测试的知识。让我们开始吧!

测试HTTP处理程序

首先,让我们考虑一个常见的场景,测试HTTP处理程序。HTTP处理程序应该与它们的依赖关系松散地结合在一起,这样就可以很容易地隔离一个元素进行测试而不影响代码的其他部分。如果你的HTTP处理程序最初设计得很好,测试应该是相当简单的。

检查状态代码

让我们考虑一个基本的测试,检查以下HTTP处理程序的状态代码。

func index(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
}

上面的index() 处理程序应该为每个请求返回一个200 OK的响应。让我们用下面的测试来验证该处理程序的响应。

func TestIndexHandler(t *testing.T) {
    w := httptest.NewRecorder()
    r := httptest.NewRequest(http.MethodGet, "/", nil)

    index(w, r)

    if w.Code != http.StatusOK {
        t.Errorf("Expected status: %d, but got: %d", http.StatusOK, w.Code)
    }
}

在上面的代码片段中,我们使用httptest 包来测试index() 处理器。我们返回了一个httptest.ResponseRecorder ,它通过NewRecorder() 方法实现了http.ResponseWriter 接口。http.ResponseWriter 记录了任何突变,使我们可以在测试中进行断言。

我们还可以使用httptest.NewRequest() 方法创建一个HTTP请求。这样做可以指定处理程序所期望的请求类型,如请求方法、查询参数和响应体。在通过http.Header 类型获得http.Request 对象后,你还可以设置请求头。

在用http.Request 对象和响应记录器调用index() 处理程序后,你可以使用Code 属性直接检查处理程序的响应。要对响应的其他属性进行断言,比如头或正文,你可以访问响应记录器上的适当方法或属性。

$ go test -v
=== RUN   TestIndexHandler
--- PASS: TestIndexHandler (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.004s

外部依赖性

现在,让我们考虑另一种常见的情况,即我们的HTTP处理器对外部服务有依赖性。

func getJoke(w http.ResponseWriter, r *http.Request) {
    u, err := url.Parse(r.URL.String())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    jokeId := u.Query().Get("id")
    if jokeId == "" {
        http.Error(w, "Joke ID cannot be empty", http.StatusBadRequest)
        return
    }

    endpoint := "https://icanhazdadjoke.com/j/" + jokeId

    client := http.Client{
        Timeout: 10 * time.Second,
    }

    req, err := http.NewRequest(http.MethodGet, endpoint, nil)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    req.Header.Set("Accept", "text/plain")

    resp, err := client.Do(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    defer resp.Body.Close()

    b, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if resp.StatusCode != http.StatusOK {
        http.Error(w, string(b), resp.StatusCode)
        return
    }

    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusOK)
    w.Write(b)
}

func main() {
    mux := http.NewServeMux()

在上面的代码块中,getJoke 处理程序期望有一个id 查询参数,它用来从Random dad joke API中获取一个笑话

让我们为这个处理程序写一个测试。

func TestGetJokeHandler(t *testing.T) {
    table := []struct {
        id         string
        statusCode int
        body       string
    }{
        {"R7UfaahVfFd", 200, "My dog used to chase people on a bike a lot. It got so bad I had to take his bike away."},
        {"173782", 404, `Joke with id "173782" not found`},
        {"", 400, "Joke ID cannot be empty"},
    }

    for _, v := range table {
        t.Run(v.id, func(t *testing.T) {
            w := httptest.NewRecorder()
            r := httptest.NewRequest(http.MethodGet, "/joke?id="+v.id, nil)

            getJoke(w, r)

            if w.Code != v.statusCode {
                t.Fatalf("Expected status code: %d, but got: %d", v.statusCode, w.Code)
            }

            body := strings.TrimSpace(w.Body.String())

            if body != v.body {
                t.Fatalf("Expected body to be: '%s', but got: '%s'", v.body, body)
            }
        })
    }
}

我们使用表格驱动测试来测试处理程序对一系列输入的影响。第一个输入是一个有效的Joke ID ,应该返回一个200 OK的响应。第二个是一个无效的ID,应该返回一个404响应。最后一个输入是一个空的ID,应该返回一个400坏的请求响应。

当你运行该测试时,它应该成功通过。

$ go test -v
=== RUN   TestGetJokeHandler
=== RUN   TestGetJokeHandler/R7UfaahVfFd
=== RUN   TestGetJokeHandler/173782
=== RUN   TestGetJokeHandler/#00
--- PASS: TestGetJokeHandler (1.49s)
    --- PASS: TestGetJokeHandler/R7UfaahVfFd (1.03s)
    --- PASS: TestGetJokeHandler/173782 (0.47s)
    --- PASS: TestGetJokeHandler/#00 (0.00s)
PASS
ok      github.com/ayoisaiah/random     1.498s

请注意,上面代码块中的测试向真正的API发出了HTTP请求。这样做会影响被测试代码的依赖性,这对单元测试代码来说是不好的做法。

相反,我们应该模拟HTTP客户端。我们有几种不同的方法来模拟Go,下面我们就来探讨一下。

Go中的嘲讽

在Go中模拟HTTP客户端的一个相当简单的模式是创建一个自定义接口。我们的接口将定义一个函数中使用的方法,并根据函数的调用位置传递不同的实现。

我们上面的HTTP客户端的自定义接口应该看起来像下面的代码块。

type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
}

我们对getJoke() 的签名将看起来像下面的代码块。

func getJoke(client HTTPClient) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
      // rest of the function
    }
}

getJoke() 处理程序的原始主体被移到返回值里面。client 变量声明被从主体中删除,而改用HTTPClient 接口。

HTTPClient 接口封装了一个Do() 方法,它接受一个HTTP请求并返回一个HTTP响应和一个错误。

当我们在main() 函数中调用getJoke() 时,我们需要提供一个HTTPClient 的具体实现。

func main() {
    mux := http.NewServeMux()

    client := http.Client{
        Timeout: 10 * time.Second,
    }

    mux.HandleFunc("/joke", getJoke(&client))

    http.ListenAndServe(":1212", mux)
}

http.Client 类型实现了HTTPClient 接口,所以程序继续调用随机爸爸笑话API。我们需要用一个不同的HTTPClient 实现来更新测试,它不会通过网络进行HTTP请求。

首先,我们将创建一个HTTPClient 接口的模拟实现。

type MockClient struct {
    DoFunc func(req *http.Request) (*http.Response, error)
}

func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
    return m.DoFunc(req)
}

在上面的代码块中,MockClient 结构通过提供Do 方法实现了HTTPClient 接口,该方法调用了DoFunc 属性。现在,当我们在测试中创建一个MockClient 的实例时,我们需要实现DoFunc 函数。

func TestGetJokeHandler(t *testing.T) {
    table := []struct {
        id         string
        statusCode int
        body       string
    }{
        {"R7UfaahVfFd", 200, "My dog used to chase people on a bike a lot. It got so bad I had to take his bike away."},
        {"173782", 404, `Joke with id "173782" not found`},
        {"", 400, "Joke ID cannot be empty"},
    }

    for _, v := range table {
        t.Run(v.id, func(t *testing.T) {
            w := httptest.NewRecorder()
            r := httptest.NewRequest(http.MethodGet, "/joke?id="+v.id, nil)

            c := &MockClient{}

            c.DoFunc = func(req *http.Request) (*http.Response, error) {
                return &http.Response{
                    Body:       io.NopCloser(strings.NewReader(v.body)),
                    StatusCode: v.statusCode,
                }, nil
            }

            getJoke(c)(w, r)

            if w.Code != v.statusCode {
                t.Fatalf("Expected status code: %d, but got: %d", v.statusCode, w.Code)
            }

            body := strings.TrimSpace(w.Body.String())

            if body != v.body {
                t.Fatalf("Expected body to be: '%s', but got: '%s'", v.body, body)
            }
        })
    }
}

在上面的代码片段中,DoFunc 为每个测试案例进行了调整,所以它返回一个自定义的响应。现在,我们已经避免了所有的网络调用,所以测试的通过率会快很多。

$ go test -v
=== RUN   TestGetJokeHandler
=== RUN   TestGetJokeHandler/R7UfaahVfFd
=== RUN   TestGetJokeHandler/173782
=== RUN   TestGetJokeHandler/#00
--- PASS: TestGetJokeHandler (0.00s)
    --- PASS: TestGetJokeHandler/R7UfaahVfFd (0.00s)
    --- PASS: TestGetJokeHandler/173782 (0.00s)
    --- PASS: TestGetJokeHandler/#00 (0.00s)
PASS
ok      github.com/ayoisaiah/random     0.005s

当你的处理程序依赖于另一个外部系统,如数据库时,你可以使用这个相同的原则。将处理程序与任何特定的实现解耦,允许你在测试中轻松模拟依赖关系,同时在你的应用程序的代码中保留真正的实现。

在测试中使用外部数据

在Go中,你应该把测试的外部数据放在一个叫做testdata 的目录中。当你为你的程序构建二进制文件时,testdata 目录会被忽略,所以你可以使用这种方法来存储你想测试程序的输入。

例如,让我们写一个函数,从一个二进制文件生成base64 编码。

func getBase64Encoding(b []byte) string {
    return base64.StdEncoding.EncodeToString(b)
}

为了测试这个函数产生正确的输出,让我们把一些样本文件和它们相应的base64 编码放在我们项目根部的testdata 目录下。

$ ls testdata
img1.jpg img1_base64.txt img2.jpg img2_base64.txt img3.jpg img3_base64.txt

为了测试我们的getBase64Encoding() 功能,运行下面的代码。

func TestGetBase64Encoding(t *testing.T) {
    cases := []string{"img1", "img2", "img3"}

    for _, v := range cases {
        t.Run(v, func(t *testing.T) {
            b, err := os.ReadFile(filepath.Join("testdata", v+".jpg"))
            if err != nil {
                t.Fatal(err)
            }

            expected, err := os.ReadFile(filepath.Join("testdata", v+"_base64.txt"))
            if err != nil {
                t.Fatal(err)
            }

            got := getBase64Encoding(b)

            if string(expected) != got {
                t.Fatalf("Expected output to be: '%s', but got: '%s'", string(expected), got)
            }
        })
    }
}

从文件系统中读取每个样本文件的字节,然后送入getBase64Encoding() 函数。随后将输出与预期输出进行比较,预期输出也是从testdata 目录中获取的。

让我们通过在testdata 中创建一个子目录,使测试更容易维护。在我们的子目录中,我们将添加所有的输入文件,允许我们简单地迭代每个二进制文件,并比较实际输出和预期输出。

现在,我们可以添加更多的测试案例,而不需要接触源代码。

$ go test -v
=== RUN   TestGetBase64Encoding
=== RUN   TestGetBase64Encoding/img1
=== RUN   TestGetBase64Encoding/img2
=== RUN   TestGetBase64Encoding/img3
--- PASS: TestGetBase64Encoding (0.04s)
    --- PASS: TestGetBase64Encoding/img1 (0.01s)
    --- PASS: TestGetBase64Encoding/img2 (0.01s)
    --- PASS: TestGetBase64Encoding/img3 (0.01s)
PASS
ok      github.com/ayoisaiah/random     0.044s

使用黄金文件

如果你在使用Go模板,最好是测试一下生成的输出与预期的输出,以确认模板是否按预期工作。Go模板通常比较大,所以不建议像我们在本教程中迄今为止所做的那样在源代码中硬编码预期输出。

让我们来探讨一下Go模板的另一种方法,它可以在项目的整个生命周期内简化测试的编写和维护。

黄金文件是一种特殊类型的文件,包含测试的预期输出。测试函数从黄金文件中读取,将其内容与测试的预期输出进行比较。

在下面的例子中,我们将使用html/template ,生成一个HTML表格,其中包含库存中每本书的一行。

type Book struct {
    Name          string
    Author        string
    Publisher     string
    Pages         int
    PublishedYear int
    Price         int
}

var tmpl = `<table class="table">
  <thead>
    <tr>
      <th>Name</th>
      <th>Author</th>
      <th>Publisher</th>
      <th>Pages</th>
      <th>Year</th>
      <th>Price</th>
    </tr>
  </thead>
  <tbody>
    {{ range . }}<tr>
      <td>{{ .Name }}</td>
      <td>{{ .Author }}</td>
      <td>{{ .Publisher }}</td>
      <td>{{ .Pages }}</td>
      <td>{{ .PublishedYear }}</td>
      <td>${{ .Price }}</td>
    </tr>{{ end }}
  </tbody>
</table>
`

var tpl = template.Must(template.New("table").Parse(tmpl))

func generateTable(books []Book, w io.Writer) error {
    return tpl.Execute(w, books)
}

func main() {
    books := []Book{
        {
            Name:          "The Odessa File",
            Author:        "Frederick Forsyth",
            Pages:         334,
            PublishedYear: 1979,
            Publisher:     "Bantam",
            Price:         15,
        },
    }

    err := generateTable(books, os.Stdout)
    if err != nil {
        log.Fatal(err)
    }
}

上面的generateTable() 函数从Book 对象的片断中创建HTML表。上面的代码将产生以下输出。

$ go run main.go
<table class="table">
  <thead>
    <tr>
      <th>Name</th>
      <th>Author</th>
      <th>Publisher</th>
      <th>Pages</th>
      <th>Year</th>
      <th>Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The Odessa File</td>
      <td>Frederick Forsyth</td>
      <td>Bantam</td>
      <td>334</td>
      <td>1979</td>
      <td>$15</td>
    </tr>
  </tbody>
</table>

为了测试上面的函数,我们将捕获实际结果并与预期结果进行比较。我们将像上一节那样把预期结果存储在testdata 目录中,然而,我们必须做一些改变。

假设我们在一个库存中拥有以下的书籍列表。

var inventory = []Book{
    {
        Name:          "The Solitare Mystery",
        Author:        "Jostein Gaarder",
        Publisher:     "Farrar Straus Giroux",
        Pages:         351,
        PublishedYear: 1990,
        Price:         12,
    },
    {
        Name:          "Also Known As",
        Author:        "Robin Benway",
        Publisher:     "Walker Books",
        Pages:         208,
        PublishedYear: 2013,
        Price:         10,
    },
    {
        Name:          "Ego Is the Enemy",
        Author:        "Ryan Holiday",
        Publisher:     "Portfolio",
        Pages:         226,
        PublishedYear: 2016,
        Price:         18,
    },
}

这个图书清单的预期输出将跨越许多行,因此,很难将其作为一个字符串字面放在源代码内。

<table class="table">
  <thead>
    <tr>
      <th>Name</th>
      <th>Author</th>
      <th>Publisher</th>
      <th>Pages</th>
      <th>Year</th>
      <th>Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The Solitaire Mystery</td>
      <td>Jostein Gaarder</td>
      <td>Farrar Straus Giroux</td>
      <td>351</td>
      <td>1990</td>
      <td>$12</td>
    </tr>
    <tr>
      <td>Also Known As</td>
      <td&gt;Robin Benway</td>
      <td>Walker Books</td>
      <td>308</td>
      <td>2013</td>
      <td>$10</td>
    </tr>
    <tr>
      <td>Ego Is The Enemy</td>
      <td>Ryan Holiday</td>
      <td>Portfolio</td>
      <td>226</td>
      <td>2016</td>
      <td>$18</td>
    </tr>
  </tbody>
</table>

除了对较大的输出实用外,一个黄金文件可以自动更新和生成。

虽然可以写一个辅助函数来创建和更新黄金文件,但我们可以利用goldie,一个专门为黄金文件创建的工具。

用下面的命令安装最新版本的goldie。

$ go get -u github.com/sebdah/goldie/v2

让我们继续使用goldie来测试generateTable() 函数。

func TestGenerateTable(t *testing.T) {
    var buf bytes.Buffer

    err := generateTable(inventory, &buf)
    if err != nil {
        t.Fatal(err)
    }

    actual := buf.Bytes()

    g := goldie.New(t)
    g.Assert(t, "books", actual)
}

上面的测试在一个字节的缓冲区中捕获了generateTable() 函数的输出。然后,它将缓冲区的内容传递给goldie 实例的Assert() 方法。缓冲区中的内容将与testdata 目录中的books.golden 文件的内容进行比较。

最初,运行该测试会失败,因为我们还没有创建books.golden 文件。

$ go test -v
=== RUN   TestGenerateTable
    main_test.go:48: Golden fixture not found. Try running with -update flag.
--- FAIL: TestGenerateTable (0.00s)
FAIL
exit status 1
FAIL    github.com/ayoisaiah/random     0.006s

错误信息建议我们添加-update 标志,这将用缓冲区的内容创建books.golden 文件。

$ go test -v -update
=== RUN   TestGenerateTable
--- PASS: TestGenerateTable (0.00s)
PASS
ok      github.com/ayoisaiah/random     0.006s

在随后的运行中,我们应该删除-update 标志,这样我们的黄金文件就不会被不断地更新。

对模板的任何改变都会导致测试失败。例如,如果你将价格字段更新为欧元而不是美元,你会立即收到一个错误。这些错误的发生是因为generateTable() 函数的输出不再符合黄金文件的内容。

Goldie提供了差异功能,以帮助你在这些错误发生时发现变化。

$ go test -v
=== RUN   TestGenerateTable
    main_test.go:48: Result did not match the golden fixture. Diff is below:

        --- Expected
        +++ Actual
        @@ -18,3 +18,3 @@
               <td>1990&lt;/td>
        -      <td>$12</td>
        +      <td>€12</td>
             </tr><tr>
        @@ -25,3 +25,3 @@
               <td>2013</td>
        -      <td>$10</td>
        +      <td>€10</td>
             </tr><tr>
        @@ -32,3 +32,3 @@
               <td>2016</td>
        -      <td>$18</td>
        +      <td>€18</td>
             </tr>

--- FAIL: TestGenerateTable (0.00s)
FAIL
exit status 1
FAIL    github.com/ayoisaiah/random     0.007s

在上面的输出中,该变化被清楚地突出显示。这些变化是故意的,所以我们可以通过使用-update 标志更新黄金文件,使我们的测试再次通过。

$ go test -v -update
=== RUN   TestGenerateTable
--- PASS: TestGenerateTable (0.00s)
PASS
ok      github.com/ayoisaiah/random     0.006s

结论

在本教程中,我们研究了Go中的一些高级测试技术。首先,我们深入研究了我们的HTTP包,并学习了如何用自定义接口模拟我们的HTTP客户端。然后,我们回顾了如何在测试中使用外部数据并使用goldie创建黄金文件。

我希望你觉得这个帖子很有用。如果你有任何其他技术想分享,请在下面留言。谢谢你的阅读,并祝你编码愉快

The postAdvanced unit testing patterns in Goappeared first onLogRocket Blog.