这两天在学习 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}
typevarvar
// 在函数外部定义匿名结构体并赋值给 configvar config struct {
  APIKey string
  OAuthConfig oauth.Config
}// 定义并初始化并赋值给 datadata := struct {
  Title string
  Users []*User
}{
  title,
  users
}

匿名结构体使用场景

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

组织全局变量

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

var config struct {
    APIKey      string
    OAuthConfig oauth.Config
}

config.APIKey = "BADC0C0A"

数据模版

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

package mainimport (    "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)
}
dataPaste
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},
    {"ab", '', 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 mainimport (    "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 userfunc 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)
}
ReleasesInfoReleaseInfoer
package mainimport (    "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)
        }
    }
}
FakeReleaseInfoerReleaseInfoerGetLatestReleaseTagTestGetReleaseTagMessage
datagetReleaseTagMessage

Run:

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

推荐阅读