31、Go语言测试:从基础到高级实践

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 语言的测试体系为我们提供了丰富的工具和方法。通过合理运用各种测试技术,我们可以确保软件的正确性、性能和可维护性。在实际开发中,我们要根据项目的特点和需求,选择合适的测试方法,不断优化测试用例,为软件的成功交付奠定坚实的基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值