博客专区 > 暗夜在火星的博客 > 暗夜在火星的博客详情
[译]从正确的方式开始测试你的Go应用
暗夜在火星 发表于3个月前
[译]从正确的方式开始测试你的Go应用
  • 发表于 3个月前
  • 阅读 18
  • 收藏 0
  • 点赞 1
  • 评论 0

聚焦虚拟化和OpenStack、容器、大数据等开源技术的年度大趴!>>> »  

从正确的方式开始测试你的Go应用

/**
 * 谨献给Yoyo
 *
 * 原文出处:https://www.toptal.com/go/your-introductory-course-to-testing-with-go
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-08-11
 */

在学习任何新的东西时,具备清醒的头脑是很重要的。

如果你对Go相当陌生,并来自诸如JavaScript或Ruby这样的语言,你很可能习惯于使用现成的框架来帮助你模拟、断言以及做一些其他测试的 您嘲笑,断言,和做其他测试巫术。

现在,消除基于于外部依赖或框架的想法!几年前在学习这门出众的编程语言时,测试是我遇到的第一个障碍,那时只有相当少的一些资源可用。

现在我知道了,在GO中测试成功,意味着对依赖轻装上阵(如同和GO所有事情那样),最少依赖于外部类库,以及编写更好、可重用的代码。此Blake Mizerany的经验介绍敢于向第三方测试库尝试,是一个调整你思想很好的开始。你将看到一些关于使用外部类库以及“Go的方式”的框架的争论。

想学Go吗?来看看我们Golang的入门教程吧。

构建自己的测试框架和模拟的概念似乎违反直觉,但也可以很容易就能想到,对于学习这门语言,这是一个良好的起点。另外,不像我当时学习那样,在贯穿常见测试脚本以及介绍我认为是有效测试和保持代码整洁最佳实践的过程中,你都有这篇文章作为引导。

以“Go的方式”来做事,消除对外部框架的依赖。

Go中的表格测试

基本的测试单元 - “单元测试”的名声 - 可以是一个程序的任何部分,它以最简单的形式,只需要一个输入并返回一个输出。让我们来看一个将要为其编写测试的简单函数。显然,它远不是完美和完成的,但出于演示目的是足够好的了:
avg.go

func Avg(nos ...int) int {  
    sum := 0
    for _, n := range nos {
        sum += n
    }
    if sum == 0 {
        return 0
    }
    return sum / len(nos)
}

上面函数,func Avg(nos ...int),返回零或给它一系列数字的整数平均值。现在让我们来给它写一个测试吧。

在Go中,给测试文件命名和包含待测试代码的文件相同名称,并带上附加的后缀_test被当为最佳实践。例如,上面代码是一个名为avg.go的文件中,所以我们的测试文件将被命名为avg_test.go

注意,这些示例只是实际文件的摘录,因为包定义和导入出于简化已删去。

这是针对Avg函数的测试:

avg_test.go

func TestAvg(t *testing.T) {  
    for _, tt := range []struct {
        Nos    []int
        Result int
    }{
        {Nos: []int{2, 4}, Result: 3},
        {Nos: []int{1, 2, 5}, Result: 2},
        {Nos: []int{1}, Result: 1},
        {Nos: []int{}, Result: 0},
        {Nos: []int{2, -2}, Result: 0},
    } {
        if avg := Average(tt.Nos...); avg != tt.Result {
            t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg)
        }
    }
}

关于函数定义,有几件事情需要注意的:

  • 首先,在测试函数名称的“Test”前缀。这是必需的,以便工具把它作为一种有效的测试检测出来。
  • 测试函数名称的后半部分通常是待测试函数或者方法的名称,在这里是Avg
  • 我们还需要传入称为testing.T的测试结构,其允许控制测试流。有关此API的更多详细信息,请访问此文档

现在,让我们来聊聊这个例子编写的格式。一个测试套件(一系列测试)正通过Agv()函数运行,并且每个测试含一个特定的输入和预期的输出。在我们的例子中,每次测试传入一系列整数(Nos)和所期望的一个特定的返回值(Result)。

表格测试从它的结构得名,很容易被表示成一个有两列的表格:输入变量和预期的输出变量。

Golang接口模拟

Go语言所提供的最伟大和最强大的功能称为接口。除了在进行程序架构设计时获得接口的强大功能和灵活性外,接口也为我们提供了令人惊讶的机会来解耦组件以及在交汇点全面测试他们。

接口是指定方法的集合,也是一个变量类型。

让我们看一个虚构的场景,假设需要从io.Reader读取前N个字节,并把它们作为一个字符串返回。它看起来像是这样:
readn.go

// readN reads at most n bytes from r and returns them as a string.
func readN(r io.Reader, n int) (string, error) {  
    buf := make([]byte, n)
    m, err := r.Read(buf)
    if err != nil {
        return "", err
    }
    return string(buf[:m]), nil
}


显然,主要要测试的是readN这个功能,当给定各种输入时,返回正确的输出。这可以用表格测试来完成。但另外也有两个特殊的场景该覆盖到,那就是要检查:

  • readN被一个大小为n的缓冲调用
  • readN返回一个错误如果抛出异常

为了知道传递给r.Read的缓冲区的大小,以及控制它返回的错误,我们需要模拟传递给readNr。如果看一下Go文档中的Reader类型,我们看到io.Reader看起来像:

type Reader interface {  
       Read(p []byte) (n int, err error)
}

这似乎相当容易。为了满足io.Reader我们所需要做是有自已模拟一个Read方法。所以,ReaderMock可以是这样:

type ReaderMock struct {  
    ReadMock func([]byte) (int, error)
}

func (m ReaderMock) Read(p []byte) (int, error) {  
    return m.ReadMock(p)
}

我们稍微来分析一下上面的代码。任何ReaderMock的实例明显地满足了io.Reader接口,因为它实现了必要的Read方法。我们的模拟还包含字段ReadMock,使我们能够设置模拟方法确切的行为,这使得动态实例任何需要的行为非常容易。

为确保接口在运行时能满足需要,一个伟大的不消耗内存的技巧是,把以下代码插入到我们的代码中:

var _ io.Reader = (*MockReader)(nil)  

这样会检查断言,但不会分配任何东西,这让我们确保该接口在编译时已被正确实现,即在该程序真正使用它运行到任何功能之前。可选的技巧,但很实用。

继续往前,让我们来写第一个测试:r.Read被大小为n的缓冲区调用。为了做到这点,我们傅用了ReaderMock,如下:

func TestReadN_bufSize(t *testing.T) {  
    total := 0
    mr := &MockReader{func(b []byte) (int, error) {
        total = len(b)
        return 0, nil
    }}
    readN(mr, 5)
    if total != 5 {
        t.Fatalf("expected 5, got %d", total)
    }
}

正如你在上面看到的,我们通过一个局部变量定义了“假的”io.Reader功能,这可用于后面断言我们的测试的有效性。相当容易。

再来看下需要测试的第二个场景,这要求我们模拟Read以返回一个错误:

func TestReadN_error(t *testing.T) {  
    expect := errors.New("some non-nil error")
    mr := &MockReader{func(b []byte) (int, error) {
        return 0, expect
    }}
    _, err := readN(mr, 5)
    if err != expect {
        t.Fatal("expected error")
    }
}

在上面的测试,不管什么调用了mr.Read(我们模拟的Reader)都将返回既定义的错误,因此假设readN的正常运行也会这样做是可靠的。

Golang方法模拟

通常我们不需要模拟方法,因为取而代之,我们倾向于使用结构和接口。这些更容易控制,但偶尔会碰到这种必要性,我经常看到围绕这块话题的困惑。甚至有人问怎么模拟类似log.Println这样的东西。虽然很少需要测试给log.Println的输入的情况,我们将利用这次机会来证明。

考虑以下简单的if语句,根据n的值输出记录:

func printSize(n int) {  
    if n < 10 {
        log.Println("SMALL")
    } else {
        log.Println("LARGE")
    }
}

在上面的例子中,我们假设这样一个可笑的场景:特定测试log.Println被正确的值调用。为了模拟这个功能,首先需要把它包装起来:

var show = func(v ...interface{}) {  
    log.Println(v...)
}

以这种方式声方法 - 作为一个变量 - 允许我们在测试中覆盖它,并为其分配任何我们所希望的行为。间接地,把log.Println的代码行替换成show,那么我们的程序将变成:

func printSize(n int) {  
    if n < 10 {
        show("SMALL")
    } else {
        show("LARGE")
    }
}

现在我们可以测试了:

func TestPrintSize(t *testing.T) {  
    var got string
    oldShow := show
    show = func(v ...interface{}) {
        if len(v) != 1 {
            t.Fatalf("expected show to be called with 1 param, got %d", len(v))
        }
        var ok bool
        got, ok = v[0].(string)
        if !ok {
            t.Fatal("expected show to be called with a string")
        }
    }

    for _, tt := range []struct{
        N int
        Out string
    }{
        {2, "SMALL"},
        {3, "SMALL"},
        {9, "SMALL"},
        {10, "LARGE"},
        {11, "LARGE"},
        {100, "LARGE"},
    } {
        got = ""
        printSize(tt.N)
        if got != tt.Out {
            t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got)
        }
    }

    // careful though, we must not forget to restore it to its original value
    // before finishing the test, or it might interfere with other tests in our
    // suite, giving us unexpected and hard to trace behavior.
    show = oldShow
}

我们不应该“模拟log.Println”,但在那些非常偶然的情况下,当我们出于正当理由真的需要模拟一个包级的方法时,为了做到这一点唯一的方法(据我所知)是把它声明为一个包级的变量,这样我们就可以控制它的值。

然而,如果我们确实需要模拟像log.Println这样的东西,假如使用了自定义的记录器,我们可以编写一个更优雅的解决方案。

Golang模板渲染测试

另一个相当常见的情况是,根据预期测试某个渲染模板的输出。让我们考虑一个对http://localhost:3999/welcome?name=Frank的GET请求,它会返回以下body:

<html>  
    <head><title>Welcome page</title></head>
    <body>
        <h1 class="header-name">
            Welcome <span class="name">Frank</span>!
        </h1>
    </body>
</html>  

如果现在它明显不够,查询参数name与类为namespan标签相匹配,这不是一个巧合。在这种情况下,明显的测试应该验证每次跨越多层输出时这种情况都正确发生。在这里我发现GoQuery类库非常有用。

GoQuery使用类似jQuery的API查询HTML结构,是用于测试程序标签输出的有效性是必不可少的。

现在用这种方式我们可以编写我们的测试了:
welcome__test.go

func TestWelcome_name(t *testing.T) {  
    resp, err := http.Get("http://localhost:3999/welcome?name=Frank")
    if err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != http.StatusOK {
        t.Fatalf("expected 200, got %d", resp.StatusCode)
    }
    doc, err := goquery.NewDocumentFromResponse(resp)
    if err != nil {
        t.Fatal(err)
    }
    if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" {
        t.Fatalf("expected markup to contain 'Frank', got '%s'", v)
    }
}

首先,在处理前我们检查响应状态码是不是200/OK。

我认为,假设上面的代码段的其余部分是不言自明不会太牵强:我们使用http包来提取URL并根据响应创建一个新的goquery兼容文档,随后我们会用它来查询返回的DOM。我们检查了在h1.header-name里面span.name封装文本'弗兰克'。

测试JSON接口

Golang经常用来写某种API,所以最后但并非最不重要的,让我们来看一些测试JSON API高级的方式。

试想,如果前面的终端返回的是JSON而不是HTML,那么从http://localhost:3999/welcome.json?name=Frank我们将期待响应的body看起来像这样:

{"Salutation": "Hello Frank!"}

断言JSON响应,正如你能想到那样,相比于断言模板响应来说并不会有太大的不同,有一点特殊就是我们不需要任何外部库或依赖。 Golang的标准库就足够了。下面是我们的测试,以确认对于给定的参数返回正确的JSON:
welcome__test.go

func TestWelcome_name_JSON(t *testing.T) {  
    resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank")
    if err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != 200 {
        t.Fatalf("expected 200, got %d", resp.StatusCode)
    }
    var dst struct{ Salutation string }
    if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil {
        t.Fatal(err)
    }
    if dst.Salutation != "Hello Frank!" {
        t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation)
    }
}

如果返回了解码以外的任何结构,json.NewDecoder反而会返回一个错误,测试将会失败。考虑到成功对响应结构解码后,我们检查字段的内容是否达到预期 - 在我们的情况下是“Hello Frank!”。

Setup 和 Teardown

Golang的测试是容易的,但有一个问题,在这之前的JSON测试和模板渲染测试。他们都假设服务器正在运行,而这创建了一个不可靠的依赖。并且,需要一个“活”的服务器不是一个很好的主意。

测试在一个“活”的生产服务器上的“实时”数据从来都不是一个好主意;从本地自旋而上或开发副本,所以没有事情做了后会有严重的损害。

幸运的是,Golang提供了httptest包来创建测试服务器。测试引发了自己独立的服务器,独立于我们主要的服务器,因此测试不会干扰生产环境。

在这种情况下,创建通用的setupteardown方法以便被全部需要运行服务器的测试调用是理想的。根据这一新的、更安全的模式,我们的测试最终看起来像这样:

func setup() *httptest.Server {  
    return httptest.NewServer(app.Handler())
}

func teardown(s *httptest.Server) {  
    s.Close()
}

func TestWelcome_name(t *testing.T) {  
    srv := setup()

    url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL)
    resp, err := http.Get(url)
    // verify errors & run assertions as usual

    teardown(srv)
}

注意app.Handler()的引用。这是一个最佳实践的函数,它返回了应用程序的http Handler,它既可以实例化生产服务器也可以实例化测试服务器。

结论

Golang的测试是一个很好的机会,它假定了你程序的外部视野和承担访问者的脚步,或是在大多数情况下,即你的API的用户。它提供了巨大的机会,以确保你提供了良好的代码和优质的体验。

不管何时当你不确定代码中更为复杂的功能时,测试作为一颗定心丸就会派上用场,同时也保证了当修改较大系统的组成部分时其他部分仍能继续一起很好工作。

希望这篇文章能对你有用,如果您知道任何其他测试技巧也欢迎来发表评论。


------------------------

共有 人打赏支持
暗夜在火星
粉丝 113
博文 98
码字总数 199380
作品 1
×
暗夜在火星
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: