作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
加布里埃尔·阿萨洛斯的头像

Gabriel Aszalos

Gabriel是一名高级开发人员 & 热爱围棋,有在全球不同环境和多元文化团队工作的经验.

Expertise

Previously At

Vodafone
Share

学习新东西的时候,保持一种新鲜的心态是很重要的.

如果您是Go的新手,并且来自JavaScript或Ruby等语言, 您可能习惯于使用现有的框架来帮助您进行模拟, assert, 并做其他测试魔法.

现在根除依赖外部依赖项或框架的想法! 测试是我几年前学习这门了不起的编程语言时遇到的第一个障碍, 那时可用的资源少得多.

我现在知道,在Go中测试成功意味着对依赖关系的依赖程度较低(就像Go中的所有东西一样)。, 尽量减少对外部库的依赖, 编写良好的可重用代码. 布莱克·米泽拉尼的经历 尝试使用第三方测试库是调整心态的一个很好的开始. 你会看到一些关于使用外部库和框架与Go方式的争论.

构建自己的测试框架和模拟概念似乎是违反直觉的, 但这比人们想象的要容易, 也是学习语言的一个很好的起点. Plus, 不像我学习的时候, 本文将指导您完成常见的测试场景,并介绍我认为有效测试和保持代码整洁的最佳实践技术.

事情是否按照“Go Way”,消除对外部框架的依赖.

Table Testing in Go

以“单元测试”而闻名的基本测试单元可以是程序中最简单形式的任何组件,它接受输入并返回输出. 让我们看一个我们想为其编写测试的简单函数. 它远非完美或完整,但它足以用于演示目的:

avg.go

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

The function above, func Avg(nos ...int),返回0或给定的一系列数字的整数平均值. 现在让我们为它编写一个测试.

In Go, 使用与包含被测试代码的文件相同的名称来命名测试文件被认为是最佳实践, with the added suffix _test. 例如,上面的代码位于一个名为 avg.go,因此我们的测试文件将被命名为 avg_test.go.

请注意,这些示例只是实际文件的摘录, 为简单起见,省略了包定义和导入.

Here’s a test for the Avg function:

avg__test.go

函数TestAvg(t *test).T) {
	对于_,tt:= range []struct {
		Nos    []int
		Result int
	}{
		{no: []int{2,4}, Result: 3},
		{no: []int{1,2,5}, Result: 2},
		{no: []int{1}, Result: 1},
		{no: []int{}, Result: 0},
		{no: []int{2, -2}, Result: 0},
	} {
		if avg := Average(tt.Nos...); avg != tt.Result {
			t.“%v的预期平均值是%d,得到%d\n”,tt.Nos, tt.Result, avg)
		}
	}
}

关于函数定义有几点需要注意:

  • 首先,测试函数名上的“Test”前缀. 这是必要的,这样工具将把它作为有效的测试.
  • 函数名的后一部分通常是要测试的函数或方法的名称, in this case Avg.
  • 我们还需要传入名为 testing.T,它允许对测试流进行控制. 有关此API的详细信息,请访问 documentation page.

现在我们来讨论一下这个例子的写作形式. 一个测试套件(一系列测试)正在通过该功能运行 Avg(),每个测试都包含一个特定的输入和预期的输出. 在我们的例子中,每个测试发送一个整数切片(Nos),并期望一个特定的返回值(Result).

表测试因其结构而得名, 很容易用一个包含两列的表来表示:输入变量和预期输出变量.

Golang接口模拟

Go语言提供的最强大的功能之一就是接口. 除了我们在构建程序时从接口中获得的功能和灵活性之外, 接口也为我们提供了惊人的机会来解耦我们的组件,并在它们的交汇点彻底测试它们.

接口是一个命名的方法集合,也是一个变量类型.

让我们假设一个场景,我们需要从一个io中读取前N个字节.Reader并将其作为字符串返回. 它看起来是这样的:

readn.go

// readN最多从r中读取n个字节,并将其作为字符串返回.
func readN(r io.读取器,n int)(字符串,错误){
	buf := make([]byte, n)
	m, err := r.Read(buf)
	if err != nil {
		return "", err
	}
	返回字符串(buf[:m]), nil
}

显然,要测试的主要内容是函数 readN,当给定各种输入时,返回正确的输出. 这可以通过表测试来完成. 但还有两个重要的方面,我们应该涵盖,这是检查:

  • r.Read 用大小为n的缓冲区调用.
  • r.Read 如果抛出错误,则返回错误.

以便知道传递给的缓冲区的大小 r.Read,以及控制它返回的错误,我们需要模拟 r being passed to readN. If we look at the 关于Reader类型的Go文档, we see what io.Reader looks like:

读取器接口
	   Read(p []byte) (n int, err error)
}

That seems rather easy. 我们所要做的一切都是为了满足 io.Reader is have our mock own a Read method. So our ReaderMock can be as follows:

类型ReaderMock结构{
	ReadMock函数c([]byte) (int, error)
}

函数c (m ReaderMock)读取(p []byte) (int, error) {
	return m.ReadMock(p)
}

让我们稍微分析一下上面的代码. Any instance of ReaderMock clearly satisfies the io.Reader 接口,因为它实现了必要的 Read method. 我们的模拟也包含该字段 ReadMock, 允许我们设置模拟方法的确切行为, 这使得动态实例化我们所需要的东西变得超级容易.

确保在运行时满足接口的一个伟大的无内存技巧是在代码中插入以下代码:

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

这将检查断言,但不会分配任何内容, 这让我们确保接口在编译时被正确实现, before 该程序实际上运行到使用它的任何功能中. 一个可选的技巧,但很有用.

继续,让我们编写第一个测试,其中 r.Read 使用大小为 n. To do this, we use our ReaderMock as follows:

函数TestReadN_bufSize(t *test . size.T) {
	total := 0
	mr := &MockReader{func(b []byte) (int, error) {
		total = len(b)
		return 0, nil
	}}
	readN(mr, 5)
	if total != 5 {
		t.fatf(“预期5,得到%d”,总数)
	}
}

正如您在上面看到的,我们已经定义了 Read function of our “fake” io.Reader 使用作用域变量,稍后可以使用它来断言测试的有效性. Easy enough.

让我们看看需要测试的第二个场景,它需要我们进行模拟 Read to return an error:

函数TestReadN_error(t *testing.T) {
	expect := errors.New("一些非nil错误")
	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 will do the same.

用Go模拟函数

我们并不经常需要模拟函数, 因为我们倾向于使用结构和接口. 这些更容易控制, 但偶尔我们也会碰到这种需要, 我经常看到围绕这个话题的困惑. 有些人甚至问如何嘲笑这样的事情 log.Println. 尽管我们很少需要测试给定的输入 log.Println,我们将利用这个机会来证明.

Consider this simple if 的值记录输出的语句 n:

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 are replaced with show,所以我们的程序变成:

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

Now we can test:

函数TestPrintSize(t *test.T) {
	var got string
	oldShow := show
	show = func(v ...interface{}) {
		if len(v) != 1 {
			t.fatf("预期show被调用时带有1个参数,得到%d", len(v))
		}
		var ok bool
		got, ok = v[0].(string)
		if !ok {
			t.Fatal("预计show将使用字符串调用")
		}
	}

	对于_,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."在%d上,期望'%s',收到'%s'\n", tt.N, tt.Out, got)
		}
	}

	//注意,我们不能忘记恢复它的原始值
	//在完成测试之前,否则它可能会干扰我们的
	//套件,给我们意想不到的和难以追踪的行为.
	show = oldShow
}

我们的外卖不应该是“嘲弄”的 log.Println’, 但是在那些非常偶然的场景中,当我们出于合理的原因需要模拟包级函数时, 这样做的唯一方法(据我所知)是将其声明为包级变量,以便我们可以控制其值.

然而,如果我们真的需要嘲笑一些事情,比如 log.Println,如果我们使用自定义记录器,可以编写一个更优雅的解决方案.

Go模板渲染测试

另一个相当常见的场景是测试呈现的模板的输出是否符合预期. 让我们考虑一个GET请求来 http://localhost:3999/welcome?name=Frank,它返回如下主体:


	Welcome page
	
		

Welcome Frank!

如果到目前为止还不够明显,那么查询参数 name 的内容匹配 span classed as “name”. In this case, 显而易见的测试是验证每次跨多个输出都正确地发生这种情况. I found the GoQuery library 希望能帮上大忙.

GoQuery使用类似jquery的API来查询HTML结构, 哪一个对于测试程序的标记输出的有效性是必不可少的.

现在我们可以这样编写测试:

welcome__test.go

TestWelcome_name(测试名.T) {
	resp, err := http.Get (" http://localhost: 3999 /受欢迎的?name=Frank")
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		t.“预期200,得到%d”,代表.StatusCode)
	}
	doc, err := goquery.NewDocumentFromResponse(职责)
	if err != nil {
		t.Fatal(err)
	}
	if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" {
		t.fatf("预期加价包含'Frank',收到'%s'", v)
	}
}

首先,在继续之前,我们检查响应代码是否为200/OK.

我认为假设上面代码片段的其余部分是自解释的并不牵强:我们使用 http 打包并从响应中创建一个新的与goquery兼容的文档, 然后我们用它来查询返回的DOM. We check that the span.name inside h1.header-name 概括了“弗兰克”这个词.

Testing JSON APIs

Go经常用于编写某种类型的api, so last but not least, 让我们看看测试JSON api的一些高级方法.

考虑端点之前是否返回JSON而不是HTML,因此 http://localhost:3999/welcome.json?name=Frank 我们希望响应体看起来像这样:

{“问候”:“你好,弗兰克!"}

断言JSON响应, 大家可能已经猜到了, 与断言模板响应没有太大区别, 除了我们不需要任何外部库或依赖之外. Go的标准库就足够了. 下面是我们的测试,确认为给定参数返回正确的JSON:

welcome__test.go

TestWelcome_name_JSON(TestWelcome_name_JSON.T) {
	resp, err := http.Get (" http://localhost: 3999 /受欢迎的.json?name=Frank")
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != 200 {
		t.“预期200,得到%d”,代表.StatusCode)
	}
	变量dst struct{问候字符串}
	if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil {
		t.Fatal(err)
	}
	if dst.Salutation != "Hello Frank!" {
		t.“我期待着‘你好,弗兰克!', got '%s'", dst.Salutation)
	}
}

如果返回的不是我们解码的结构, json.NewDecoder 将返回一个错误,测试将失败. 考虑到响应对结构的解码成功, 我们检查字段的内容是否符合预期——在我们的示例中是“Hello Frank”!”.

Setup & Teardown

用Go进行测试很容易, 但是上面的JSON测试和之前的模板渲染测试都有一个问题. 它们都假定服务器正在运行,这就造成了不可靠的依赖关系. 此外,反对“实时”服务器也不是一个好主意.

It’s never a good idea to test against “live” data on a “live” production server; spin up local or development copies so there’s no damage done with things go horribly wrong.

Luckily, Go offers the httptest 包来创建测试服务器. 测试会启动它们自己的独立服务器, 独立于我们的主系统, 所以测试不会影响生产.

在这些情况下,理想的做法是创建泛型 setup and teardown 需要运行服务器的所有测试调用的函数. 按照这种新的、更安全的模式,我们的测试最终看起来像这样:

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

函数teardown(s *httptest .;.Server) {
	s.Close()
}

TestWelcome_name(测试名.T) {
	srv := setup()

	url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL)
	resp, err := http.Get(url)
	// verify errors & 像往常一样运行断言

	teardown(srv)
}

Note the app.Handler() reference. 这是一个最佳实践函数,它返回应用程序的 http.Handler,它可以实例化您的生产服务器或测试服务器.

Conclusion

Testing in Go 这是一个很好的机会,假设你的程序的外部视角,并采取你的参观者的立场, or in most cases, the users of your API. 它提供了一个很好的机会来确保您既交付了好的代码,又提供了高质量的体验.

当您不确定代码中更复杂的功能时, 测试作为一种保证会派上用场, 同时也保证了在修改大型系统的部件时,这些部件将继续很好地协同工作.

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

关于总博客的进一步阅读:

就这一主题咨询作者或专家.
Schedule a call
加布里埃尔·阿萨洛斯的头像
Gabriel Aszalos

Located in 乌尔姆,德国巴登-符腾堡州

Member since April 28, 2016

About the author

Gabriel是一名高级开发人员 & 热爱围棋,有在全球不同环境和多元文化团队工作的经验.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

Previously At

Vodafone

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.