Go语言测试:从基础到高级实践
1. 测试概述
在软件开发的历史长河中,测试一直是确保程序质量的关键环节。早在1949年,EDSAC(第一台存储程序计算机)的开发者Maurice Wilkes在实验室爬楼梯时就有了一个惊人的感悟:“我突然强烈地意识到,我余生的很大一部分时间将花在查找自己程序中的错误上。”如今,程序的规模和复杂度远超Wilkes所处的时代,为了应对这种复杂性,人们在测试技术上投入了大量精力。
测试,这里我们主要指自动化测试,是编写小程序来检查被测代码(生产代码)在特定输入下是否按预期运行的实践。这些输入通常是精心选择以测试某些功能,或者是随机生成以确保广泛覆盖。
Go语言的测试方法相对简单,它依赖于一个命令
go test
和一套编写测试函数的约定。这种轻量级的机制在纯测试方面非常有效,并且自然地扩展到基准测试和文档示例。
2.
go test
工具
go test
子命令是Go包的测试驱动程序,它根据特定约定组织测试。在包目录中,文件名以
_test.go
结尾的文件通常不是
go build
构建的包的一部分,但在
go test
构建时会包含在内。
在
*_test.go
文件中,有三种特殊类型的函数:测试函数、基准测试函数和示例函数。
- 测试函数:名称以
Test
开头,用于测试程序逻辑的正确性,
go test
调用这些函数并报告结果(PASS或FAIL)。
- 基准测试函数:名称以
Benchmark
开头,用于测量某些操作的性能,
go test
报告操作的平均执行时间。
- 示例函数:名称以
Example
开头,提供机器检查的文档。
go test
工具会扫描
*_test.go
文件中的这些特殊函数,生成一个临时的
main
包来正确调用它们,构建并运行,报告结果,然后清理。
3. 测试函数
每个测试文件都必须导入
testing
包。测试函数的签名如下:
func TestName(t *testing.T) {
// ...
}
测试函数名必须以
Test
开头,可选的后缀
Name
必须以大写字母开头。
3.1 示例:回文检测函数测试
我们以一个简单的回文检测函数为例,首先定义一个包
gopl.io/ch11/word1
,其中包含一个
IsPalindrome
函数:
// Package word provides utilities for word games.
package word
// IsPalindrome reports whether s reads the same forward and backward.
// (Our first attempt.)
func IsPalindrome(s string) bool {
for i := range s {
if s[i] != s[len(s)-1-i] {
return false
}
}
return true
}
在同一目录下的
word_test.go
文件中,我们编写两个测试函数
TestPalindrome
和
TestNonPalindrome
:
package word
import "testing"
func TestPalindrome(t *testing.T) {
if !IsPalindrome("detartrated") {
t.Error(`IsPalindrome("detartrated") = false`)
}
if !IsPalindrome("kayak") {
t.Error(`IsPalindrome("kayak") = false`)
}
}
func TestNonPalindrome(t *testing.T) {
if IsPalindrome("palindrome") {
t.Error(`IsPalindrome("palindrome") = true`)
}
}
我们可以使用以下命令构建并运行测试:
$ cd $GOPATH/src/gopl.io/ch11/word1
$ go test
运行结果可能如下:
ok
gopl.io/ch11/word1
0.008s
然而,当用户报告问题时,比如无法识别“été”和“A man, a plan, a canal: Panama”为回文,我们需要添加新的测试用例:
func TestFrenchPalindrome(t *testing.T) {
if !IsPalindrome("été") {
t.Error(`IsPalindrome("été") = false`)
}
}
func TestCanalPalindrome(t *testing.T) {
input := "A man, a plan, a canal: Panama"
if !IsPalindrome(input) {
t.Errorf(`IsPalindrome(%q) = false`, input)
}
}
再次运行测试,会得到失败的结果:
$ go test
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
FAIL
gopl.io/ch11/word1
0.014s
这时候,我们应该先编写测试并观察它是否触发了用户报告的相同失败,然后再修复问题。修复后,我们重写
IsPalindrome
函数:
// Package word provides utilities for word games.
package word
import "unicode"
// IsPalindrome reports whether s reads the same forward and backward.
// Letter case is ignored, as are non-letters.
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
同时,我们编写一个更全面的测试用例表:
func TestIsPalindrome(t *testing.T) {
var tests = []struct {
input string
want bool
}{
{"", true},
{"a", true},
{"aa", true},
{"ab", false},
{"kayak", true},
{"detartrated", true},
{"A man, a plan, a canal: Panama", true},
{"Evil I did dwell; lewd did I live.", true},
{"Able was I ere I saw Elba", true},
{"été", true},
{"Et se resservir, ivresse reste.", true},
{"palindrome", false}, // non-palindrome
{"desserts", false},
// semi-palindrome
}
for _, test := range tests {
if got := IsPalindrome(test.input); got != test.want {
t.Errorf("IsPalindrome(%q) = %v", test.input, got)
}
}
}
再次运行测试,新的测试用例通过:
$ go test gopl.io/ch11/word2
ok
gopl.io/ch11/word2
0.015s
3.2 测试函数的其他注意事项
-
t.Errorf不会导致测试函数停止执行,因此可以在一次运行中发现多个失败。 -
如果需要立即停止测试函数,可以使用
t.Fatal或t.Fatalf。 - 测试失败消息通常采用“f(x) = y, want z”的形式,其中f(x)解释了尝试的操作及其输入,y是实际结果,z是预期结果。
4. 随机测试
表驱动测试适用于检查函数在精心选择的输入上的工作情况,而随机测试则通过随机构造输入来探索更广泛的输入范围。
4.1 随机测试的策略
有两种策略来确定函数在随机输入下的预期输出:
- 编写函数的另一种实现,使用效率较低但更简单清晰的算法,检查两种实现是否给出相同的结果。
- 根据某种模式创建输入值,以便我们知道预期的输出。
4.2 示例:随机回文测试
以下是一个随机回文生成函数和相应的测试函数:
import "math/rand"
// randomPalindrome returns a palindrome whose length and contents
// are derived from the pseudo-random number generator rng.
func randomPalindrome(rng *rand.Rand) string {
n := rng.Intn(25) // random length up to 24
runes := make([]rune, n)
for i := 0; i < (n+1)/2; i++ {
r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
runes[i] = r
runes[n-1-i] = r
}
return string(runes)
}
func TestRandomPalindromes(t *testing.T) {
// Initialize a pseudo-random number generator.
seed := time.Now().UTC().UnixNano()
t.Logf("Random seed: %d", seed)
rng := rand.New(rand.NewSource(seed))
for i := 0; i < 1000; i++ {
p := randomPalindrome(rng)
if !IsPalindrome(p) {
t.Errorf("IsPalindrome(%q) = false", p)
}
}
}
由于随机测试是非确定性的,记录失败测试的足够信息以重现失败至关重要。在这个例子中,
IsPalindrome
的输入
p
告诉了我们所需的所有信息,但对于接受更复杂输入的函数,记录伪随机数生成器的种子可能更简单。
5. 测试命令
go test
工具不仅适用于测试库包,还可以用于测试命令。以
echo
程序为例,我们将其拆分为两个函数:
echo
负责实际工作,
main
解析和读取标志值并报告
echo
返回的错误。
5.1
echo
程序代码
// Echo prints its command-line arguments.
package main
import (
"flag"
"fmt"
"io"
"os"
"strings"
)
var (
n = flag.Bool("n", false, "omit trailing newline")
s = flag.String("s", " ", "separator")
)
var out io.Writer = os.Stdout // modified during testing
func main() {
flag.Parse()
if err := echo(!*n, *s, flag.Args()); err != nil {
fmt.Fprintf(os.Stderr, "echo: %v\n", err)
os.Exit(1)
}
}
func echo(newline bool, sep string, args []string) error {
fmt.Fprint(out, strings.Join(args, sep))
if newline {
fmt.Fprintln(out)
}
return nil
}
5.2 测试
echo
程序
在
echo_test.go
文件中编写测试代码:
package main
import (
"bytes"
"fmt"
"testing"
)
func TestEcho(t *testing.T) {
var tests = []struct {
newline bool
sep string
args []string
want string
}{
{true, "", []string{}, "\n"},
{false, "", []string{}, ""},
{true, "\t", []string{"one", "two", "three"}, "one\ttwo\tthree\n"},
{true, ",", []string{"a", "b", "c"}, "a,b,c\n"},
{false, ":", []string{"1", "2", "3"}, "1:2:3"},
}
for _, test := range tests {
descr := fmt.Sprintf("echo(%v, %q, %q)",
test.newline, test.sep, test.args)
out = new(bytes.Buffer) // captured output
if err := echo(test.newline, test.sep, test.args); err != nil {
t.Errorf("%s failed: %v", descr, err)
continue
}
got := out.(*bytes.Buffer).String()
if got != test.want {
t.Errorf("%s = %q, want %q", descr, got, test.want)
}
}
}
5.3 测试注意事项
-
测试代码和生产代码可以在同一个包中,即使包名为
main,测试时其main函数会被忽略。 -
被测试的代码不应调用
log.Fatal或os.Exit,因为这些会导致进程停止。
6. 白盒测试
测试可以根据对被测包内部工作原理的了解程度进行分类,主要分为黑盒测试和白盒测试。
6.1 黑盒测试和白盒测试的定义
- 黑盒测试:只假设包的API和文档所暴露的内容,包的内部是不透明的。
- 白盒测试:可以访问包的内部函数和数据结构,能够进行普通客户端无法进行的观察和更改。
6.2 两种测试方法的优缺点
- 黑盒测试:通常更健壮,随着软件的发展需要的更新较少,有助于测试作者从客户端的角度思考,揭示API设计中的缺陷。
- 白盒测试:可以更详细地覆盖实现中较复杂的部分。
6.3 示例:白盒测试
我们之前的
TestIsPalindrome
是黑盒测试,而
TestEcho
是白盒测试,因为它调用了未导出的
echo
函数并更新了全局变量
out
。
另一个例子是一个网络存储服务的配额检查逻辑:
package storage
import (
"fmt"
"log"
"net/smtp"
)
func bytesInUse(username string) int64 { return 0 /* ... */ }
// Email sender configuration.
// NOTE: never put passwords in source code!
const sender = "notifications@example.com"
const password = "correcthorsebatterystaple"
const hostname = "smtp.example.com"
const template = `Warning: you are using %d bytes of storage,
%d%% of your quota.`
func CheckQuota(username string) {
used := bytesInUse(username)
const quota = 1000000000 // 1GB
percent := 100 * used / quota
if percent < 90 {
return // OK
}
msg := fmt.Sprintf(template, used, percent)
auth := smtp.PlainAuth("", sender, password, hostname)
err := smtp.SendMail(hostname+":587", auth, sender,
[]string{username}, []byte(msg))
if err != nil {
log.Printf("smtp.SendMail(%s) failed: %s", username, err)
}
}
为了测试这个功能,我们不想发送真实的电子邮件,因此将电子邮件逻辑移到一个未导出的包级变量
notifyUser
中:
var notifyUser = func(username, msg string) {
auth := smtp.PlainAuth("", sender, password, hostname)
err := smtp.SendMail(hostname+":587", auth, sender,
[]string{username}, []byte(msg))
if err != nil {
log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
}
}
func CheckQuota(username string) {
used := bytesInUse(username)
const quota = 1000000000 // 1GB
percent := 100 * used / quota
if percent < 90 {
return // OK
}
msg := fmt.Sprintf(template, used, percent)
notifyUser(username, msg)
}
然后编写测试代码:
package storage
import (
"strings"
"testing"
)
func TestCheckQuotaNotifiesUser(t *testing.T) {
// Save and restore original notifyUser.
saved := notifyUser
defer func() { notifyUser = saved }()
// Install the test's fake notifyUser.
var notifiedUser, notifiedMsg string
notifyUser = func(user, msg string) {
notifiedUser, notifiedMsg = user, msg
}
// ...simulate a 980MB-used condition...
const user = "joe@example.org"
CheckQuota(user)
if notifiedUser == "" && notifiedMsg == "" {
t.Fatalf("notifyUser not called")
}
if notifiedUser != user {
t.Errorf("wrong user (%s) notified, want %s",
notifiedUser, user)
}
const wantSubstring = "98% of your quota"
if !strings.Contains(notifiedMsg, wantSubstring) {
t.Errorf("unexpected notification message <<%s>>, "+
"want substring %q", notifiedMsg, wantSubstring)
}
}
在这个测试中,我们使用
defer
来确保在测试结束后恢复
notifyUser
的原始值,避免影响后续测试。
7. 外部测试包
在某些情况下,测试可能会导致包导入图中出现循环,为了避免这种情况,我们可以使用外部测试包。
7.1 问题描述
考虑
net/url
和
net/http
两个包,
net/http
依赖于
net/url
,但
net/url
的一个测试需要导入
net/http
,如果在
net/url
包中声明这个测试函数,会导致包导入循环,而Go规范禁止这种循环。
7.2 解决方案
我们将测试函数声明在一个外部测试包中,即在
net/url
目录下的文件中,包声明为
package url_test
。额外的后缀
_test
是
go test
的信号,它会构建一个包含这些文件的额外包并运行其测试。
7.3 示例:
fmt
包的测试
我们可以使用
go list
工具来总结一个包目录中的Go源文件,以
fmt
包为例:
$ go list -f={{.GoFiles}} fmt
[doc.go format.go print.go scan.go]
$ go list -f={{.TestGoFiles}} fmt
[export_test.go]
$ go list -f={{.XTestGoFiles}} fmt
[fmt_test.go scan_test.go stringer_test.go]
有时候,外部测试包可能需要访问被测包的内部信息,这时可以在一个内部
_test.go
文件中声明一些内容,为外部测试提供“后门”。例如,
fmt
包为了避免依赖
unicode
包,自己实现了一个
isSpace
函数,为了确保其行为与
unicode.IsSpace
一致,
fmt
包通过
export_test.go
文件暴露内部的
isSpace
函数:
package fmt
var IsSpace = isSpace
8. 编写有效测试
Go语言的测试框架相对简约,与其他语言的框架不同,它期望测试作者自己完成大部分工作。
8.1 有效测试的特点
- 好的测试在失败时不会崩溃,而是清晰简洁地描述问题的症状和相关上下文,理想情况下,维护者无需阅读源代码就能理解测试失败的原因。
- 好的测试应该在一次运行中尝试报告多个错误,因为失败的模式本身可能具有启示性。
8.2 示例:糟糕的断言函数
以下是一个糟糕的断言函数示例:
import (
"fmt"
"strings"
"testing"
)
// A poor assertion function.
func assertEqual(x, y int) {
if x != y {
panic(fmt.Sprintf("%d != %d", x, y))
}
}
func TestSplit(t *testing.T) {
words := strings.Split("a:b:c", ":")
assertEqual(len(words), 3)
// ...
}
这个断言函数在失败时提供的错误信息几乎无用,因为它将测试失败简单地视为两个整数的差异,放弃了提供有意义上下文的机会。
8.3 示例:改进的测试函数
我们可以从具体细节出发,提供更好的错误信息:
func TestSplit(t *testing.T) {
s, sep := "a:b:c", ":"
words := strings.Split(s, sep)
if got, want := len(words), 3; got != want {
t.Errorf("Split(%q, %q) returned %d words, want %d",
s, sep, got, want)
}
// ...
}
这样的测试报告了被调用的函数、其输入以及结果的意义,明确标识了实际值和预期值,并且即使这个断言失败,测试仍会继续执行。
9. 避免脆弱测试
一个应用程序在遇到新的但有效的输入时经常失败被称为有缺陷,而一个测试在程序进行合理更改时意外失败则被称为脆弱。
9.1 脆弱测试的问题
脆弱的测试会让维护者感到沮丧,花费大量时间处理它们可能会耗尽它们曾经带来的好处。当被测试的函数产生复杂的输出时,如长字符串、复杂的数据结构或文件,直接检查输出是否与预期的“黄金”值完全相等是很诱人的,但随着程序的发展,输出的部分内容可能会发生变化。
9.2 避免脆弱测试的方法
- 只检查你关心的属性,优先测试程序更简单和更稳定的接口。
- 有选择地进行断言,例如不检查精确的字符串匹配,而是查找在程序发展过程中保持不变的相关子字符串。
- 编写一个实质性的函数,将复杂的输出提炼为其本质,使断言更可靠。
通过遵循这些原则,我们可以编写更健壮、更有效的测试,确保软件的质量和可维护性。
综上所述,Go语言的测试机制虽然简单,但通过合理运用各种测试方法和技巧,我们可以确保程序的正确性和性能。无论是简单的功能测试,还是复杂的白盒测试和集成测试,都能在Go语言的测试框架下得到很好的实现。在实际开发中,我们应该根据具体情况选择合适的测试方法,编写有效且健壮的测试用例,为软件的质量保驾护航。
10. 测试总结与实践建议
10.1 测试方法总结
| 测试方法 | 特点 | 适用场景 | 示例 |
|---|---|---|---|
| 表驱动测试 | 方便检查精心选择的输入 | 功能测试,验证特定输入的输出 | 回文检测函数的表驱动测试 |
| 随机测试 | 探索更广泛的输入范围 | 覆盖更多边界情况和随机输入 | 随机回文测试 |
| 测试命令 | 可用于测试命令行程序 | 命令行工具的功能测试 |
echo
程序的测试
|
| 白盒测试 | 访问包内部信息,详细覆盖复杂部分 | 检查包内部逻辑和数据结构 | 网络存储服务配额检查逻辑的测试 |
| 外部测试包 | 避免包导入循环 | 解决测试依赖导致的循环问题 |
net/url
包的外部测试
|
10.2 实践建议
- 编写测试的顺序 :先编写测试并观察其触发用户报告的相同失败,再修复问题。这样可以确保修复的是正确的问题。
- 测试用例的维护 :采用表驱动测试可以方便地添加新的测试用例,同时避免断言逻辑的重复,便于维护。
- 错误信息的提供 :测试失败时,提供清晰、有上下文的错误信息,帮助维护者快速定位问题。
-
避免全局变量的影响
:在白盒测试中,如果修改了全局变量,使用
defer确保在测试结束后恢复其原始值,避免影响后续测试。
11. 测试流程示例
下面是一个简单的 mermaid 流程图,展示了一个典型的测试流程:
graph TD;
A[需求分析] --> B[编写测试用例];
B --> C[实现生产代码];
C --> D[运行测试];
D --> E{测试是否通过};
E -- 是 --> F[部署上线];
E -- 否 --> G[修复生产代码];
G --> D;
这个流程图展示了从需求分析开始,到编写测试用例、实现生产代码、运行测试,根据测试结果决定是否部署上线或修复代码的过程。
12. 持续集成与测试
在现代软件开发中,持续集成(CI)是一个重要的实践,它可以确保每次代码变更都经过自动化测试。以下是一个简单的持续集成流程示例:
1.
代码提交
:开发人员将代码提交到版本控制系统(如 Git)。
2.
触发 CI 任务
:版本控制系统检测到代码变更,触发 CI 服务器上的任务。
3.
环境搭建
:CI 服务器创建一个干净的环境,安装所需的依赖。
4.
运行测试
:在新环境中运行所有的测试用例。
5.
结果反馈
:CI 服务器将测试结果反馈给开发人员,如果测试失败,开发人员需要修复代码并重新提交。
通过持续集成,可以及时发现代码变更引入的问题,保证软件的质量。
13. 性能测试与基准测试
除了功能测试,性能测试也是软件开发中不可或缺的一部分。Go 语言的
go test
工具支持基准测试,可以帮助我们测量程序的性能。
13.1 基准测试函数
基准测试函数的名称以
Benchmark
开头,其签名如下:
func BenchmarkName(b *testing.B) {
// ...
}
13.2 示例:基准测试
假设我们有一个函数
fibonacci
用于计算斐波那契数列,我们可以编写如下基准测试函数:
package main
import "testing"
func fibonacci(n int) int {
if n <= 1 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
fibonacci(10)
}
}
运行基准测试的命令如下:
$ go test -bench=.
运行结果会显示函数的平均执行时间等性能指标。
13.3 性能优化建议
- 分析性能瓶颈 :通过基准测试找出程序的性能瓶颈,例如某个函数的执行时间过长。
- 优化算法 :选择更高效的算法可以显著提高程序的性能。
- 减少内存分配 :频繁的内存分配和释放会影响程序的性能,尽量复用对象。
14. 测试覆盖率
测试覆盖率是衡量测试用例对代码的覆盖程度的指标。Go 语言提供了工具来计算测试覆盖率。
14.1 计算测试覆盖率
运行以下命令可以生成测试覆盖率报告:
$ go test -coverprofile=coverage.out
$ go tool cover -html=coverage.out
14.2 提高测试覆盖率的建议
- 补充测试用例 :分析未覆盖的代码部分,编写新的测试用例来覆盖这些部分。
- 边界条件测试 :确保测试用例覆盖了所有可能的边界条件。
15. 测试的未来发展
随着软件开发的不断发展,测试技术也在不断进步。未来,测试可能会朝着以下方向发展:
-
智能化测试
:利用人工智能和机器学习技术,自动生成测试用例,提高测试效率。
-
持续测试
:将测试融入到软件开发的整个生命周期中,实现持续的质量保证。
-
云测试
:利用云计算的强大计算能力,进行大规模的测试。
我们应该关注这些趋势,不断学习和应用新的测试技术,提高软件的质量和开发效率。
总之,Go 语言的测试体系为我们提供了丰富的工具和方法。通过合理运用各种测试技术,我们可以确保软件的正确性、性能和可维护性。在实际开发中,我们要根据项目的特点和需求,选择合适的测试方法,不断优化测试用例,为软件的成功交付奠定坚实的基础。
超级会员免费看

被折叠的 条评论
为什么被折叠?



