这两天在学习 Golang 如何 TDD ,了解到匿名结构体切片在 TableDrivenTests 中经常用到。

Writing good tests is not trivial, but in many situations a lot of ground can be covered with table-driven tests: Each table entry is a complete test case with inputs and expected results, and sometimes with additional information such as a test name to make the test output easily readable. If you ever find yourself using copy and paste when writing a test, think about whether refactoring into a table-driven test or pulling the copied code out into a helper function might be a better option.

大意就是 table driven 的每个数据用例定义了测试的输入输出,把测试数据和测试逻辑分开,易于维护和保持代码整洁。而 table driven 的数据结构就是一个匿名结构体切片 (anonymous struct slice) 。为了在系统了解它,在网上搜了几篇典型的文章并总结如下。

结构体基础

结构体 (struct) 将多个不同类型的字段集中组成一种复合类型,按声明时的字段顺序初始化。

type user struct {
name string
age byte
}

user := user {"Tom", 2}

定义匿名结构体时没有 type 关键字,与其他定义类型的变量一样,如果在函数外部需在结构体变量前加上 var 关键字,在函数内部可省略 var 关键字。

// 在函数外部定义匿名结构体并赋值给 config
var config struct {
  APIKey string
  OAuthConfig oauth.Config
}

// 定义并初始化并赋值给 data
data := struct {
  Title string
  Users []*User
}{
  title,
  users
}

匿名结构体使用场景

匿名结构体在四种常见情景下的用法。

组织全局变量

属于同一类的全局变量可通过匿名结构体组织在一起。

var config struct {
APIKey      string
OAuthConfig oauth.Config
}

config.APIKey = "BADC0C0A"

数据模版

可在后端把数据组织成前端需要的格式传给渲染模版

package main
import (
    "html/template"
    "net/http"
    "strings"
)
type Paste struct {
    Expiration string
    Content    []byte
    UUID       string
}
func pasteHandler(w http.ResponseWriter, r *http.Request) {
    paste_id := strings.TrimPrefix(r.URL.Path, "/paste")
    paste := &Paste{UUID: paste_id}
    keep_alive := false
    burn_after_reading := false
    data := struct {
        Paste *Paste
        KeepAlive bool
        BurnAfterReading bool
    } {
        paste,
        keep_alive,
        burn_after_reading,
    }
    t, _ := template.ParseFiles("templates/paste.html")
    t.Execute(w, data)
}

匿名函授定义并初始化之后赋值给 data 变量,除了把 Paste 结构体对象的值传给前端之外,还额外添加了必要的字段。写过前后端的同学应该知道,前端有时需要后端返回一个标志变量作为判断条件是否显示某一块内容。

Expiration: {{ .Paste.Expiration }}
UUID: {{ .Paste.UUID}}
{{ if .BurnAfterReading }}
BurnAfterReading: True
{{ else }}
BurnAfterReading: False
{{ end }}

测试案例数据

在写测试代码时,经常用到匿名结构体生成用例的输入输出,为了覆盖各个测试维度,通常结合切片使用,构成了测试样例尽可能地覆盖所有可能发生情况。

var indexRuneTests = []struct {
s    string
rune rune
out  int
}{
{"a A x", 'A', 2},
{"some_text=some_value", '=', 9},
{"☺a", 'a', 3},
{"a☻☺b", '☺', 4},
}

嵌入式锁 (Embedded lock)

var hits struct {
sync.Mutex
n int
}

hits.Lock()
hits.n++
hits.Unlock()

Golang TDD 技巧

结合例子分析:请求 Github 的接口,获取某个项目的最新版本号。因为请求 Github 接口涉及到系统外部的响应,在写测试时把请求外部系统的逻辑抽象放在一个实现了 Golang interface 的方法中。在测试中,我们写测试代码时实现 Golang Interface 定义的这个请求外部系统的方法,这样我们就能模拟外部系统的返回而不是依赖外部系统的返回。

package main
import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
)
type ReleasesInfo struct {
    Id      uint   `json:"id"`
    TagName string `json:"tag_name"`
}
type ReleaseInfoer interface {
    GetLatestReleaseTag(string) (string, error)
}
type GithubReleaseInfoer struct{}
// Function to actually query the Github API for the release information.
func (gh GithubReleaseInfoer) GetLatestReleaseTag(repo string) (string, error) {
    apiUrl := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
    response, err := http.Get(apiUrl)
    if err != nil {
        return "", err
    }
    defer response.Body.Close()
    body, err := ioutil.ReadAll(response.Body)
    if err != nil {
        return "", err
    }
    releases := []ReleasesInfo{}
    if err := json.Unmarshal(body, &releases); err != nil {
        return "", err
    }
    tag := releases[0].TagName
    return tag, nil
}
// Function to get the message to display to the end user
func getReleaseTagMessage(ri ReleaseInfoer, repo string) (string, error) {
    tag, err := ri.GetLatestReleaseTag(repo)
    if err != nil {
        return "", fmt.Errorf("Error querying Github API: %s", err)
    }
    return fmt.Sprintf("The latest release is %s", tag), nil
}
func main() {
    gh := GithubReleaseInfoer{}
    msg, err := getReleaseTagMessage(gh, "docker/machine")
    if err != nil {
        fmt.Fprintln(os.Stderr, msg)
    }
    fmt.Println(msg)
}

分析上面的代码:ReleasesInfo 结构体实现了定义在 ReleaseInfoer interface 中的方法。把请求外部的方法声明在 interface 中的好处上文有提到,方便写测试代码。接下来看看测试代码。

package main
import (
    "errors"
    "reflect"
    "testing"
)
type FakeReleaseInfoer struct {
    Tag string
    Err error
}
func (f FakeReleaseInfoer) GetLatestReleaseTag(repo string) (string, error) {
    if f.Err != nil {
        return "", f.Err
    }
    return f.Tag, nil
}
func TestGetReleaseTagMessage(t *testing.T) {
    cases := []struct {
        f           FakeReleaseInfoer
        repo        string
        expectedMsg string
        expectedErr error
    }{
        {
            f: FakeReleaseInfoer{
                Tag: "v0.1.0",
                Err: nil,
            },
            repo:        "doesnt/matter",
            expectedMsg: "The latest release is v0.1.0",
            expectedErr: nil,
        },
        {
            f: FakeReleaseInfoer{
                Tag: "v0.1.0",
                Err: errors.New("TCP timeout"),
            },
            repo:        "doesnt/foo",
            expectedMsg: "",
            expectedErr: errors.New("Error querying Github API: TCP timeout"),
        },
    }
    for _, c := range cases {
        msg, err := getReleaseTagMessage(c.f, c.repo)
        if !reflect.DeepEqual(err, c.expectedErr) {
            t.Errorf("Expected err to be %q but it was %q", c.expectedErr, err)
        }
        if c.expectedMsg != msg {
            t.Errorf("Expected %q but got %q", c.expectedMsg, msg)
        }
    }
}

结构体类型 FakeReleaseInfoer 实现了接口 ReleaseInfoer 中的 GetLatestReleaseTag 方法。模拟了 Github 接口的返回,这样我们就不怕在跑 CI 是由于频繁请求而被 Github 的服务器封杀服务器 IP 地址。重点分析 TestGetReleaseTagMessage 函数。

定义了匿名结构体切片 (anonymous struct slice) 并初始化之后赋值给 data 参数。结构体中包含了需要测试 getReleaseTagMessage 的输入输出(输出包括正确和错误的输出,错误的输出发生在请求外部系统时出错)

Run:

go test -v
=== RUN   TestGetReleaseTagMessage
--- PASS: TestGetReleaseTagMessage (0.00s)
PASS
ok      github.com/wenweih/testing    0.023s

推荐阅读