
单元测试是现代软件测试的一个关键部分,然而,由于各种原因,我们往往忽略了端到端的测试。通过一个案例研究,我将向你展示这些测试是如何被添加到一个开源项目中的,这样你就可以为自己的工作调整方法和技术。
测试金字塔
在"Test Driven Development by Example"中,Kent Beck定义了 "单元测试 "的概念:
"单元测试在与程序的其他部分隔离的情况下测试单个单元(模块、函数、类)"。
我把大部分时间花在写单元测试上,因为它们的隔离性质意味着它们相对稳定,运行迅速,不需要经常改变。
现在,还有其他类型的测试也是有用的,但它们有自己的权衡。它们也有咨询比例,我们可以通过使用 "测试金字塔 "将其形象化。

我对 "测试金字塔 "的看法
单元测试,在图的底部,易于维护,编写速度快,只验证一件事,他们测试的是孤立的东西。
随着我们在图中的进展,测试涉及到我们系统中越来越多的组件,甚至延伸到外部服务器和资源,最后招募一个网络浏览器和自动化工具,如Selenium。
单元测试的危险性在于缺乏背景。如果不把组件放在一起测试,我们就纯粹依靠对交互的假设。
上图:一个通过了单元测试的雨伞的备忘录,但在集成测试中失败了。
在我工作过的一些公司里,UI测试可能是手工进行的,这使得它们容易出错,而且测试成本极高。也就是说,我亲眼目睹了让一个熟练的QA测试员尝试用创造性的方式打破你的工作是多么有价值。
那么,我们如何将其应用于Go?
一个案例研究
我创办arkade是为了成为一个Kubernetes应用和图表的市场。它使它们更容易被发现,并通过一个命令进行安装。我不得不一遍又一遍地写同样的指令,我知道通过自动化设置本地开发环境可以变得更快、更有效。
下面是一个看起来像什么的例子。
arkade install kubernetes-dashboard
arkade install argocd
但我们不打算在这里讨论安装应用程序的问题。
arkade推出后不久,发现大多数Kubernetes开发者也需要在他们的机器上有一套核心的CLI,如terraform、kubectl、kubectx、kind、k3d、kail、k9s,不胜枚举。Arkade在这方面也有帮助,它使这些二进制文件只需一个命令就能完成。
arkade get kubectl@v1.21.1 \
faas-cli \
k9s \
helm \
k3d \
kind@v0.12.0
Arkade与其他工具(如apt-get或brew)的做法不同。它检查你的系统,然后为你的操作系统和CPU下载正确的二进制文件。
单元测试URL模板
对于每个工具,我们都会添加一个Go模板来执行并生成一个下载URL。你如何进行单元测试?
从tools.go中节选一个样本,提供k3sup 二进制文件。
func MakeTools() Tools {
tools := []Tool{}
tools = append(tools,
Tool{
Owner: "alexellis",
Repo: "k3sup",
Name: "k3sup",
Description: "Bootstrap Kubernetes with k3s over SSH < 1 min.",
BinaryTemplate: `{{ if HasPrefix .OS "ming" -}}
{{.Name}}.exe
{{- else if eq .OS "darwin" -}}
{{.Name}}-darwin
{{- else if eq .Arch "armv6l" -}}
{{.Name}}-armhf
{{- else if eq .Arch "armv7l" -}}
{{.Name}}-armhf
{{- else if eq .Arch "aarch64" -}}
{{.Name}}-arm64
{{- else -}}
{{.Name}}
{{- end -}}`,
})
return tools
因此,当这个模板在输入 "darwin "的情况下执行时,我们会得到k3sup 的二进制文件和GitHub的下载URL。
下面是get_test.go 中的单元测试的样子。
func Test_DownloadK3sup(t *testing.T) {
tools := MakeTools()
name := "k3sup"
var tool *Tool
for _, target := range tools {
if name == target.Name {
tool = &target
break
}
}
tests := []test{
{os: "mingw64_nt-10.0-18362",
arch: arch64bit,
version: "0.9.2",
url: "https://github.com/alexellis/k3sup/releases/download/0.9.2/k3sup.exe"},
{os: "linux",
arch: arch64bit,
version: "0.9.2",
url: "https://github.com/alexellis/k3sup/releases/download/0.9.2/k3sup"},
{os: "darwin",
arch: arch64bit,
version: "0.9.2",
url: "https://github.com/alexellis/k3sup/releases/download/0.9.2/k3sup-darwin"},
{os: "linux",
arch: "armv7l",
version: "0.9.2",
url: "https://github.com/alexellis/k3sup/releases/download/0.9.2/k3sup-armhf"},
{os: "linux",
arch: "aarch64",
version: "0.9.2",
url: "https://github.com/alexellis/k3sup/releases/download/0.9.2/k3sup-arm64"},
}
for _, tc := range tests {
got, err := tool.GetURL(tc.os, tc.arch, tc.version)
if err != nil {
t.Fatal(err)
}
if got != tc.url {
t.Fatalf("want: %s, got: %s", tc.url, got)
}
}
}
这个测试执行起来非常快,因为它只行使了GetURL 函数,并有一组预定义的参数。当通过正常的用户交互运行时,可以通过HTTP调用从GitHub获得版本,但我们在单元测试中不这样做。
我们只是测试URL的形成是否正确,对于提供的每个CPU/OS架构。最近,一些工具为苹果M1 Macs增加了一个二进制文件,所以所有的开源贡献者需要做的就是增加一个测试案例并更新模板。
那么,端到端测试在这里的作用是什么?
在写这篇文章的时候,运行arkade get ,输出一个ASCII表,显示可以下载的76种不同的工具,用于4-8种不同的操作系统和CPU组合。
这可能意味着有多达608个不同的二进制文件需要检查,即使是并行地做这些工作,运行起来也会很耗时和昂贵。
在与社区合作后,我们决定只检查英特尔/AMD CPU的Linux操作系统。这不是万无一失的,但它确实能很好地表明URL是否有可能被破坏。我们有知道一个系统如何被破坏的好处,所以我们正在测试。
一个项目可能在他们的URL中的某个地方引入了v ,所以v0.1.0 ,其URL的结尾是:helm_0.1.0 ,而下一个版本则是。 helm_v0.1.0 - 或者他们开始添加一个后缀,如helm_linux_amd64 - 或者他们从_ 下划线改为- 破折号?
即使我们的测试范围缩小了,仍然需要非常长的时间,因为任何依赖Kubernetes Go库的工具都会严重膨胀到30-100MB以上。
我们采取的方法是发送一个HTTP HEAD请求,而不是下载整个二进制文件。这应该能让我们知道最新的版本是否仍然适用于英特尔/AMD平台上的Linux,并且比优柔寡断的上游项目维护者领先一步。
与e2e测试的摩擦
有一个公开的Pull Request,要求贡献一个新的 "main.go",可以手动运行以检查下载情况,但Go单元测试框架提供了一个更通用的平台,而且是专门为这个任务建立的。
我写了一个测试表,就像你在上面看到的那样,但即使是运行HTTP HEAD,这个测试在我的机器上也要花费30-60秒,我知道它会妨碍像我这样的人,只是克隆一个Go项目并期望运行go test 。
我希望能够将端到端的测试分离出来,但同时,按顺序运行它们也需要太长时间。
考虑到这些测试可能在任何时候发生故障,我希望它们能按计划运行,而不是阻止新的、可能与故障无关的贡献。
以下是我们的做法,你可能也能从这个方法中受益。
//go:build e2e
// +build e2e
package get
import (
"net/http"
"testing"
)
// Test_CheckTools runs end to end tests to verify the URLS for various tools using a HTTP head request.
func Test_CheckTools(t *testing.T) {
tools := MakeTools()
os := "linux"
arch := "x86_64"
for _, toolV := range tools {
tool := toolV
t.Run("Download of "+tool.Name, func(t *testing.T) {
t.Parallel()
url, err := tool.GetURL(os, arch, tool.Version)
if err != nil {
t.Fatalf("Error getting url for %s: %s", tool.Name, err)
}
t.Logf("Checking %s via %s", tool.Name, url)
status, body, headers, err := tool.Head(url)
if err != nil {
t.Fatalf("Error with HTTP HEAD for %s, %s: %s", tool.Name, url, err)
}
if status != http.StatusOK {
t.Fatalf("Error with HTTP HEAD for %s, %s: status code: %d, body: %s", tool.Name, url, status, body)
}
if headers.Get("Content-Length") == "" {
t.Fatalf("Error with HTTP HEAD for %s, %s: content-length zero", tool.Name, url)
}
})
}
}
以下是我们的收获。
通过在文件顶部添加go:build e2e ,这些测试不会在go test ,但会在go test -tags e2e 。这意味着维护者和贡献者可以继续做他们的日常工作,快速迭代。
通过使用t.Parallel() 命令并行运行,测试的速度大大加快。
虽然这里没有详细介绍,但上面的代码是通过GitHub Action中的cron时间表每晚运行的:arkade/.github/workflows/e2e-url-checker.yml
这显示了单元测试和e2e测试的区别,使用相同的Go单元测试框架,和测试表。
总结
我们简单看了一下什么是单元测试,什么不是,以及为什么端到端测试会运行缓慢,