gopl 测试

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了gopl 测试相关的知识,希望对你有一定的参考价值。

go test

测试是自动化测试的简称,即编写简单的程序来确保程序(产品代码)在该测试中针对特定输入产生预期的输出。这些测试主要分两种:

  • 通过精心设计,用来检测某种功能
  • 随机性的,用来扩大测试的覆盖面

go test 子命令是 Go 语言包的测试驱动程序。在一个包目录中,以 _test.go 结尾的文件不是 go build 命令编译的目标,而是 go test 编译的目标。
在 *_test.go 的测试源码文件中,有三种类型的函数:

  • 功能测试函数
  • 基准测试函数
  • 示例函数

功能测试函数,以 Test 开头,用来检测一些程序逻辑的正确性。
基准测试函数,以 Benchmark 开头,用来测试程序的性能。
示例函数,以 Example 开头,提供一个机器检查过的示例文档。

Test 函数(功能测试)

每一个测试文件必须导入 testing 包。这些函数的函数签名如下:

func TestName(t *testing.T) {
    // ...
}

参数 t 提供了汇报测试失败和日志记录的功能。

定义示例

下面先定义一个用来测试的示例,这个示例包含一个函数 IsPalindrome,用来判断一个字符串是否是回文:

// word 包提供了文字游戏相关的工具函数
package word

// IsPalindrome 判断一个字符串是否是回文
func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s)-1-i] {
            return false
        }
    }
    return true
}

这个函数对于一个字符串是否是回文字符串前后重复测试了两次,其实只要检查完字符串一半的字符就可以结束了。这个在稍后测试性能的时候会做改进,这里先关注功能。

测试源码文件

在同一个目录中,再写一个测试文件。假设上面的示例的文件名是 word.go,那么这个测试文件的文件名可以是 word_test.go(命名没有强制要求,但是这样的命名使得文件的意义一目了然)。文件中包含了两个功能测试函数,这两个函数都是检查 IsPalindrome 函数是否针对某个输入的参数能给出正确的结果,并且用 t.Error 来报错:

package word

import "testing"

func TestPalindrome(t *testing.T) {
    if !IsPalindrome("civic") {
        t.Error(`IsPalindrome("civic") = false`)
    }
    if !IsPalindrome("madam") {
        t.Error(`IsPalindrome("madam") = false`)
    }
}

func TestNonPalindrome(t *testing.T) {
    if IsPalindrome("palindrome") {
        t.Error(`IsPalindrome("palindrome") = true`)
    }
}

功能扩展

这个最初版本的回文判断函数比较简陋,有些明显也是回文的情况,但是无法被现在这个版本的函数检测出来:

  • "上海自来水来自海上"
  • "Madam, I‘m Adam"

针对上面两种回文,又写了新的测试用例:

func TestChinesePalindrome(t *testing.T) {
    input := "上海自来水来自海上"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

func TestSentencePalindrome(t *testing.T) {
    input := "Madam, I‘m Adam"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

这里用了 Errorf 函数,具有格式化的功能。

运行 go test

添加了新的测试后,再运行 go test 命令失败了,错误信息如下:

PS G:\Steed\Documents\Go\src\gopl\ch11\word1> go test
--- FAIL: TestChinesePalindrome (0.00s)
    word_test.go:23: IsPalindrome("上海自来水来自海上") = false
--- FAIL: TestSentencePalindrome (0.00s)
    word_test.go:30: IsPalindrome("Madam, I‘m Adam") = false
FAIL
exit status 1
FAIL    gopl/ch11/word1 0.292s
PS G:\Steed\Documents\Go\src\gopl\ch11\word1>

这里是一个比较好的实践,先写测试然后发现它触发的的错误。通过这步,可以定位到真正要解决的问题,并在修复后确认问题已经解决。

运行 go test 还可以指定一些参数:

PS G:\Steed\Documents\Go\src\gopl\ch11\word1> go test -v -run="Chinese|Sentence"
=== RUN   TestChinesePalindrome
--- FAIL: TestChinesePalindrome (0.00s)
    word_test.go:23: IsPalindrome("上海自来水来自海上") = false
=== RUN   TestSentencePalindrome
--- FAIL: TestSentencePalindrome (0.00s)
    word_test.go:30: IsPalindrome("Madam, I‘m Adam") = false
FAIL
exit status 1
FAIL    gopl/ch11/word1 0.250s
PS G:\Steed\Documents\Go\src\gopl\ch11\word1>

参数 -v 可以输出包中每个测试用例的名称和执行时间。默认只会输出有问题的测试。
参数 -run 是一个正则表达式,可以使 go test 只运行那些测出函数名称匹配的函数。

上面选择性地只运行新的测试用例。一旦之后的修复使得测试用例通过后,还必须使用不带开关的 go test 来运行一次完整的测试。

新的示例函数

上一版本的函数比较简单,使用字节序列而不是字符序列,因此无法支持非 ASCII 字符的检查。另外也没有忽略空格、标点符号和字母大小写。下面重写了这个函数:

// word 包提供了文字游戏相关的工具函数
package word

import "unicode"

// IsPalindrome 判断一个字符串是否是回文
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
}

新的测试用例

测试用例也重新写。这里是一个更加全面的测试用例,把之前的用例和新的用例结合到一个表里:

package word

import "testing"

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want  bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"palindrome", false},
        {"desserts", false},
        {"上海自来水来自海上", true},
        {"Madam, I‘m Adam", true},
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf(`IsPalindrome(%q) = %v`, test.input, got)
        }
    }
}

这种基于表的测试方式在 Go 里面很常见。根据需要添加新的表项很直观,并且由于断言逻辑没有重复,因此可以花点精力让输出的错误消息更好看一点。

小结-测试函数

调用 t.Errorf 输出的失败的测试用例信息没有包含整个跟踪栈信息,也不会导致程序终止执行。这样可以在一次测试过程中发现多个失败的情况。
如果需要在测试函数中终止,比如由于初始化代码失败,可以使用 t.Fatal 或 t.Fatalf 函数来终止当前测试函数,它们必须在测试函数的同一个 goroutine 内调用。

测试错误消息的建议
测试错误消息一般格式是 f(x)=y, want z,这里 f(x) 表示需要执行的操作和它的输入,y 是实际的输出结果,z 是期望得到的结果。在测试一个布尔函数的时候,省略 “want z” 部分,因为它没有给出有用的信息。上面的测试用例输出的错误消息基本也是这么做的,

随机测试

基于表的测试方便针对精心选择的输入检测函数是否工作正常,以测试逻辑上引人关注的用例。另外一种方式是随机测试,通过构建随机输入来扩展测试的覆盖范围。
对于随机的输入,要如何确认输出是否正确,这里有两种策略:

  • 额外写一个函数,这个函数使用低效但是清晰的算法,然后检查两种实现的输出是否一致
  • 构建符合某种模式的输入,这样就可以知道期望的输出模式

下面的例子使用了第二种模式,randomPalindrome 函数可以随机的创建回文字符串,使用这些回文字符串来验证进行测试:

import (
    "math/rand"
    "testing"
    "time"
)

// randomPalindrome 返回一个回文字符串,它的长度和内容都是随机生成的
func randomPalindrome(rng *rand.Rand) string {
    n := rng.Intn(25) // 随机字符串最大长度24
    runes := make([]rune, n)
    for i := 0; i < (n+1)/2; i++ {
        r := rune(rng.Intn(0x1000)) // 随机字符最大是 `\u0999
        runes[i] = r
        runes[n-1-i] = r
    }
    return string(runes)
}

func TestRandomPalindromes(t *testing.T) {
    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)
        }
    }
}

由于随机测试的不确定性,在遇到测试用例失败的情况下,一定要记录足够多的信息以便于重现这个问题。这里记录伪随机数生成的种子会比转存储整个输入数据结构要简单得多。有了随机数的种子,就可以简单地修改测试代码来准确地重现错误。
通过使用当前时间作为伪随机数的种子源,在测试的整个生命周期中,每次运行的时候都会得到新的输入。如果你的项目使用自动化系统来周期地运行测试,这一点很重要。

测试一个命令

就是测试命令源码文件,其实和测试包源码文件差不多。毕竟都是一样的代码,不过需要额外做一些特殊的处理。
对于包的测试,go test 很有用,但是稍加修改,也能够将它用来测试可执行程序。一个 main 包可以生成可执行程序,不过也可以当做库来导入。

示例程序

下面的 echo 程序,可以输出命令行参数:

// 输出命令行参数
package main

import (
    "flag"
    "fmt"
    "strings"
)

var n = flag.Bool("n", false, "omit trailing newline")
var sep = flag.String("s", " ", "separator")

func main() {
    flag.Parse()
    fmt.Print(strings.Join(flag.Args(), *sep))
    if !*n {
        fmt.Println()
    }
}

为了便于测试,需要对程序进行修改。把程序分成两个函数,echo 执行逻辑,main 用来读取和解析命令行参数以及报告 echo 函数可能返回的错误:

// 输出命令行参数
package main

import (
    "flag"
    "fmt"
    "io"
    "os"
    "strings"
)

var (
    n   = flag.Bool("n", false, "omit trailing newline")
    sep = flag.String("s", " ", "separator")
)

var out io.Writer = os.Stdout // 测试过程中将会被更改

func main() {
    flag.Parse()
    if err := echo(!*n, *sep, 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.Fprintf(out, strings.Join(args, sep))
    if newline {
        fmt.Fprintln(out)
    }
    return nil
}

分离出执行逻辑
把程序的主要功能从 main 函数里分离出来了,运行程序的时候通过 main 函数来调用 echo。而测试的时候,就可以直接对 echo 函数进行测试。
避免依赖全局变量
在接下来的测试中,将通过不同的参数和开关来调用 echo,以检查它在不同的模式下都能正常工作。这里的 echo 函数调用的时候,通过传参获取这些信息,这是为了避免函数依赖全局变量,这样测试的时候也可以直接传参来调用 echo 不同的模式。
控制输出的变量
这里还另外引入了一个全局变量 out,该变量是 io.Writer 类型,所有的结果都将输出到这里。echo 函数的输出是输出到 out 变量而不是直接输出到 os.Stdout。这样正常使用的时候,就是输出到用户界面,而测试的时候,可以覆盖掉这个变量输出到其他地方。这样是实现了记录写入的内容以便于检查。

测试代码

下面是测试代码,在文件 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) // 捕获的输出
        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)
        }
    }
}

这里依然是通过表来组织测试用例,这样可以很容易地添加新的测试用例。下面是添加了一行到测试用例中:

{false, ":", []string{"1", "2", "3"}, "1:2:3\n"},

上面添加的这条是有错误的,正好可以看看测试失败的时候的输出:

PS H:\Go\src\gopl\ch11\echo> go test
--- FAIL: TestEcho (0.00s)
    echo_test.go:32: echo(false, ":", ["1" "2" "3"]) = "1:2:3", want "1:2:3\n"
FAIL
exit status 1
FAIL    gopl/ch11/echo  0.163s
PS H:\Go\src\gopl\ch11\echo>

错误信息首先描述了想要进行的操作,使用了类似 Go 的语法,就像一个函数调用。然后依次是实际获得个值和预期的结果。这样的错误信息就很有帮助。

测试中的错误处理
还要注意,测试代码里并没有调用 log.Fatal 或 os.Exit,因为这两个调用会阻止跟踪的过程,这两个函数的调用可以认为是 main 函数的特权。如果有时候发生了未预期的错误或者崩溃,即使测试用例本身失败了,测试驱动程序也还可以继续工作。预期的的错误应该通过返回一个非空的 error 值来报告,就像上面的测试代码里做的那样。

白盒测试

测试的一种分类方式是基于对所要进行测试的包的内部的了解程度:

  • 黑盒测试,假设测试者对包的了解仅通过公开的API和文档,而包的内部逻辑是不透明的
  • 白盒测试,可以访问包的内部函数和数据结构,并且可以做一些常规用户无法做到的观察和改动

白盒这个名字是传统的说法,净盒(clear box)的说法更准确。
以上两种方法是互补的。黑盒测试通常更加健壮,程序更新后基本不需要修改。并且可以帮助测试者了解用户的情况以及发现API设计的缺陷。反之,白盒测试可以对实现的特定之处提供更详细的覆盖测试。
之前的内容已经分别给出了这两种测试方法的例子:

  • TestIsPalindrome 函数仅调用导出的函数 IsPalindrome,所以它是一个黑盒测试
  • TestEcho 函数调用 echo 函数并且更新了全局变量 out,无论函数 echo 还是变量 out 都是未导出的,所以它是一个白盒测试

伪实现
在写 TestEcho 的时候,通过修改 echo 函数,从而在输出结果时使用了一个包级别的变量,使得测试可以使用一个额外的实现代替标准输出来记录要检查的数据。通过这样的技术,可以使用易于测试的伪实现来替换部分产品代码。这种伪实现的优点是更易于配置、预测和观察,并且更可靠。

示例程序

下面的代码演示了向用户提供存储服务的 Web 服务中的限额逻辑。当用户使用的额度超过 90% 的时候,系统自动发送一封告警邮件:

package storage

import (
    "fmt"
    "log"
    "net/smtp"
)

var usage = make(map[string]int64)

func bytesInUse(username string) int64 { return usage[username] }

// 邮件发送者配置
// 注意:永远不要把密码放到源代码中
const sender = "[email protected]"
const password = "password"
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.SendMail(%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) {
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }

    const user = "[email protected]"
    usage[user] = 980000000 // 模拟已经使用了 980M 的情况

    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)
    }
}

正确使用伪实现

目前来看,这个测试本身完成的很好,但是还有一个遗留问题。因为对 CheckQuota 测试中使用了伪实现替换了原本的 notifyUser 的内容,这样在之后的其他测试中,notifyUser 依然是这里被替换上的伪实现,这可能使得其他的测试无法正常工作(对于全局变量的更新一直都是存在风险的)。这里还必须再修改一下这个测试让他最后可以恢复 notifyUser 原来的值,这样之后的测试就不会收到影响。这里必须在所有的测试执行路径上这样做,包括测试失败和崩溃的情况。通常这种情况下建议使用 defer :

func TestCheckQuotaNotifiesUser(t *testing.T) {
    // 保存留待恢复的notifyUser
    saved := notifyUser
    defer func() { notifyUser = saved }()

    // 设置测试的伪通知notifyUser
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }

    // ...测试其余的部分...
}

以这种方式来使用全局变量是安全的,因为 go test 一般不会并发执行多个测试。
这种方式有很多用处:

  • 用来临时保存并恢复各种全局变量,包括命令行标志、调试参数、以及性能参数
  • 用来安装和移除钩子程序来让产品代码调用测试代码
  • 将产品代码设置为少见却很重要的状态,比如超时、错误,甚至是交叉并行执行

外部测试包

先来看一下 net/url 包,这个包提供了 URL 解析的功能。还有 net/http 包,这个包提供了 Web 服务器和 HTTP 客户端的库。高级的 net/http 包依赖于低级的 net/url 包。然而,在 net/url 包中有一个测试是用来演示 URL 和 HTTP 库之间进行交互的例子。也就是说,低级别包的测试导入了高级别包。这种情况下,在 net/url 包中声明的这个测试函数会导致包的循环引用,但是 Go 规范禁止循环引用。
为了解决测试时可能会出现的循环引用的问题,可以将这个测试函数定义在外部测试包中。

声明外部测试包

具体做法就是,测试文件的包名不和被测试的包同名,而是使用一个新的包名。在这个例子里,就是原本包名是 url,现在因为要导入高级别的包会出现循环引用,所以将包名改成一个别的名称,比如 url_test。这个额外的后缀 _test 告诉 go test 工具,它应该单独地编译这个包,然后进行它的测试。为了便于理解,可以认为这个外部测试包的导入路径是 net/url_test,但事实上它无法通过任何路径导入。
由于外部测试在一个单独的包里,因此它们可以引用一些依赖于被测试包的帮助包,这个是包内测试无法做到的。从设计层次来看,外部测试包逻辑上在它所依赖的两个包之上。
为了避免包循环导入,外部测试包允许测试用例,尤其是集成测试用例(用来测试多个组件的交互),自由地导入其他的包,就像一个引用程序那样。

使用 go list 工具

可以使用 go list 工具来汇总一个包目录中哪些是产品代码,哪些是包内测试、哪些是外部测试。这里用 fmt 包作为例子。

GoFiles
这类文件是包含产品代码的文件列表,这些文件是 go build 命令将编译进程序的代码:

PS H:\Go\src\gopl\ch11> go list -f="{{.GoFiles}}" fmt
[doc.go format.go print.go scan.go]

TestGoFiles
这类文件也属于 fmt 包,但是这些以 _test.go 结尾的文件是测试源码文件,仅在编译测试的时候才会使用:

PS H:\Go\src\gopl\ch11> go list -f="{{.TestGoFiles}}" fmt
[export_test.go]

这里的 export_test.go 这个文件还有特殊的意义,后面会单独讲。

XTestGoFiles
这类是包外部测试文件列表,这些同样的测试源码文件,仅用在测试过程中:

PS H:\Go\src\gopl\ch11> go list -f="{{.XTestGoFiles}}" fmt
[example_test.go fmt_test.go scan_test.go stringer_test.go]

白盒测试技巧

这是一个在外部测试中使用白盒测试的技巧,包内的白盒测试没有这个问题。
有时候,外部测试包需要对被测试包拥有特殊的访问权限。比如这种的情况:为了避免循环引用,需要声明外部测试包,但是又要做白盒测试,需要调用非导出的变量和函数。
应对这种情况,需要使用一种小技巧:在包内测试文件中添加一些声明,将包内部的功能暴露给外部测试。由于是声明在测试文件中的,所以暴露的后门只有在测试时可用。如果一个源文件存在的唯一目的就在于此,并且也不包含任何测试,这个文件一般就命名为 export_test.go。
下面是 fmt 包的 export_test.go 文件里所有的代码部分:

package fmt

var IsSpace = isSpace
var Parsenum = parsenum

fmt 包的实现需要功能 unicode.isSpace 作为 fmt.Scanf 的一部分。为了避免创建不合理的依赖,fmt 没有导入 unicode 包及其巨大的数据表,而是包含了一个更加简单的实现 isSpace。
为了确保 fmt.isSpace 和 unicode.isSpace 的功能一致,fmt 添加了一个测试。这是一个集成测试,所以用了外部测试包。但是测试中需要访问 isSpace,这是一个非导出的函数。所以就有了上面的代码,定义了一个可导出的变量来引用 isSpace 函数。并且这段代码是定义在测试文件中的,所以无法在产品代码中访问到这个函数。

这个技巧在任何外部测试需要使用白盒测试技术的时候都可以使用。

编写有效测试

Go 语言的测试期望测试的编写者自己来做大部分工作,通过定义函数来避免重复。测试的过程不是死记硬背地填表格,测试也是有用户界面的,虽然它的用户也是它的维护者。

好的测试

一个好的测试,不会在发生错误时崩溃,而是要输出一个简洁、清晰的现象描述来报告错误,以及与之上下文相关的信息。理想情况下,不需要再通过阅读源代码来探究失败的原因。
一个好的测试,不应该在发现一次测试失败后就终止,而是要在一次运行中尝试报告多个错误,因为错误发生的方式本身会揭露错误的原因。

举例说明

下面的断言函数比较两个值,构建一条一般的错误消息,并且停止程序。这是一个错误的例子,输出的错误消息毫无用处。它的最大的问题就是没有提供一个好的用户界面:

import (
    "fmt"
    "strings"
    "testing"
)

// 一个糟糕的断言函数
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)
    // ...
}

合适的做法
这里断言函数犯了过早抽象的错误:仅仅测试两个整数是否相同,而没能根据上下文提供更有意义的错误信息。这里可以根据具体的错误信息提供一个更好的错误输出。比如下面的做法。只有在测试中出现了重复的模式时才需要引入抽象:

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)
    }
    // ...
}

现在测试函数友好的用户界面表现在一下几个方面

  • 报告调用的函数名称、它的输入以及输出表示的含义
  • 显式的区分出实际值和期望值
  • 并且及时测试失败也能够继续执行。

当有了这样的一个测试函数之后,下一步不是定义一个函数来替代整个 if 语句,而是在一个循环中执行这个测试,就像之前基于表的测试方式那样。
当然定义一个函数来替代整个 if 语句也是可以的做法,只是这个例子太简单了,并不需要任何工具函数。但是为了使得测试代码更简洁,也可以考虑引入工具函数,如果上面的 assertEqual 函数的实现的用户界面更加友好的话。并且如果这种模式在其他测试代码里也会重复用到,那就更有必要进行抽象了。
一个好的测试的关键是首先实现你所期望的具体行为,之后再使用工具函数来使代码简洁并且避免重复。好的结果很少是从抽象的、通用的测试函数开始的。

这里再预告一点,比较两个变量的值在测试中很常见,并且会需要对各种类型的值进行比较,这就需要基于反射来实现。另外还会需要比较复合类型,这通过基于地址来判断引用的变量是否是同一个变量来实现,这是 unsafe 包的内容。在掌握了反射的内容之后,在 unsafe 包的内容里,会实现一个深度相等的工具函数。

避免脆弱的测试

如果一个应用在遇到新的合法输入的情况下经常崩溃,那么这个程序是有缺陷的
如果在程序发生可靠的改动的时候测试用例奇怪地失败了,那么这个测试用例也是脆弱的
避免写出脆弱测试的最简单的方法就是仅检查你关心的属性。例如,不要对输出的字符串进行完全匹配,而是寻找到在程序进化过程中不会发生改变的子串。通常情况下,这值得写一个稳定的函数来从复杂的输出中提取核心内容,只有这样之后的断言才会可靠。这虽然需要一些额外的工作,但这是值得的,否则这些时间会被花在修复那些奇怪地失败的测试上面。

覆盖率

语句覆盖率是一种最简单的且广泛使用的方法之一。一个测试套件的语句覆盖率是指部分语句在一次执行中执行执行一次。可以使用 go cover 工具,这个工具被集成到了 go test 中,用来衡量语句覆盖率并帮助识别测试之间的明显差别。
如果使用VSCode,直接通过测试源码文件里的按钮运行测试,再切换到源码文件中就能看到测试覆盖率的效果。下面讲的是不依赖编辑器和插件的做法。

生成覆盖率报告

通过下面的命令可以输出覆盖工具的使用方法:

PS G:\Steed\Documents\Go\src\gopl\ch11\storage2> go tool cover
Usage of ‘go tool cover‘:
Given a coverage profile produced by ‘go test‘:
        go test -coverprofile=c.out

Open a web browser displaying annotated source code:
        go tool cover -html=c.out
...

命令 go tool 运行 Go 工具链里的一个可执行文件。这些程序位于 $GOROOT/pkg/tool/${GOOS}_{GOARCH},就是 Go 安装目录里的文件夹下,都是一些 exe 文件。这里多亏了 go build 工具,我们不需要直接运行它。

-coverprofile 标记
要生成覆盖率报告,需要带上 -coverprofile 标记来运行测试:

PS G:\Steed\Documents\Go\src> go test -run=CheckQuotaNotifiesUser -coverprofile="c.out" gopl/ch11/storage2
ok      gopl/ch11/storage2      0.349s  coverage: 58.3% of statements
PS G:\Steed\Documents\Go\src>

这个标记通过检测产品代码,启用了覆盖数据收集。也就是说,它修改了源代码的副本,这样在这个语句块执行之前,设置一个布尔变量,每个语句块都对应一个变量。在修改程序退出之前,它将每个变量的值都写入到指定的日志文件,这里是 c.out,并记录被执行语句的汇总信息。

-cover 标记
如果不需要记录这个日志文件而只要查看命令行输出的内容,可以使用 -cover 标记:

PS G:\Steed\Documents\Go\src> go test -run=CheckQuotaNotifiesUser -cover gopl/ch11/storage2
ok      gopl/ch11/storage2      0.366s  coverage: 58.3% of statements
PS G:\Steed\Documents\Go\src>

效果是一样的,只是不生成记录文件。

-convermode=count 标记
默认的 mode 是 set。这个标记使每个语句块的检测使用一个递增计数器来替代原本的布尔值。这样日志中就能统计到每个块的执行次数,由此可以识别出执行频率较高的“热块”和相反的“冷块”。
VSCode似乎不能指定这个模式,所以只能生成查看布尔值的报告,检查代码是否被覆盖,看不到热块和冷块的效果。

查看覆盖率报告

在生成数据后,运行 cover 工具来处理生成的日志,可以生成一个 HTML 报告。可以在浏览器里直观的查看:

PS G:\Steed\Documents\Go\src> go tool cover -html="c.out"

Benchmark 函数

基准测试就是在一定的工作负载之下检测程序性能的一种方法。
基准测试函数看上去和功能测试函数差不多,前缀是 Benchmark 并且拥有一个 *testing.B 参数。*testing.B 和 *testing.T 差不多,还额外增加了一些和性能检测相关的方法。另外它还有一个整型成员 *testing.B.N,用来指定被检测操作的执行次数。

基准测试函数

回到之前的检查回文的函数,下面是 IsPalindrome 函数的基准测试,它在一个循环中调用了 IsPalindrome 共 N 次:

func BenchmarkIsPalindrome(b *testing.B) {
    for i := 0; i < b.N; i++ {
        IsPalindrome("山西悬空寺空悬西山")
    }
}

上面的基准测试函数直接加到之前的测试源码文件中。
在基准测试函数中手动写代码来实现循环,而不是在测试驱动程序中自动实现是有原因的。在基准测试函数中,for循环之外,可以执行一些必要的初始化代码并且这段时间不会加到每次迭代的时间中去。如果有代码会干扰结果,参数 testing.B 还提供了方法来停止、恢复和重置计时器(需要用到的场景并不多)。

执行基准测试

依然是使用 go test 命令来进行测试,但是默认情况下不会运行任何基准测试。需要加上 -bench 参数并指定有运行的基准测试。它是一个匹配 Benchmark 函数名称的正则表达式,默认值不匹配任何函数。可以使用点来匹配所有的基准测试函数:

PS G:\Steed\Documents\Go\src\gopl\ch11\word2> go test -bench="."
goos: windows
goarch: amd64
pkg: gopl/ch11/word2
BenchmarkIsPalindrome-4          1000000              1052 ns/op
PASS
ok      gopl/ch11/word2 2.253s
PS G:\Steed\Documents\Go\src\gopl\ch11\word2>

基准测试函数名称后面的数字后缀表示 GOMAXPROCS 的值。这对于一些并发相关的基准测试是一个重要的信息。
报告显示每次调用 IsPalindrome 的平均耗时是 1.052ms,这个是 1000000 次调用的平均值。基准测试运行器在开始的时候并不清楚测试操作的耗时,所以开始会用比较小的N值来做检测,然后为了检测稳定的运行时间,会推断出一个较大的次数来保证得到稳定的测试结果。

提升效率

现在有了基准测试,那么就先想办法来让程序更快一点,然后再运行基准测试来检查具体快了多少。
有一处是明显可以改进的,只需要遍历字符串前面一半的字符就可以完成字符串的检查。避免了第二次的重复比较:

    n := len(letters)
    for i := 0; i < n; i++ {
        if letters[i] != letters[len(letters)-1-i] {
            return false
        }
    }
    return true

但是通常情况下,优化并不能总是带来期望的好处。这个优化后的运行时间也就 1.004ms,只有4.5%的提升。

另外还有一处可以优化,为 letters 预分配一个容量足够大的数组,避免在 append 调用的时候多次进行扩容:

    // var letters []rune
    letters := make([]rune, 0, len(s))
    for _, r := range s {
        if unicode.IsLetter(r) {
            letters = append(letters, unicode.ToLower(r))
        }
    }

这次改进后平均运行时间缩短到了 0.839ms,提升了20%。

查看内存分配

如上面的例子所示,最快的程序通常是那些进行内存分配数量最少的程序。命令行标记 -benchmem 在报告中会包含内存分配统计数据。下面是优化前后两个函数的基准测试报告:

Running tool: D:\Go\bin\go.exe test -benchmem -run=^$ gopl\ch11\word2 -bench . -coverprofile=C:\Users\Steed\AppData\Local\Temp\vscode-gotvbvaq\go-code-cover

goos: windows
goarch: amd64
pkg: gopl/ch11/word2
BenchmarkIsPalindrome-4      1000000          1095 ns/op         120 B/op          4 allocs/op
BenchmarkIsPalindrome2-4     2000000           871 ns/op         112 B/op          1 allocs/op
PASS
coverage: 88.2% of statements
ok      gopl/ch11/word2 4.185s
Success: Benchmarks passed.

优化前有4次内存分配,分配了120B的内存。优化有只进行了1次内存分配,分配了112B的内存。(这里关于内存的分配主要是切片扩容的机制。)

性能比较函数

之前的性能测试是告诉我们给定操作的绝对耗时,但是在很多情况下,需要关注的问题是两个不同操作之间的相对耗时。比如如下的场景:

  • 如果一个函数需要1ms来处理一千个元素,那么处理一万个或者一百万个元素需要多久。这样的比较能揭示渐进增长函数的运行时间
  • I/O缓冲区要设置多大最佳。对一个应用使用一系列的大小进行基准测试,可以帮助我们选择最小的缓冲区并带来最佳的性能表现
  • 对于一个任务来讲,哪种算法表现最佳?对两个不同的算法使用相同的输入,在重要的或者具有代表性的工作负载下,进行基准测试通常可以显示出每个算法的优缺点

性能比较函数只是普通的代码,表现形式通常是带有一个参数的函数,再被多个不同的 Benchmark 函数传入不同的值来调用,比如下面这样:

func benchmark(b *testing.B, size int) { /* ... */ }
func Benchmark10(b *testing.B)         { benchmark(b, 10) }
func Benchmark100(b *testing.B)        { benchmark(b, 100) }
func Benchmark1000(b *testing.B)       { benchmark(b, 1000) }

参数 size 指定了输入的大小,每个 Benchmark 函数传入的值都不同但是在每个函数内部是一个常量。不要使用 b.N 来控制输入的大小。除非是把它当做固定大小输入的循环次数,否则基准测试的结果将毫无意义。
基准测试比较揭示的模式在程序设计阶段很有用处,但是即使程序正常工作了,也不要丢掉基准测试。随着程序的演变,或者它的输入增长了,或者它被部署在其他的操作系统上并拥有一些新特性,这时仍然可以重用基准测试来回顾当初的设计决策。

性能剖析

当希望仔细地查看程序的速度是,发现关键代码的最佳技术就是性能剖析。性能剖析是通过自动化手段在程序执行过程中基于一些性能事件的采样来进行性能评测,然后再从这些采样中推断分析,得到的统计报告就称作为性能剖析(profile)。

获取报告

Go 支持很多种性能剖析方式。其中,工具 go test 内置支持一些类别的性能剖析:

  • CPU 性能剖析
  • 堆性能剖析
  • 阻塞性能剖析

CPU 性能剖析
CPU 性能剖析识别出执行过程中需要 CPU 最多的函数。在每个 CPU 上面执行的线程都每隔几毫秒会定期地被操作系统中断,在每次中断过程中记录一个性能剖析事件,然后恢复正常执行。

堆性能剖析
堆性能剖析识别出负责分配最多内存的语句。性能剖析库对协程内部内存分配调用进行采样,平均每 512KB 的内存申请会触发一个性能剖析事件。

阻塞性能剖析
阻塞性能剖析识别出那些阻塞协程最久的操作,例如系统调用,通道发送和接收数据,以及锁等待等。性能分析库在一个 goroutine 每次被上述操作之一阻塞的时候记录一个事件。

获取性能剖析报告很容易,只需要像下面这样指定一个标志参数即可。一次只获取一种性能剖析报告,如果使用了多个标志,一种类别的报告会把其他类别的报告覆盖掉:

$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out

还可以对非测试程序进行性能剖析,性能剖析对于长时间运行的程序尤其有用。所以 Go 运行时的性能剖析特性可以通过 runtime API 来启用。

分析报告

在获取了性能剖析报告后,需要使用 pprof 工具来分析它。这是 Go 自带的一个工具,但是因为不经常使用,所以通过 go tool pprof 间接来使用它。它有很多特性和选项,但是基本的用法只有两个参数:

  • 产生性能剖析结果的可执行文件
  • 性能剖析日志

为了使得性能剖析过程高效并且节约空间,性能剖析日志里没有包含函数名称而是使用它们的地址。这就需要可执行文件才能理解理解数据内容。通常情况下 go test 工具在测试完成之后就丢弃了用于测试而临时产生的可执行文件,但在性能剖析启用的时候,它保存并把可执行文件命名为 foo.test,其中 foo 是被测试包的名字。

示例

下面的命令演示如何获取和显示简单的 CPU 性能剖析。这里选择了 net\/http 包中的一个基准测试。通常情况下最后对我们关心的具有代表性的具体负载而构建的基准测试进行性能剖析。对测试用例进行基准测试永远没有代表性,这里使用了过滤器 -run=NONE 来禁止那些测试:

F:\>go test -run=NONE -bench=ClientServerParallelTLS64 -cpuprofile=cpu.log net/http
goos: windows
goarch: amd64
pkg: net/http
BenchmarkClientServerParallelTLS64-4    2019/04/24 15:40:39 http: TLS handshake error from 127.0.0.1:55188: read tcp 127.0.0.1:55163->127.0.0.1:55188: use of closed network connection
2019/04/24 15:40:39 http: TLS handshake error from 127.0.0.1:55366: read tcp 127.0.0.1:55264->127.0.0.1:55366: use of closed network connection
2019/04/24 15:40:41 http: TLS handshake error from 127.0.0.1:57477: read tcp 127.0.0.1:57266->127.0.0.1:57477: use of closed network connection
   10000            198886 ns/op            9578 B/op        107 allocs/op
PASS
ok      net/http        3.697s

F:\>

运行完上面的测试后,会生成两个文件,一个是测试报告,一个是用于测试而临时产生的可执行文件。再用下面的命令打印测试报告:

F:\>go tool pprof -text -nodecount=10 ./http.test cpu.log
./http.test: open ./http.test: The system cannot find the file specified.
Fetched 1 source profiles out of 2
Type: cpu
Time: Apr 24, 2019 at 3:40pm (CST)
Duration: 2.71s, Total samples = 9820ms (362.69%)
Showing nodes accounting for 5720ms, 58.25% of 9820ms total
Dropped 370 nodes (cum <= 49.10ms)
Showing top 10 nodes out of 217
      flat  flat%   sum%        cum   cum%
    4220ms 42.97% 42.97%     4270ms 43.48%  runtime.cgocall
     210ms  2.14% 45.11%      260ms  2.65%  runtime.step
     200ms  2.04% 47.15%      490ms  4.99%  runtime.pcvalue
     190ms  1.93% 49.08%      190ms  1.93%  math/big.addMulVVW
     180ms  1.83% 50.92%      180ms  1.83%  runtime.osyield
     160ms  1.63% 52.55%      320ms  3.26%  runtime.scanobject
     160ms  1.63% 54.18%      160ms  1.63%  vendor/golang_org/x/crypto/curve25519.ladderstep
     150ms  1.53% 55.70%      150ms  1.53%  runtime.findObject
     140ms  1.43% 57.13%      140ms  1.43%  runtime.memmove
     110ms  1.12% 58.25%     1020ms 10.39%  runtime.gentraceback

F:\>

标记 -text 指定输出的格式,这里用的是一个文本表格,表格中每行是一个函数,这些函数是根据消耗CPU最多的规则排序的“热函数”。
标记 -nodecount=10 限制输出最高的10条记录。

这里是一份书上的性能剖析结果:

$ go tool pprof -text -nodecount=10 ./http.test cpu.log
2570ms of 3590ms total (71.59%)
Dropped 129 nodes (cum <= 17.95ms)
Showing top 10 nodes out of 166 (cum >= 60ms)
    flat  flat%   sum%     cum   cum%
  1730ms 48.19% 48.19%  1750ms 48.75%  crypto/elliptic.p256ReduceDegree
   230ms  6.41% 54.60%   250ms  6.96%  crypto/elliptic.p256Diff
   120ms  3.34% 57.94%   120ms  3.34%  math/big.addMulVVW
   110ms  3.06% 61.00%   110ms  3.06%  syscall.Syscall
    90ms  2.51% 63.51%  1130ms 31.48%  crypto/elliptic.p256Square
    70ms  1.95% 65.46%   120ms  3.34%  runtime.scanobject
    60ms  1.67% 67.13%   830ms 23.12%  crypto/elliptic.p256Mul
    60ms  1.67% 68.80%   190ms  5.29%  math/big.nat.montgomery
    50ms  1.39% 70.19%    50ms  1.39%  crypto/elliptic.p256ReduceCarry
    50ms  1.39% 71.59%    60ms  1.67%  crypto/elliptic.p256Sum

这个性能剖析结果告诉我们,HTTPS基准测试中 crypto\/elliptic.p256ReduceDegree 函数占用了将近一半的CPU资源,对性能占很大比重。
相比之下,上面的性能剖析结果中,主要是runtime包的内存分配的函数,那么减少内存消耗是一个有价值的优化。

对于更微妙的问题,最好使用 pprof 的图形显示功能。这需要 GraphViz 工具,可以从 http://www.graphviz.org 下载。然后使用标记 -web 生成函数的有向图,并能标记出函数的CPU消耗数值,以及有颜色突出“热函数”。点到为止,未展开。

Example 函数

这是第三种也是最后一种测试函数,示例函数。名字以 Example 开头,既没有参数,也没有返回值。
下面是IsPalindrome函数对应的示例函数:

func ExampleIsPalindrome() {
    fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
    fmt.Println(IsPalindrome("palindrome"))
    // Output:
    // true
    // false
}

示例函数有三个目的:

  • 用作文档
  • 作为可执行测试
  • 提供一个真实的演练场

用作文档

比起乏味的描述,举一个好的例子是描述库函数功能最简洁直观的方式。
基于 Example 函数的后缀,基于 Web 的文档服务器 godoc 可以将示例函数(比如:ExampleIsPalindrome)和它所演示的函数或包(比如:IsPalindrome函数),关联起来。
如果是一个名字叫 Example 的函数,那么就会和包的文档关联。

作为可执行测试

示例函数是可以通过 go test 运行的可执行测试。示例函数的最后如果有一段类型 // Output: 的注释,就像上面的例子里一样。测试驱动程序将会执行这个函数并且检查输出到终端的内容与注释是否匹配。

提供一个真实的演练场

http://golang.org 就是由 godoc 提供的文档服务,它使用 Go Playground 来让用户在 Web 浏览器上编辑和运行每个示例函数。这可以作为了解特定函数功能或者了解语言特性最快捷的方法。

以上是关于gopl 测试的主要内容,如果未能解决你的问题,请参考以下文章

gopls替换hover文档

gopl 方法和接口

安装gopls

gopl goroutine 和通道

gopl 反射1

gopls requires a module at the root of your workspace.You can work with multiple modules by opening(