golang测试
go test 命令
go test 命令是一个按照一定的约定和组织来测试代码的程序。我们需要了解有哪些约定和组织:在包目录内,所有后缀为 _test.go 的源文件不会被 go build 构建命令构建成包的一部分,相反,它们会被 go test 命令使用。
在 *_test.go 文件中,有3种类型的函数:测试函数、基准(benchmark)函数、示例函数。
一个测试函数以 Test为函数名的前缀,用来测试程序的一些逻辑行为是否正确;go test 命令会调用这些测试函数并报告测试结果是 PASS 或 FAIL。
基准函数则以 Benchmark 为函数名前缀,是用来衡量一些函数的性能的;go test 命令会多次运行基准测试函数以计算一个平均的执行时间。
示例函数是以 Example 为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。
go test 命令会遍历所有的 *_test.go 文件中符合上述命名规则的函数,生成一个临时的 main包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。
测试函数
要使用测试函数,必须导入 testing 包 (一般第一个参数 t 为 *testing.T 类型,需要用来报告测试情况),而且测试函数的名字除了必须以 Test 开头,可选的后缀名必须以大写字母开头。
书上给出的例子是判断回文字符串,功能函数 IsPalindrome 和测试函数 TestPalindrome、TestNonPalindrome 都属于 package word,不过前者和后者是不同的文件中的,因为前者是 go build 用的,而后者是 go test 用的。
word.go 源码文件如下
package word
func IsPalindrome(s string) bool {
for i := range s {
if s[i] != s[len(s)-1-i] {
return false
}
}
return true
}
测试文件 word_test.go 如下
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`)
}
}
在项目目录下,运行 go test ,可以看到测试文件(这里只有1个)测试结果 PASS 或 FAIL,以及总的项目测试情况 ok 或 FAIL,项目路径和测试耗时
PS C:\Users\zime\go\src\word> go test
PASS
ok word 0.030s
很好,两个测试用例都过了。结果一个中国人发现这个函数不能把“山上人人上山”这样的汉语识别为回文,而一个美国中部用户抱怨中间有标点空格和大小写的“A man, a plan, a canal: Panama.” 也不能识别为回文。遇到用户的错误报告,良好的习惯是编写测试用例来复现用户观察到的结果,这样能更好定位要解决的问题,也能知道解决了没有。我们添加两个测试函数:TestChinesePalindrome 和 TestCanalPalindrome,而且对于长句子,我们把它放入变量 input,避免两次输入长句子。
func TestChinesePalindrome(t *testing.T) {
if !IsPalindrome("山上人人上山") {
t.Error(`IsPalindrome("山上人人上山") = false`)
}
}
func TestCanalPalindrome(t *testing.T) {
input := "A man, a plan, a canal: Panama"
if !IsPalindrome(input) {
t.Errorf(`IsPalindrome(%q) = false`, input)
}
}
再次运行测试
PS C:\Users\zime\go\src\word> go test
--- FAIL: TestChinesePalindrome (0.00s)
word_test.go:22: IsPalindrome("山上人人上山") = false
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL word 0.046s
很好,我们复现了客户的抱怨情况。先写测试用例,然后不断完善代码,可以让我们进行快速迭代,即所谓 TDD。不过这里有个问题,如果测试集有很多运行缓慢的测试,而我们只是修改了局部,反复运行测试非常耗时间,怎样避免运行所有测试函数呢?
先用 go test -v 打印出每个测试函数的名字和运行时间
PS C:\Users\zime\go\src\word> go test -v
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN TestChinesePalindrome
word_test.go:22: IsPalindrome("山上人人上山") = false
--- FAIL: TestChinesePalindrome (0.00s)
=== RUN TestCanalPalindrome
word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
--- FAIL: TestCanalPalindrome (0.00s)
FAIL
exit status 1
FAIL word 0.049s
然后用 -run 参数指定一个正则表达式来筛选需要测试的函数,即 go test -v -run="<regexp>"
C:\Users\zime\go\src\word>go test -v -run="Chinese|Canal"
=== RUN TestChinesePalindrome
word_test.go:22: IsPalindrome("山上人人上山") = false
--- FAIL: TestChinesePalindrome (0.00s)
=== RUN TestCanalPalindrome
word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
--- FAIL: TestCanalPalindrome (0.00s)
FAIL
exit status 1
FAIL word 0.106s
(这里有个小坑,带 -run 参数,不能在 windows powershell 环境下,切换到 cmd 才行。)这样,我们就只运行了2个测试用例,可以节约时间。当然,在修复 bug 后,需要运行一遍全部的测试用例,确保不会在修复 bug 中引入了新的问题。
通过分析,我们发现,第一个bug是因为我们对字符串的处理,是基于 byte,而非码点 rune序列,这样,对于非 ASCII 字符就会有问题。第二个bug则是因为我们没有忽略空格和字母的大小写等导致的。我们改成可以识别汉字回文、字母回文(忽略大小写),改写 IsPalindrome函数。
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
} else if unicode.Is(unicode.Han, r) {
letters = append(letters, r)
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
再次运行
C:\Users\zime\go\src\word>go test -v -run="Chinese|Canal"
=== RUN TestChinesePalindrome
--- PASS: TestChinesePalindrome (0.00s)
=== RUN TestCanalPalindrome
--- PASS: TestCanalPalindrome (0.00s)
PASS
ok word 0.043s
与此同时,我们知道之前我们的测试用例太少,不能很好发现问题,而且各种情况都写一个函数非常麻烦,所以,把测试数据合并成表格式的数据,然后循环中测试(只需要保留一个测试函数 TestIsPalindrome 即可,添加测试数据非常方便)
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},
{"detartrated", true},
{"A man, a plan, a canal: Panama", true},
{"山上人人上山", 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 发现全部通过即可。
在前面的测试中,一个测试数据失败,并不会停止后续测试进行,这通常符合预期。但对于某种场合,或许一个测试失败,再去测试后面的函数是无意义的(例如前者失败就会导致后者失败),这时,我们可以用 t.Fatal 和 t.Fatalf 代替 t.Error 和 t.Errorf,这样,一旦失败了就会停止测试。不过这里有一个前提,就是必须在同一个 goroutine 内部。
测试失败的信息一般形式是“f(x)=y, want z”,其中 f(x)对应失败的操作和对应的输入,y是实际运行结果,z是期望的正确结果(对于返回布尔型结果的函数,如果函数名反映了正确结果应该是什么,那么z不是必需的)。
随机测试
表格驱动的测试便于构造精心挑选的测试用例,例如考虑各种边界和极限情况。另一种测试思路是随机测试,也就是构造广泛的随机输入来测试考察函数的行为。
对于一个随机的输入,我们如何知道希望的输出结果呢?这有两种策略:1、编写对照函数,使用简单和清晰的算法,虽然效率低,但行为要和测试的函数是一致的,然后针对相同的随机输入检查两者的输出结果。其实这种手段oier很熟悉,就是死算的算法来检验那个高性能算法的正确性。2、生成的随机输入的数据遵循特定的模式,从而我们知道期望的输出的模式。这种手段其实oier也会采用,其实此时的随机是限定模式的随机。
下面的例子使用了第二种策略。
package word
import (
"math/rand"
"testing"
"time"
)
func TestIsPalindrome(t *testing.T) {
// ....
}
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)
}
}
}
上面的文件word2_test.go测试情况类似如下
sjg@sjg-PC:~/go/src/word2$ go test . -v
=== RUN TestIsPalindrome
--- PASS: TestIsPalindrome (0.00s)
=== RUN TestRandomPalindromes
word2_test.go:49: Random seed: 1710318896279145518
--- PASS: TestRandomPalindromes (0.00s)
PASS
ok word2 0.003s
测试一个命令
前面的例子都是测试包中的函数,其实 go test 也可以用来测试可执行程序(package main)。
echo.go 生成命令的代码如下
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
}
测试文件 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"},
{true, ",", []string{"a", "b", "c"}, "a b c\n"}, // NOTE: wrong expectation!
}
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)
}
}
}
测试如下:(下面结果不通过是因为有一行故意错误设置的测试数据,去掉该行就通过了)
sjg@sjg-PC:~/go/src/echo$ go test .
--- FAIL: TestEcho (0.00s)
echo _test.go:34: echo(true, ",", ["a" "b" "c"]) = "a,b,c\n", want "a b c\n"
FAIL
FAIL echo 0.001s
FAIL
白盒测试
前面的随机测试,是一种黑盒测试,被测试函数是导出的函数。测试命令中的的例子是白盒测试,因为 echo函数是未导出的内部函数。这个白盒测试中,输出 out 在前一个测试用例完成后是生成新的对象。在白盒测试时,有时需要容易测试的伪对象,方便配置,结果容易预测,更可靠,适合观察,例如涉及数据库更新、发送邮件、信用卡消费行为等场合。
下面的代码演示为用户提供网络存储的web服务当中的配额检查:当用户使用了超过90%的存储配额之后将发送提醒邮件。
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 = `警告:您已使用 %d 字节,占您存储配额的 %d%% 。`
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 函数中:
const template = `警告:您已使用 %d 字节,占您存储配额的 %d%% 。`
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)
}
上面代码的重点是notifyUser函数不是常见的函数定义方式,而是“函数指针”形式,这样它就是一个包内共享的变量(“包范围全局变量”),其他地方可以覆盖它的值让“函数指针”指向其他执行代码。现在就可以在测试中用伪邮件发送函数代替真实的邮件发送函数了:
package storage
import (
"strings"
"testing"
)
func TestCheckQuotaNotifiesUser(t *testing.T) {
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)
}
}
替换邮件发送函数的伪发送函数仅仅是记录了一下谁发送的,发送了什么信息。测试中可以打印这些信息。
上面的“函数指针”替换能帮助我们绕过麻烦的邮件发送过程,但这个替换也存在一定问题:当测试函数返回后, CheckQuota将不能正常工作,因为 notifyUsers 使用的还是测试函数中的伪函数。为了杜绝这个问题,最好的做法是测试完了恢复“函数指针”,使用golang的defer执行来恢复是一个好办法。
func TestCheckQuotaNotifiesUser(t *testing.T) {
savedUser := notifyUser
defer func() {
notifyUser = savedUser
}()
var notifiedUser, notifiedMsg string
notifyUser = func(user, msg string) { // 注意:“函数指针”值覆盖
notifiedUser, notifiedMsg = user, msg
}
// 其余代码
}
上面这种全局变量暂存,被修改,defer恢复的模式是安全的,因为go test命令不会同时并发地执行多个测试。
外部测试包
上面的例子,我们功能部分storage.go文件隶属于package storage,而测试代码storage_test.go文件也隶属于同一个package storage。这样做多数时候没有问题,但我们更常见的情况是测试代码隶属于package xxx_test,这是为什么?
在 golang 中有这样两个包:net/url 和 net/http。如果net/url包中存在一个测试,用来演示不同URL和HTTP客户端的交互行为,这时就会存在问题:本身上层的 net/http包依赖下层的 net/url包,而现在测试代码中要求下层的 net/url 使用到上层的 net/http包,就产生了循环依赖问题,而golang是禁止循环依赖的。
解决这个问题的办法就是package xxx_test形式的外部测试包:让测试代码在 net/url_test 包中(它和 net/url包在同一个目录也是可以的,而且一般也放在同一目录),然后 net/url_test包同时依赖(引用)net/http 和 net/url,这样就解除了循环依赖。在集成测试中,更可能遇到需要外部测试包的情况。
要了解目录下哪些golang源文件是产品代码,哪些是包内测试,哪些是外部测试包,可以用go list命令。例如, go list -f={{.GoFiles}} 包名 就可以查看产品代码对应的源文件列表,也就是 go build时需要编译的部分。
外部测试包提高了灵活性(它是独立的,可以“随便import”),但它的外部性也会存在一个问题:外部测试包的确需要访问被测试包内部的代码(即未导出的函数、变量),但测试函数已经和它们不在同一个包下了,失去了访问权限。解决这个问题需要一点点技巧:在包内加一个名称类似 export_test.go 的文件,在该文件中导出一个内部的实现给外部测试包。例如,package fmt内部是 isSpace 函数,在 export_test.go 文件中放入如下代码给外部测试留个后门:
package fmt
var IsSpace = isSpace
测试覆盖率
这里先来关注“接口”一章中的表达式求值例子。先创建接口 Expr 表示 golang 中的任意表达式(暂时它没有任何方法,后面再根据表达式的公共特点来定义接口应该具备的方法)
package eval
type Expr interface{}
type Var string
type literal float64
type unary struct {
op rune // one of '+', '-'
x Expr
}
type binary struct {
op rune // one of '+', '-', '*', '/'
x, y Expr
}
type call struct {
fn string // one of "pow", "sin", "sqrt"
args []Expr
}
我们这里为了演示,作了简化:“表达式语言”包括浮点数(即小数点符号表示的字面值);二元操作符+、-、*、/;表示正负的一元操作符-、+;调用函数,如 pow(x,y)、sin(x)、sqrt(x);变量,如x、pi;当然也有括号和标准的优先级运算符。所有的值都是 float64类型。上面的 ast.go 文件包括了有关类型。为了计算包含变量的表达式,我们需要将变量的名字(字符串)映射成对应的值(float64),在 eval.go文件中包含如下代码定义环境变量类型Env:
type Env map[Var]float64
不管什么样的表达式,都要能利用 Env类型的映射表来计算值,所以,Expr接口中添加 Eval方法(包 eval 只导出 Expr、Env、Var类型,调用方不需要使用具体的表达式类型):
type Expr interface {
Eval(env Env) float64
}
我们来看具体的表达式类型如何计算值,即它们实现接口方法的情况:
type Env map[Var]float64
func (v Var) Eval(env Env) float64 {
return env[v]
}
func (l literal) Eval(_ Env) float64 {
return float64(l)
}
func (u unary) Eval(env Env) float64 {
switch u.op {
case '+':
return +u.x.Eval(env)
case '-':
return -u.x.Eval(env)
}
panic(fmt.Sprintf("unsupported unary operator: %q", u.op))
}
func (b binary) Eval(env Env) float64 {
switch b.op {
case '+':
return b.x.Eval(env) + b.y.Eval(env)
case '-':
return b.x.Eval(env) - b.y.Eval(env)
case '*':
return b.x.Eval(env) * b.y.Eval(env)
case '/':
return b.x.Eval(env) / b.y.Eval(env)
}
panic(fmt.Sprintf("unsupported binary operator: %q", b.op))
}
func (c call) Eval(env Env) float64 {
switch c.fn {
case "pow":
return math.Pow(c.args[0].Eval(env), c.args[1].Eval(env))
case "sin":
return math.Sin(c.args[0].Eval(env))
case "sqrt":
return math.Sqrt(c.args[0].Eval(env))
}
panic(fmt.Sprintf("unsupported function call: %s", c.fn))
}
要进行具体的表达式计算,有一步工作是把表达式解析切割成一个个token,分类到上面的各种求值类型中。下面的 parse.go 做的是这个工作(非常类似前面的S表达式解码工作),编译器相关的东西都比较复杂,下面仅列出代码:
package eval
import (
"fmt"
"strconv"
"strings"
"text/scanner"
)
type lexer struct {
scan scanner.Scanner
token rune // current lookahead token
}
func (lex *lexer) next() { lex.token = lex.scan.Scan() }
func (lex *lexer) text() string { return lex.scan.TokenText() }
type lexPanic string
func (lex *lexer) describe() string {
switch lex.token {
case scanner.EOF:
return "end of file"
case scanner.Ident:
return fmt.Sprintf("identifier %s", lex.text())
case scanner.Int, scanner.Float:
return fmt.Sprintf("number %s", lex.text())
}
return fmt.Sprintf("%q", rune(lex.token)) // any other rune
}
func precedence(op rune) int {
switch op {
case '*', '/':
return 2
case '+', '-':
return 1
}
return 0
}
func Parse(input string) (_ Expr, err error) {
defer func() {
switch x := recover().(type) {
case nil:
// no panic
case lexPanic:
err = fmt.Errorf("%s", x)
default:
// unexpected panic: resume state of panic.
panic(x)
}
}()
lex := new(lexer)
lex.scan.Init(strings.NewReader(input))
lex.scan.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanFloats
lex.next() // initial lookahead
e := parseExpr(lex)
if lex.token != scanner.EOF {
return nil, fmt.Errorf("unexpected %s", lex.describe())
}
return e, nil
}
func parseExpr(lex *lexer) Expr { return parseBinary(lex, 1) }
func parseBinary(lex *lexer, prec1 int) Expr {
lhs := parseUnary(lex)
for prec := precedence(lex.token); prec >= prec1; prec-- {
for precedence(lex.token) == prec {
op := lex.token
lex.next() // consume operator
rhs := parseBinary(lex, prec+1)
lhs = binary{op, lhs, rhs}
}
}
return lhs
}
func parseUnary(lex *lexer) Expr {
if lex.token == '+' || lex.token == '-' {
op := lex.token
lex.next() // consume '+' or '-'
return unary{op, parseUnary(lex)}
}
return parsePrimary(lex)
}
func parsePrimary(lex *lexer) Expr {
switch lex.token {
case scanner.Ident:
id := lex.text()
lex.next() // consume Ident
if lex.token != '(' {
return Var(id)
}
lex.next() // consume '('
var args []Expr
if lex.token != ')' {
for {
args = append(args, parseExpr(lex))
if lex.token != ',' {
break
}
lex.next() // consume ','
}
if lex.token != ')' {
msg := fmt.Sprintf("got %s, want ')'", lex.describe())
panic(lexPanic(msg))
}
}
lex.next() // consume ')'
return call{id, args}
case scanner.Int, scanner.Float:
f, err := strconv.ParseFloat(lex.text(), 64)
if err != nil {
panic(lexPanic(err.Error()))
}
lex.next() // consume number
return literal(f)
case '(':
lex.next() // consume '('
e := parseExpr(lex)
if lex.token != ')' {
msg := fmt.Sprintf("got %s, want ')'", lex.describe())
panic(lexPanic(msg))
}
lex.next() // consume ')'
return e
}
msg := fmt.Sprintf("unexpected %s", lex.describe())
panic(lexPanic(msg))
}
我们来测试一下(eval_test.go文件):
package eval
import (
"fmt"
"math"
"testing"
)
func TestEval(t *testing.T) {
tests := []struct {
expr string
env Env
want string
}{
{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 12, "y": 1}, "1729"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
{"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
{"5 / 9 * (F - 32)", Env{"F": 32}, "0"},
{"5 / 9 * (F - 32)", Env{"F": 212}, "100"},
}
var prevExpr string
for _, test := range tests {
// Print expr only when it changes.
if test.expr != prevExpr {
fmt.Printf("\n%s\n", test.expr)
prevExpr = test.expr
}
expr, err := Parse(test.expr)
if err != nil {
t.Error(err) // parse error
continue
}
got := fmt.Sprintf("%.6g", expr.Eval(test.env))
fmt.Printf("\t%v => %s\n", test.env, got)
if got != test.want {
t.Errorf("%s.Eval() in %v = %q, want %q\n",
test.expr, test.env, got, test.want)
}
}
}
测试结果:
zime@zime-virtual-machine:~/go/src/ch7eval$ go test -v .
=== RUN TestEval
sqrt(A / pi)
map[A:87616 pi:3.141592653589793] => 167
pow(x, 3) + pow(y, 3)
map[x:12 y:1] => 1729
map[x:9 y:10] => 1729
5 / 9 * (F - 32)
map[F:-40] => -40
map[F:32] => 0
map[F:212] => 100
--- PASS: TestEval (0.00s)
PASS
ok ch7eval 0.004s
上面的测试中给出的都是合法的表达式,我们下面给 Expr接口增加一个Check方法来对表达式语义树进行静态检查:
type Expr interface {
Eval(env Env) float64
Check(vars map[Var]bool) error
}
新建 check.go 文件实现接口方法:
package eval
import (
"fmt"
"strings"
)
func (v Var) Check(vars map[Var]bool) error {
vars[v] = true
return nil
}
func (literal) Check(vars map[Var]bool) error {
return nil
}
func (u unary) Check(vars map[Var]bool) error {
if !strings.ContainsRune("+-", u.op) {
return fmt.Errorf("unexpected unary op %q", u.op)
}
return u.x.Check(vars)
}
func (b binary) Check(vars map[Var]bool) error {
if !strings.ContainsRune("+-*/", b.op) {
return fmt.Errorf("unexpected binary op %q", b.op)
}
if err := b.x.Check(vars); err != nil {
return err
}
return b.y.Check(vars)
}
func (c call) Check(vars map[Var]bool) error {
arity, ok := numParams[c.fn]
if !ok {
return fmt.Errorf("unknown function %q", c.fn)
}
if len(c.args) != arity {
return fmt.Errorf("call to %s has %d args, want %d",
c.fn, len(c.args), arity)
}
for _, arg := range c.args {
if err := arg.Check(vars); err != nil {
return err
}
}
return nil
}
var numParams = map[string]int{"pow": 2, "sin": 1, "sqrt": 1}
前面的测试相当于单元测试,可以验证模块的基本功能。下面的是覆盖率测试(coverage.go)。测试覆盖率的意义是,虽然它并不能检测出所有bug,但足够完善的覆盖率测试可以增强我们对程序可靠性的信心。
package eval
import (
"fmt"
"math"
"testing"
)
func TestCoverage(t *testing.T) {
var tests = []struct {
input string
env Env
want string // expected error from Parse/Check or result from Eval
}{
{"x % 2", nil, "unexpected '%'"},
{"!true", nil, "unexpected '!'"},
{"log(10)", nil, `unknown function "log"`},
{"sqrt(1, 2)", nil, "call to sqrt has 2 args, want 1"},
{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
{"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
}
for _, test := range tests {
expr, err := Parse(test.input)
if err == nil {
err = expr.Check(map[Var]bool{})
}
if err != nil {
if err.Error() != test.want {
t.Errorf("%s: got %q, want %q", test.input, err, test.want)
}
continue
}
got := fmt.Sprintf("%.6g", expr.Eval(test.env))
if got != test.want {
t.Errorf("%s: %v => %s, want %s",
test.input, test.env, got, test.want)
}
}
}
先确保所有的测试都可以通过:下面指定要运行的测试函数为 TestCoverage
zime@zime-virtual-machine:~/go/src/ch7eval$ go test -v -run=Coverage .
=== RUN TestCoverage
--- PASS: TestCoverage (0.00s)
PASS
ok ch7eval 0.002s
要了解测试覆盖率工具的用法,可以 go tool cover 显示帮助信息。下面的命令用了 -coverprofile 标志参数,它通过在测试代码中插入生成钩子来统计覆盖率数据,即在运行每个测试前,它将待测试代码拷贝一份并做修改,在每个词法块都会设置一个布尔标志变量。当被修改后的被测代码运行退出时,将统计日志数据写入 c.out 文件,然后输出一个总结信息:
zime@zime-virtual-machine:~/go/src/ch7eval$ go test -run=Coverage -coverprofile=c.out .
ok ch7eval 0.003s coverage: 76.4% of statements
可以用下面的 go tool cover 命令解读上述统计日志文件(将自动打开浏览器显示结果):
zime@zime-virtual-machine:~/go/src/ch7eval$ go tool cover -html=c.out
上图中,可以选择显示不同文件的测试覆盖情况。代码中绿色的表示测试覆盖到了,红色的则表示没有被覆盖到。这样,我们很容易添加测试来增加覆盖面(panic语句没有被执行到通常是合理的),但100%的测试覆盖率通常在实践中不可行。
如果我们额外加上 -covermode=count 标志参数,那么每个代码块中插入的不是布尔变量而是一个计数器。这样的话,统计日志中不仅可以知道代码块被执行了没有,还能知道哪些是被频繁执行的热点代码(从灰色到绿色,覆盖度递增):
zime@zime-virtual-machine:~/go/src/ch7eval$ go test -run=Coverage -covermode=count -coverprofile=c2.out .
ok ch7eval 0.003s coverage: 76.4% of statements
zime@zime-virtual-machine:~/go/src/ch7eval$ go tool cover -html=c2.out
基准测试
基准测试相当于性能测试。golang中基准测试函数写法和普通测试函数类似,只是前缀是Benchmark,带一个 *testing.B 类型的参数。
package word
import "unicode"
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
} else if unicode.Is(unicode.Han, r) {
letters = append(letters, r)
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
上面的例子测试回文检测函数的性能。测试命令中 -bench 用正则方式指定要测试的函数,. 表示所有基准测试函数:
zime@zime-virtual-machine:~/go/src/ch11word$ go test -bench=.
goos: linux
goarch: amd64
pkg: ch11word
cpu: Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz
BenchmarkIsPalindrome 3811491 317.2 ns/op
PASS
ok ch11word 1.534s
在基准测试后,我们有时候会想到能否优化算法和程序,例如将 for i := range letters { 改写成 for i := 0; i < len(letters)/2; i++ {,但是,我们再次运行测试可以发现改善非常有限(离改善一半时间差很远),这样的优化没有多少意义。而且,有些场合,收益很低的优化反而导致代码复杂性增加不少。
zime@zime-virtual-machine:~/go/src/ch11word$ go test -bench=.
goos: linux
goarch: amd64
pkg: ch11word
cpu: Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz
BenchmarkIsPalindrome 3324339 304.3 ns/op
PASS
ok ch11word 1.382s
我们来进行另一种形式的优化:预先分配一个足够大的数组,从而后续append调用就不会反复内存分配了
func IsPalindrome(s string) bool {
letters := make([]rune, 0, len(s))
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
} else if unicode.Is(unicode.Han, r) {
letters = append(letters, r)
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
zime@zime-virtual-machine:~/go/src/ch11word$ go test -bench=.
goos: linux
goarch: amd64
pkg: ch11word
cpu: Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz
BenchmarkIsPalindrome 6103443 178.7 ns/op
PASS
ok ch11word 1.295s
可以发现,这个改进性能提升明显。用 -benchmem标志参数可以输出内存分配情况:
zime@zime-virtual-machine:~/go/src/ch11word$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: ch11word
cpu: Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz
BenchmarkIsPalindrome 3321108 327.5 ns/op 248 B/op 5 allocs/op
PASS
ok ch11word 1.459s
zime@zime-virtual-machine:~/go/src/ch11word$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: ch11word
cpu: Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz
BenchmarkIsPalindrome 5932233 183.9 ns/op 128 B/op 1 allocs/op
PASS
ok ch11word 1.301s
上面是优化前后的内存分配情况。
基准测试的结果是和数据规模有关的,下面的比较型的基准测试可以用于比较不同数量级的基准测试:
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) }
剖析
基准测试往往是针对模块中的函数,是对特定操作的性能的衡量。我们希望整个程序运行得更快,但常常不知道从哪里开始优化——知道了瓶颈的大概位置再进行基准测试可能是准确的。性能剖析是从更宏观角度去观察程序的运行速度。
zime@zime-virtual-machine:~/go/src/ch11word$ go test -run=NONE -bench=. -cpuprofile=cpu.out -memprofile=mem.out -blockprofile=block.out
上述命令会生成cpu剖析(最耗cpu时间的函数)、堆剖析(最耗内存的语句)、阻塞剖析(阻塞goroutine最久的操作)到相应文件(实际最好不要同时生成几个剖析文件,因为生成其中一个的分析操作可能影响另一个),还有 *.test 文件。接下来用 go tool pprof 命令解析性能文件:
zime@zime-virtual-machine:~/go/src/ch11word$ go tool pprof -text -nodecount=10 ./ch11word.test cpu.out
File: ch11word.test
Type: cpu
Time: Mar 26, 2024 at 2:29pm (CST)
Duration: 1.65s, Total samples = 1450ms (87.66%)
Showing nodes accounting for 1150ms, 79.31% of 1450ms total
Showing top 10 nodes out of 68
flat flat% sum% cum cum%
350ms 24.14% 24.14% 1430ms 98.62% ch11word.IsPalindrome
210ms 14.48% 38.62% 620ms 42.76% runtime.mallocgc
180ms 12.41% 51.03% 920ms 63.45% runtime.growslice
140ms 9.66% 60.69% 140ms 9.66% unicode.ToLower
70ms 4.83% 65.52% 70ms 4.83% runtime.nextFreeFast (inline)
60ms 4.14% 69.66% 60ms 4.14% runtime.memclrNoHeapPointers
50ms 3.45% 73.10% 50ms 3.45% runtime.scanblock
30ms 2.07% 75.17% 30ms 2.07% runtime.(*mspan).base (inline)
30ms 2.07% 77.24% 120ms 8.28% runtime.gcmarknewobject
30ms 2.07% 79.31% 30ms 2.07% runtime.markBits.setMarked (inline)
借助 GraphViz, pprof 可以图形化显示性能分析结果,这对复杂的性能问题更适用,因为上面那样的 -text 格式已经能帮助我们知道大部分问题。golang官方博客“Profiling Go Programs”有详细说明。
示例函数
示例函数也是被 go test 特殊对待的函数,函数名用 Example 作为前缀。示例函数没有函数参数和返回值,有点像是给人演示用法的main函数。
func ExampleIsPalindrome() {
fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
fmt.Println(IsPalindrome("palindrome"))
// Output:
// true
// false
}
zime@zime-virtual-machine:~/go/src/ch11word$ go test -run=IsPalindrome -v
=== RUN TestIsPalindrome
--- PASS: TestIsPalindrome (0.00s)
=== RUN ExampleIsPalindrome
--- PASS: ExampleIsPalindrome (0.00s)
PASS
ok ch11word 0.002s
zime@zime-virtual-machine:~/go/src/ch11word$ go test -run=ExampleIsPalindrome -v
=== RUN ExampleIsPalindrome
--- PASS: ExampleIsPalindrome (0.00s)
PASS
ok ch11word 0.002s
需要注意的是:ExampleXxx 函数中的 // Output: 格式的注释后的注释行对应了前面部分的标准输出,即注释中就包含了用来核对的输出结果(必须格式对应)。如果没有这个注释部分,示例函数不会被执行(在GoLand IDE中表示运行的绿色三角形会消失)。