如何编写可测试的golang代码

iceiceiceice · · 1265 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

每次在开发之前,我都会考虑写好单元测试,但是随着开发的进行,就会发现事情没有这么简单,因为更多时候项目中间夹杂着很多的数据库操作,网络操作,文件操作等等,每次涉及到有这些操作的单元测试,都要花费很大的代价取初始化各种环境,拖到最后单元测试只能不了了之,因此这里的一个重点是写出来的代码本身不可测试,因此在这篇文章中,重点是如何写出可测试的代码,如何把一些无关的操作屏蔽掉,文章是我几个月之前翻译的,最近在项目中进行了实践,感觉不错,因此放到这里,希望能有更多的人看到。原文地址

在golang中通过接口和组合来实现高效的单元测试

go单元测试提供了:

  • Increased enforcement of behavior expectations beyond the compiler (providing critical assurance around rapidly changing code paths)
  • 快速的执行速度(许多流行的模块测试能在秒级完成)
  • 易于集成到CI环境(go test为内建)
  • 通过-race标志进行竞态检测

因此,单元测试是确保代码质量和防止回归的最佳方式之一。不幸的是,单元测试经常是很多go项目中最容易被忽视的方面之一。

这种情况有一部分是由于缺乏高质量的资源来解释如何正确构建一个可以被测试的go程序导致的。这份文档尝试提供这两方面的努力,提高go社区中可用程序的总体质量。

不要因为程序运行了就让你陷入错误的安全感:你不久就会庆幸你开始了测试。

预览

在文章中我会介绍下面的内容

  • 确保可测试的概念
  • 4个具体的例子来学习如何在go中进行有效的测试

最后你应该使用你学到的东西应用到实践中

概念
如果你从一开始就没有正确的构建和测试你的程序,那么测试这条路将会非常困难。这在编程界是一个相当普遍的格言在go测试中尤其正确

为了有效的测试go程序,有三个重要的概念:

  • 在你的go代码中使用接口
  • 通过组合构建更高层次的接口
  • 熟悉go test和testing模块

下面来详细解释一下

使用接口

你通过阅读go官方文档已经在工作中熟悉了go接口的使用。你可能不明白为什么接口如此重要,以及为什么你应该尽可能快的开始使用你自己的接口

对于那些不熟悉接口的,我建议去读一下go的官方文档 来理解接口是怎么工作的

长话短说

  • 接口是一组被定义的被考虑实现的方法类型的集合
  • 当任何给出的类型实现了该接口的所有方法时,go编译器就认为它实现了该接口

这在go的标准库中被用的很频繁。例如,在database/sql中使用相同的接口来编写与不同数据库进行交互的功能

新手go程序员可能已经能熟练的在其他编程语言中写单元测试像java,python或者php,通过使用stubs或者mocks技术来伪造方法调用的结果并且使用一种细粒度的方式来探索各种代码的路径。然而许多人并没有意识到,接口已经把上面的都实现了。

由于被嵌入到语言中,并被标准库所支持,接口为测试者提供了大量的功能和灵活性。可以在接口中封装给定测试之外的操作,并选择性的将其重新实现以用于相关测试。这允许作者控制测试中行为的每个方面。

使用组合
接口对增加灵活性和控制非常重要,但还不够。例如,考虑一下我们有一个struct将大量方法公开给外部消费者的情况,但是在其他的某些操作中也依赖于这些方法。我们不能将所有的对象封装在一个接口中,我们只需要实现我们需要测试的方法就够了。

因此,这变得至关重要,通过使用较小的接口来组成更大的接口,以便能够控制我们想要改变哪些方法和不想改变哪些方法去适应测试。在一个实际的例子中这样看起来更容易一点,因此我会避免更抽象的讨论直到文章的结束。

go test和testing模块

很明显,你至少应该浏览一下go test和testing模块的文档,熟悉一下每块能够让你更有效的进行单元测试。如果你不熟悉这些工具和库,用起来就会有些生疏(但是一旦熟悉了就好了)

那是很有吸引力的,第三方工具可以帮助测试,但是我强烈建议你避免这样做,直到你掌握了基础知识,并且确定依赖带给你的好处多于坏处。

首先你要有下面的一些基础知识

  • 对于任何给定的foo.go,测试被放置在相同的目录中并被命名为foo_test.go
  • go test . 运行当前目中的的单元测试。 go test ./... 将运行当前目录和该目录之下的测试, go test foo_test.go不工作因为被测试的文件不包括在内
  • -v标志对go test是有用的,它会打印出详细的输出(每个单独的测试结果)
  • Tests是个函数接受一个testing.T结构指针作为一个参数,并调用TestFoo,其中Foo是被测试的函数名称
  • 通常不会一直如你所期望的那样为真,相反,测试失败可以调用t.Fatal,如果你确定条件与你期望的不同
  • 在测试中打印输出可能不会如你所期望的那样工作。如果你在测试中需要打印信息可以使用t.Log或者t.Logf

例子

讨论的够多了,来写一些测试

下面的例子都可以在github上找到代码

例子1: Hello, Testing!
我假定你已经安装并且配置好了go开发环境

新建一个go的包在GOPATH下:

$ mkdir -p ~/go/src/github.com/nathanleclaire/testing-article
$ cd ~/go/src/github.com/nathanleclaire/testing-article

创建一个hello.go的文件

package main

import (
        "fmt"
)

func hello() string {
        return "Hello, Testing!"
}

func main() {
        fmt.Println(hello())
}

  

现在为hello.go写一个测试
新建一个hello_test.go的文件在相同的文件夹下

package main

import (
        "testing"
)

func TestHello(t *testing.T) {
        expectedStr := "Hello, Testing!"
        result := hello()
        if result != expectedStr {
                t.Fatalf("Expected %s, got %s", expectedStr, result)
        }
}

  

这个测试很简单。我们注入了一个*testing.T的实例到测试中,这被用来控制测试流和输出。我们把对函数调用的期望设置在一个变量中,然后在函数真正返回的时候检查它。

运行测试

$ go test -v
=== RUN TestHello
---PASS:TestHello(0.00s)
PASS
OK  github.com/nathanleclaire/testing-article 0.006s

例子2:用一个接口来模拟结果

作为程序的一部分,我们希望从GitHubAPI中获取一些数据。在这种情况下,假设我们想要查询一个给出的库的最新的tag

我们很容易写出下面的代码

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
)

type ReleasesInfo struct {
    Id      uint   `json:"id"`
    TagName string `json:"tag_name"`
}

// Function to actually query the GitHub API for the release information.
func 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(repo string) (string, error) {
    tag, err := 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() {
        msg, err := getReleaseTagMessage("docker/machine")
        if err != nil {
                fmt.Fprintln(os.Stderr, msg)
        }

        fmt.Println(msg)
}

  

事实上,这是一个自然而然想到的go程序结构

但这是不可测试的。如果我们要getLatestReleaseTag 直接测试这个函数,那么如果GitHub API关闭了,或者GitHub决定限制我们(如果在CI环境中频繁地运行测试,那很可能会影响我们)。另外,每当最新版本标签更改时,我们都必须更新测试。

该怎么办?我们可以重新定义这个实现的方式,使其更具可测性。如果我们查询Github API使用interface来代替直接调用函数 ,那么我们实际上可以控制通过测试返回的结果。

我们重新定义这个程序有一点就是让他有个接口,ReleaseInfoer其中一个实现可以是GithubReleaseInfoer。ReleaseInfoer只有一个方法,GetLatestReleaseTag它在性质上与我们上面的函数类似(它接受一个存储库名称作为参数并返回一个 string和/或error作为结果)。

该接口看着像下面这样

type ReleaseInfoer interface {
        GetLatestReleaseTag(string) (string, error)
}

  

然后我们更新上面的函数直接调用使用GithubReleaseInfoer结构代替

type GithubReleaseInfoer struct {}

// Function to actually query the GitHub API for the release information.
func (gh GithubReleaseInfoer) GetLatestReleaseTag(repo string) (string, error) {
        // ... same code as above
}

更新后,getReleaseTagMessage和main像下面这样

// 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 query 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, err)
                os.Exit(1)
        }

        fmt.Println(msg)
}

  

为什么要这么干?现在我们可以测试getReleaseTagMessage函数通过定义一个新的结构,只要实现了具有一个方法的ReleaseInfoer接口。这样,在测试的时候,我们就可以确保我们所依赖的方法的行为完全如我们期望的那样。

我们能定义一个FakeReleaseInfoer结构来表现我们想要的结构。我们只需要在结构中定义要返回的内容

package main

import "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) {
    f := FakeReleaseInfoer{
        Tag: "v0.1.0",
        Err: nil,
    }

    expectedMsg := "The latest release is v0.1.0"
    msg, err := getReleaseTagMessage(f, "dev/null")
    if err != nil {
        t.Fatalf("Expected err to be nil but it was %s", err)
    }

    if expectedMsg != msg {
        t.Fatalf("Expected %s but got %s", expectedMsg, msg)
    }
}

  

从上面可以看到,FakeReleaseInfoer被设置为返回Tag v0.1.0和Err 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)
                }
        }
}

  

注意reflect.DeepEqual的使用。这是来自标准库中的用来检查两个结构体是否相等的方法。这里用来检查错误是否相等,但也可以用来比较两个结构体的内容。仅仅使用 == 在这里并不能比较出相等,由于errors.New的使用(我尝试使用Error方法,但是对于nil值不工作,如果你有更好的方法可以告诉我)

这种技术在测试中可以获得更多对第三方库的控制。例如,Sam Alba的Golang Docker客户端会给你一个type DockerClient struct的交互,这对测试来说并不容易mock。但是你可以用type DockerClient interface在你自己的模块中创建一个模块,它指定你正在使用的方法dockerclient.DockerClient作为要实现的东西,在你的代码中使用它,然后创建你自己的接口版本来测试。

除了我在这里重点讨论的可测试性的好处外,使用接口可能会为您的程序的未来可扩展性带来巨大的利益。如果您已经构建了与GitHub API交互的每个组件,例如通过接口工作,则根本不需要更改程序的架构,以添加对其他源代码托管平台的支持。你可以简单地实现一个BitbucketReleaseInfoer 并使用它来包装Bitbucket API而不是GitHub。当然,这种类型的包装抽象将不适用于每个用例,但它可以用来强有力地模拟出外部和内部的依赖关系。

例子3 使用组合来测试一个更大的struct

上面的例子说明了一个可能非常有用的介绍性概念,但是有时候我们可能想要模拟一个struct相互依赖的部分,并分别测试每个部分。

如果你发现自己的一个interface或struct在一系列的要暴露方法中开始变大,那么分成几个更小的解耦并且相互组合可能是个好主意。例如,假设我们有个Job接口,它暴露了一个Log方法的内部和外部的结构。可以传递可变数量的参数传递给这个方法。它也提供Runing,Suspending和Resume方法

type Job interface {
    Log(...interface{})
    Suspend() error
    Resume() error
    Run() error
}

  

如果我们在工作中开发了一个struct并且实现了该接口,我们想使用内部的Log方法来记录日志。因此,像上面的例子那样实现整个接口是行不通的。那我们如何mock接口的部分来测试整个结构呢?

我们可以通过定义几个更小的接口然后使用组合。考虑一个Job,PollerJob的实现,用来做系统监控软件。我的第一版代码如下:

package main

import (
    "log"
    "net/http"
    "time"
)

type Job interface {
    Log(...interface{})
    Suspend() error
    Resume() error
    Run() error
}

type PollerJob struct {
    suspend     chan bool
    resume      chan bool
    resourceUrl string
    inMemLog    string
}

func NewPollerJob(resourceUrl string) PollerJob {
    return PollerJob{
        resourceUrl: resourceUrl,
        suspend:     make(chan bool),
        resume:      make(chan bool),
    }
}

func (p PollerJob) Log(args ...interface{}) {
    log.Println(args...)
}

func (p PollerJob) Suspend() error {
    p.suspend <- true
    return nil
}

func (p PollerJob) PollServer() error {
    resp, err := http.Get(p.resourceUrl)
    if err != nil {
        return err
    }

    p.Log(p.resourceUrl, "--", resp.Status)

    return nil
}

func (p PollerJob) Run() error {
    for {
        select {
        case <-p.suspend:
            <-p.resume
        default:
            if err := p.PollServer(); err != nil {
                p.Log("Error trying to get resource: ", err)
            }
            time.Sleep(1 * time.Second)
        }
    }
}

func (p PollerJob) Resume() error {
    p.resume <- true
    return nil
}

func main() {
    p := NewPollerJob("https://nathanleclaire.com")
    go p.Run()
    time.Sleep(5 * time.Second)

    p.Log("Suspending monitoring of server for 5 seconds...")
    p.Suspend()
    time.Sleep(5 * time.Second)

    p.Log("Resuming job...")
    p.Resume()

    // Wait for a bit before exiting
    time.Sleep(5 * time.Second)
}

  

上面程序的输出结构如下:

$ go run -race job.go
2015/10/11 20:37:59 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:01 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:02 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:03 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:04 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:04 Suspending monitoring of server for 5 seconds...
2015/10/11 20:38:10 Resuming job...
2015/10/11 20:38:10 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:11 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:12 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:14 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:15 https://nathanleclaire.com -- 200 OK
2015/10/11 20:38:16 https://nathanleclaire.com -- 200 OK

如果我们想测试各种复杂的互动,怎么办呢?所有的方法都放在一起,不使用外部资源测试程序的每个组件似乎是一件令人头疼的事情。

解决方案是将更高层的Job接口分解为几个其他接口,并将它们全部嵌入到PollerJob结构中,这样我们就可以在测试的时候将每个接口单独模拟出来。

我们能将Job接口拆分成几个不同的接口,如下所示:

type Logger interface {
    Log(...interface{})
}

type SuspendResumer interface {
    Suspend() error
    Resume() error
}

type Job interface {
    Logger
    SuspendResumer
    Run() error
}

  

您可以看到有一个SuspendResumer用于处理挂起/恢复功能的接口,并且一个Log仅用于管理Log方法的接口。另外,我们将创建一个PollServer接口来控制对我们正在轮询的服务器的状态调用:

type ServerPoller interface {
    PollServer() (string, error)
}

  

有了所有这些组件接口,我们就可以开始重新构建我们PollerJob的Job接口实现。通过嵌入Logger 和ServerPoller(两个接口)和一个指向PollSuspendResumer结构的指针,我们保证对于PollerJob作为一个Job的定义能通过编译。我们提供了一个NewPollerJob函数,它将提供一个结构的实例,并且正确地设置和初始化所有的组件。请注意,我们使用我们自己的组件实现了这个函数的返回。

type PollerLogger struct{}

type URLServerPoller struct {
    resourceUrl string
}

type PollSuspendResumer struct {
    SuspendCh chan bool
    ResumeCh  chan bool
}

type PollerJob struct {
    WaitDuration time.Duration
    ServerPoller
    Logger
    *PollSuspendResumer
}

func NewPollerJob(resourceUrl string, waitDuration time.Duration) PollerJob {
    return PollerJob{
        WaitDuration: waitDuration,
        Logger:       &PollerLogger{},
        ServerPoller: &URLServerPoller{
            resourceUrl: resourceUrl,
        },
        PollSuspendResumer: &PollSuspendResumer{
            SuspendCh: make(chan bool),
            ResumeCh:  make(chan bool),
        },
    }
}

  

其余的代码定义了相关的结构,并且可以在github上获得

这为我们提供了灵活性,当我们进行测试时,我们需要将PollerJob结构中的每个组件单独虚拟出来。每个组件可以在需要的地方重新使用和重复工作,更灵活,使我们能够从我们依赖的组件中获得更多的可能。

我们现在能单独测试Run,而不必与任何实际的服务器通信。我们只需要简单的控制ServerPoller的返回并且验证被写入的内容是否与我们预期的那样。因此测试文件看起来像下面这样。

package main

import (
    "errors"
    "fmt"
    "testing"
    "time"
)

type ReadableLogger interface {
    Logger
    Read() string
}

type MessageReader struct {
    Msg string
}

func (mr *MessageReader) Read() string {
    return mr.Msg
}

type LastEntryLogger struct {
    *MessageReader
}

func (lel *LastEntryLogger) Log(args ...interface{}) {
    lel.Msg = fmt.Sprint(args...)
}

type DiscardFirstWriteLogger struct {
    *MessageReader
    writtenBefore bool
}

func (dfwl *DiscardFirstWriteLogger) Log(args ...interface{}) {
    if dfwl.writtenBefore {
        dfwl.Msg = fmt.Sprint(args...)
    }
    dfwl.writtenBefore = true
}

type FakeServerPoller struct {
    result string
    err    error
}

func (fsp FakeServerPoller) PollServer() (string, error) {
    return fsp.result, fsp.err
}

func TestPollerJobRunLog(t *testing.T) {
    waitBeforeReading := 100 * time.Millisecond
    shortInterval := 20 * time.Millisecond
    longInterval := 200 * time.Millisecond

    testCases := []struct {
        p           PollerJob
        logger      ReadableLogger
        sp          ServerPoller
        expectedMsg string
    }{
        {
            p:           NewPollerJob("madeup.website", shortInterval),
            logger:      &LastEntryLogger{&MessageReader{}},
            sp:          FakeServerPoller{"200 OK", nil},
            expectedMsg: "200 OK",
        },
        {
            p:           NewPollerJob("down.website", shortInterval),
            logger:      &LastEntryLogger{&MessageReader{}},
            sp:          FakeServerPoller{"500 SERVER ERROR", nil},
            expectedMsg: "500 SERVER ERROR",
        },
        {
            p:           NewPollerJob("error.website", shortInterval),
            logger:      &LastEntryLogger{&MessageReader{}},
            sp:          FakeServerPoller{"", errors.New("DNS probe failed")},
            expectedMsg: "Error trying to get state: DNS probe failed",
        },
        {
            p: NewPollerJob("some.website", longInterval),

            // Discard first write since we want to verify that no
            // additional logs get made after the first one (time
            // out)
            logger: &DiscardFirstWriteLogger{MessageReader: &MessageReader{}},

            sp:          FakeServerPoller{"200 OK", nil},
            expectedMsg: "",
        },
    }

    for _, c := range testCases {
        c.p.Logger = c.logger
        c.p.ServerPoller = c.sp

        go c.p.Run()

        time.Sleep(waitBeforeReading)

        if c.logger.Read() != c.expectedMsg {
            t.Errorf("Expected message did not align with what was written:\n\texpected: %q\n\tactual: %q", c.expectedMsg, c.logger.Read())
        }
    }
}

  

请注意,创建我们自己的ReadableLogger接口进行测试并能够以各种方式实现Logger为我们提供了灵活性的帮助。Suspend而且Resume也同样能够被测试通过控制JobPoller组件的ServerPoller接口

func TestPollerJobSuspendResume(t *testing.T) {
    p := NewPollerJob("foobar.com", 20*time.Millisecond)
    waitBeforeReading := 100 * time.Millisecond
    expectedLogLine := "200 OK"
    normalServerPoller := &FakeServerPoller{expectedLogLine, nil}

    logger := &LastEntryLogger{&MessageReader{}}
    p.Logger = logger
    p.ServerPoller = normalServerPoller

    // First start the job / polling
    go p.Run()

    time.Sleep(waitBeforeReading)

    if logger.Read() != expectedLogLine {
        t.Errorf("Line read from logger does not match what was expected:\n\texpected: %q\n\tactual: %q", expectedLogLine, logger.Read())
    }

    // Then suspend the job
    if err := p.Suspend(); err != nil {
        t.Errorf("Expected suspend error to be nil but got %q", err)
    }

    // Fake the log line to detect if poller is still running
    newExpectedLogLine := "500 Internal Server Error"
    logger.MessageReader.Msg = newExpectedLogLine

    // Give it a second to poll if it's going to poll
    time.Sleep(waitBeforeReading)

    // If this log writes, we know we are polling the server when we're not
    // supposed to (job should be suspended).
    if logger.Read() != newExpectedLogLine {
        t.Errorf("Line read from logger does not match what was expected:\n\texpected: %q\n\tactual: %q", newExpectedLogLine, logger.Read())
    }

    if err := p.Resume(); err != nil {
        t.Errorf("Expected resume error to be nil but got %q", err)
    }

    // Give it a second to poll if it's going to poll
    time.Sleep(waitBeforeReading)

    if logger.Read() != expectedLogLine {
        t.Errorf("Line read from logger does not match what was expected:\n\texpected: %q\n\tactual: %q", expectedLogLine, logger.Read())
    }
}

  

测试一个小功能会有很多方法,但是随着代码量的增长,它要有很好的扩展。照这样mock可以使其更容易指定错误情况下的表现或者在复杂的并发情况下控制逻辑。

由于接口为测试提供了实用性和创造性,最好将外部依赖关系封装在一个中,然后将它们组合起来,以尽可能创建更高级的接口。正如你所希望的那样,即使小的单一方法的接口也可以被用来组合成更大的功能。

例子4:使用和构造标准库功能
如上图所示的概念是为自己的程序非常有用,但你也将注意到,许多在go标准库的构建可以在单元测试中以类似的方式进行管理.

我们来看看测试一个HTTP服务器的例子。在goroutine中实际启动HTTP服务器并向它发送你希望能够直接处理的请求(例如http.Get),但是这更像一个集成测试而不是一个合适的单元测试。下面看一个小型的http服务,并讨论如何进行测试。

package main

import (
    "fmt"
    "log"
    "net/http"
)

func mainHandler(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("X-Access-Token")
    if token == "magic" {
        fmt.Fprintf(w, "You have some magic in you\n")
        log.Println("Allowed an access attempt")
    } else {
        http.Error(w, "You don't have enough magic in you", http.StatusForbidden)
        log.Println("Denied an access attempt")
    }
}

func main() {
    http.HandleFunc("/", mainHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

  

上面的Http服务监听在8080端口,并且检查是否有一个X-Access-Token的header被设置。如果token匹配上了我们的"magic"值,我们允许用户访问并且返回一个HTTP 200 OK的状态码。否则我们拒绝请求,返回一个403.这是对一些API服务器如何处理授权的简单模仿,该如何测试它呢?

正如你所看到的。这个mainHandler函数接收两个参数,a http.ResponseWriter(注意它是一个interface你可以通过阅读源http源码或文档来验证)和一个http.Request结构体指针。为了测试这个handler,我们能构造http.ResponseWriter 接口的实现,今后也可以继续使用,幸运的是,Go作者已经提供了一个httptest包含ResponseRecorder 结构的包,以帮助解决这个问题。这样的模块提供了通用的测试功能用一个有用而常见的模式。

鉴于此,我们也能手工构造一个http.Request结构通过调用NewRequest带上我们期望的参数。我们只需要简单的调用Header.Set在Request上来设置header。我们在NewRequest方法中指定它应该是GET方法,并且不再请求体中包含任何信息,同样我们也可以测试POST请求

初始化的测试如下:

package main

import (
    "bytes"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestMainHandler(t *testing.T) {
    rootRequest, err := http.NewRequest("GET", "/", nil)
    if err != nil {
        t.Fatal("Root request error: %s", err)
    }

    cases := []struct {
        w                    *httptest.ResponseRecorder
        r                    *http.Request
        accessTokenHeader    string
        expectedResponseCode int
        expectedResponseBody []byte
    }{
        {
            w:                    httptest.NewRecorder(),
            r:                    rootRequest,
            accessTokenHeader:    "magic",
            expectedResponseCode: http.StatusOK,
            expectedResponseBody: []byte("You have some magic in you\n"),
        },
        {
            w:                    httptest.NewRecorder(),
            r:                    rootRequest,
            accessTokenHeader:    "",
            expectedResponseCode: http.StatusForbidden,
            expectedResponseBody: []byte("You don't have enough magic in you\n"),
        },
    }

    for _, c := range cases {
        c.r.Header.Set("X-Access-Token", c.accessTokenHeader)

        mainHandler(c.w, c.r)

        if c.expectedResponseCode != c.w.Code {
            t.Errorf("Status Code didn't match:\n\t%q\n\t%q", c.expectedResponseCode, c.w.Code)
        }

        if !bytes.Equal(c.expectedResponseBody, c.w.Body.Bytes()) {
            t.Errorf("Body didn't match:\n\t%q\n\t%q", string(c.expectedResponseBody), c.w.Body.String())
        }
    }
}

  

但是,我们可以考虑的测试功能有一个显而易见的缺失。我们不检查写入的log内容是我们所期望的。我们该怎么做?

如果我们检查标准库的log包的源代码,我们就可以看到这个log.Println方法直接封装了一个Logger 结构的实例,内部调用了Write方法在Writer接口上(在使用std结构的情况下,如果你直接引用 log.*,那么Writer就是os.Stdout).我想知道是否有任何方法可以将接口设置为我们期望的那样,以便可以验证所写的就是我们期望的。

当然,有一种方法可以这样做,我们能引用log.SetOutput方法来指定我们自定义的writer为了记录日志。我们使用io.Pipe来创建Writer。这将为我们提供一个Reader,我们能用它来读随后的writer调用在Logger中。我们用bufio.Reader封装了给出的PipeReader,因此我们可以调用bufio.Reader的ReadString方法一行一行的读。

注意PipeWriter的文档:

Write实现了标准的写接口,它写入数据到管道,阻塞直到readers读完所有的数据或者read端被关闭。

因此,我们必须并发的读从PipeReader中,在mainHandler函数正在写入时,我在自己的goroutine中运行这个测试。在我原来的版本中我得到这个错误,并通过使用go test的-timeout标志发现了这个错误,如果超时的话它会导致panic。

最后组合起来,像下面这样:

func TestMainHandler(t *testing.T) {
    rootRequest, err := http.NewRequest("GET", "/", nil)
    if err != nil {
        t.Fatal("Root request error: %s", err)
    }

    cases := []struct {
        w                    *httptest.ResponseRecorder
        r                    *http.Request
        accessTokenHeader    string
        expectedResponseCode int
        expectedResponseBody []byte
        expectedLogs         []string
    }{
        {
            w:                    httptest.NewRecorder(),
            r:                    rootRequest,
            accessTokenHeader:    "magic",
            expectedResponseCode: http.StatusOK,
            expectedResponseBody: []byte("You have some magic in you\n"),
            expectedLogs: []string{
                "Allowed an access attempt\n",
            },
        },
        {
            w:                    httptest.NewRecorder(),
            r:                    rootRequest,
            accessTokenHeader:    "",
            expectedResponseCode: http.StatusForbidden,
            expectedResponseBody: []byte("You don't have enough magic in you\n"),
            expectedLogs: []string{
                "Denied an access attempt\n",
            },
        },
    }

    for _, c := range cases {
        logReader, logWriter := io.Pipe()
        bufLogReader := bufio.NewReader(logReader)
        log.SetOutput(logWriter)

        c.r.Header.Set("X-Access-Token", c.accessTokenHeader)

        go func() {
            for _, expectedLine := range c.expectedLogs {
                msg, err := bufLogReader.ReadString('\n')
                if err != nil {
                    t.Errorf("Expected to be able to read from log but got error: %s", err)
                }
                if !strings.HasSuffix(msg, expectedLine) {
                    t.Errorf("Log line didn't match suffix:\n\t%q\n\t%q", expectedLine, msg)
                }
            }
        }()

        mainHandler(c.w, c.r)

        if c.expectedResponseCode != c.w.Code {
            t.Errorf("Status Code didn't match:\n\t%q\n\t%q", c.expectedResponseCode, c.w.Code)
        }

        if !bytes.Equal(c.expectedResponseBody, c.w.Body.Bytes()) {
            t.Errorf("Body didn't match:\n\t%q\n\t%q", string(c.expectedResponseBody), c.w.Body.String())
        }
    }
}

  

我希望这些例子清楚地说明了在Go标准库中以及在你自己的代码中具有良好架构的接口的价值,以及如何读取你所依赖的模块的源代码(包括Go标准库,它是很准确的文档)可以让你更好的理解你正在使用的代码,以及简化测试。

 

转载地址  :  https://github.com/AmateurEvents/article/issues/1


有疑问加站长微信联系(非本文作者)

本文来自:博客园

感谢作者:iceiceiceice

查看原文:如何编写可测试的golang代码

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1265 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传