测试您的 Go 应用程序:以正确的方式开始
已发表: 2022-03-11在学习任何新事物时,保持新鲜的心态很重要。
如果您是 Go 的新手并且来自 JavaScript 或 Ruby 等语言,那么您可能习惯于使用现有的框架来帮助您模拟、断言和执行其他测试魔法。
现在消除依赖外部依赖或框架的想法! 几年前,当我学习这种非凡的编程语言时,测试是我偶然发现的第一个障碍,当时可用的资源要少得多。
我现在知道,在 Go 中测试成功意味着轻视依赖(就像所有 Go 一样),对外部库的依赖最少,并编写良好的可重用代码。 这篇关于 Blake Mizerany 与第三方测试库冒险的经历的介绍是调整你的心态的一个很好的开始。 你会看到一些关于使用外部库和框架与使用“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 的更多详细信息,请访问文档页面。
现在我们来谈谈编写示例的形式。 一个测试套件(一系列测试)正在通过函数Avg()
运行,每个测试都包含一个特定的输入和预期的输出。 在我们的例子中,每个测试都发送一个整数切片( 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
在给定各种输入时返回正确的输出。 这可以通过表格测试来完成。 但是我们还应该涵盖另外两个重要的方面,即检查:
-
r.Read
使用大小为 n 的缓冲区调用。 - 如果抛出一个错误,
r.Read
返回一个错误。
为了知道传递给r.Read
的缓冲区的大小,以及控制它返回的错误,我们需要模拟传递给readN
的r
。 如果我们查看有关 Reader 类型的 Go 文档,我们会看到io.Reader
样子:
type Reader interface { Read(p []byte) (n int, err error) }
这似乎相当容易。 为了满足io.Reader
我们所要做的就是让我们的 mock 拥有一个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
函数定义了一个范围变量的行为,以后可以使用它来断言我们测试的有效性。 很容易。
再来看看我们需要测试的第二种场景,它需要我们mock 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
的正确功能也会这样做。
Go 中的函数模拟
我们并不经常需要模拟一个函数,因为我们倾向于使用结构和接口。 这些更容易控制,但有时我们会遇到这种必要性,我经常看到围绕该主题的混淆。 有些人甚至问如何模拟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
类的东西,如果我们要使用自定义记录器,可以编写一个更优雅的解决方案。
Go 模板渲染测试
另一个相当常见的场景是测试渲染模板的输出是否符合预期。 让我们考虑一个对http://localhost:3999/welcome?name=Frank
的 GET 请求,它返回以下正文:
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
如果现在还不够明显,那么查询参数name
与分类为“名称”的span
的内容相匹配并不是巧合。 在这种情况下,显而易见的测试将是验证这在每次跨多个输出时都正确发生。 我发现 GoQuery 库在这里非常有用。
现在我们可以用这种方式编写我们的测试:
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
是否封装了文本“Frank”。
测试 JSON API
Go 经常用于编写某种 API,所以最后但并非最不重要的一点是,让我们研究一些测试 JSON API 的高级方法。
考虑端点之前是否返回了 JSON 而不是 HTML,所以从http://localhost:3999/welcome.json?name=Frank
我们期望响应体看起来像:
{"Salutation": "Hello Frank!"}
正如人们可能已经猜到的那样,断言 JSON 响应与断言模板响应没有太大区别,只是我们不需要任何外部库或依赖项。 Go 的标准库就足够了。 这是我们的测试,确认为给定参数返回了正确的 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!”。
安装和拆卸
使用 Go 进行测试很容易,但是上面的 JSON 测试和之前的模板渲染测试都存在一个问题。 他们都假设服务器正在运行,这会产生不可靠的依赖关系。 此外,反对“实时”服务器也不是一个好主意。
幸运的是,Go 提供了 httptest 包来创建测试服务器。 测试会启动他们自己的独立服务器,独立于我们的主服务器,因此测试不会干扰生产。
在这些情况下,创建通用setup
和teardown
函数以供所有需要运行服务器的测试调用是理想的。 遵循这种新的、更安全的模式,我们的测试最终会看起来像这样:
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,它可以实例化您的生产服务器或测试服务器。
结论
在 Go 中进行测试是一个很好的机会,可以假设您的程序的外部视角,并以您的访问者的立场,或者在大多数情况下,您的 API 的用户。 它提供了一个很好的机会来确保您提供良好的代码和高质量的体验。
每当您不确定代码中更复杂的功能时,测试会派上用场,作为一种保证,并且还可以保证在修改较大系统的部分时,这些部分将继续很好地协同工作。
希望这篇文章对你有用,如果你知道任何其他测试技巧,欢迎发表评论。