个性化阅读
专注于IT技术分析

测试你的Go App:正确入门

本文概述

学习新知识时, 务必要有新的思想状态。

如果你刚开始使用Go, 并且使用的是JavaScript或Ruby之类的语言, 则可能已习惯使用现有的框架来帮助你模拟, 断言和执行其他测试向导。

现在, 消除依赖外部依赖项或框架的想法!测试是我几年前在学习这种非凡编程语言时偶然发现的第一个障碍, 当时可用的资源非常少。

我现在知道, 在Go中测试成功意味着可以轻而易举地依赖于依赖项(与Go一样), 最少地依赖外部库, 并编写良好的可重用代码。 Blake Mizerany在第三方测试库中的丰富经验是对调整心态的一个很好的开始。你将看到一些有关使用外部库和框架而不是”采用Go方式”的良好论据。

想学习围棋吗?查看我们的Golang入门教程。

构建自己的测试框架和模拟概念似乎违反直觉, 但比人们想象的要容易, 并且是学习该语言的良好起点。另外, 与我学习时不同, 你将获得这篇文章来指导你完成常见的测试方案, 并介绍一些我认为是最佳实践的技术, 以有效地测试并保持代码的清洁。

以”顺其自然”的方式做事, 消除对外部框架的依赖。

鸣叫

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__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)。

表测试的名称来自其结构, 可以轻松地由具有两列的表表示:输入变量和预期输出变量。

接口模拟

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在给出各种输入后会返回正确的输出。这可以通过表测试来完成。但是, 我们还应该涵盖另外两个非同寻常的方面, 它们正在检查以下内容:

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

为了知道传递给r.Read的缓冲区的大小以及控制它返回的错误, 我们需要模拟传递给readN的r。如果我们查看Reader类型的Go文档, 我们会看到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)
	}
}

正如你在上面看到的, 我们已经使用范围变量定义了” fake” io.Reader的Read函数的行为, 该变量以后可用于断言测试的有效性。很简单。

让我们看一下我们需要测试的第二种情况, 这需要我们模拟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
}

我们的收获不应该是’mock log.Println’, 但是在那些非常偶然的情况下, 由于某些合理的原因我们确实需要模拟程序包级别的功能时, 据我所知, 唯一的方法是通过将其声明为包级变量, 以便我们可以控制其值。

但是, 如果确实需要模拟log.Println之类的东西, 那么如果我们要使用自定义记录器, 则可以编写出更为优雅的解决方案。

进行模板渲染测试

另一个相当常见的情况是测试渲染的模板的输出是否符合预期。让我们考虑对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>

如果目前还不够明显, 则查询参数名称与分类为”名称”的范围的内容相匹配并非偶然。在这种情况下, 显而易见的测试将是验证每次跨多个输出正确执行此操作。我发现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是否封装了文本” 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软件包来创建测试服务器。测试会触发与我们的主服务器无关的独立服务器, 因此测试不会干扰生产。

在这种情况下, 最好创建通用的设置和拆卸功能, 以供需要运行服务器的所有测试调用。遵循这种新的, 更安全的模式, 我们的测试最终将看起来像这样:

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用户。它提供了绝佳的机会来确保你既交付了良好的代码又获得了高质量的体验。

每当你不确定代码中的更复杂功能时, 都可以放心地进行测试, 这可以放心, 并且还可以保证在修改大型系统的各个部分时, 这些部分可以继续正常工作。

希望本文对你有所帮助, 如果你知道其他任何测试技巧, 也欢迎你发表评论。

赞(0)
未经允许不得转载:srcmini » 测试你的Go App:正确入门

评论 抢沙发

评论前必须登录!