go的测试方案

单元测试

编写测试

编写测试和函数很类似,其中有一些规则

  • 程序需要在一个名为xxx_test.go的文件中编写
  • 测试函数的命名必须以单词Test开始
  • 测试函数只接受一个参数t *testing.T

类型为*testing.T的变量t是测试框架中的hook(钩子),使用这个可以在测试用例中添加相关的操作,例如失败可以执行t.Fail()

举例说明,测试最简单的helloworld方法

hello.go

func Hello() string {
    return "Hello, world"
}

hello_test.go

func TestHello(t *testing.T) {
    got := Hello()
    want := "Hello, world"

    if got != want {
        t.Errorf("got '%q' want '%q'", got, want)
    }
}

子测试例子

func TestHello(t *testing.T) {

    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"

        if got != want {
            t.Errorf("got '%q' want '%q'", got, want)
        }
    })

    t.Run("say hello world when an empty string is supplied", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World"

        if got != want {
            t.Errorf("got '%q' want '%q'", got, want)
        }
    })
}

这个测试用例说明Hello函数需要有字符串参数,并且当空字符串时返回Hello, World,当字符串非空时返回Hello, 加字符串。

可以对断言进行重构,变成函数提高可读性,在Go中,可以在其他函数中声明函数,并将它们分给变量。 像调用普通函数一样调用它们,即是断言重构,断言重构例子:

func TestHello(t *testing.T) {
    assertCorrectMessage := func(t *testing.T, got, want string) {
        t.Helper()
        if got != want {
            t.Errorf("got '%q' want '%q'", got, want)
        }
    }

    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"
        assertCorrectMessage(t, got, want)
    })

    t.Run("empty string defaults to 'world'", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World"
        assertCorrectMessage(t, got, want)
    })
}

将断言重构为函数可以减少重复,并提高测试的可读性。t.Helper()告诉测试这个方法是辅助函数, 这样,当失败时,测试失败时所报告的行号将在函数调用中而不是在这个辅助函数内部。

编写示例

Go示例代码执行起来就像测试一样,所以可以认为示例反映出的是代码的实际功能。

作为包的测试套件的一部分,示例会被编译并可以选择性的执行。

示例也存在于一个包的_test.go文件的函数中。例如

func ExampleAdd() {
    sum := Add(1, 5)
    fmt.Println(sum)
    // Output: 6
}

当删除注释Output行时,示例函数虽然被编译,但不会执行。

通过添加这段代码,示例将出现在godoc的文档中,将使代码更容易理解。

可以通过运行godoc -http=:6060来访问所有包的文档中找到示例。

Mocking

假设存在一个测试,耗时4秒钟,如果大量运行这类测试用例会破坏开发人员的生产力。

例如,有一个程序,从3开始依次递减向下,当到0时打印GO!并退出,要求每次打印从新的一行开始 且打印间隔1秒钟。

3
2
1
GO!

代码如下

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

如果不使用mock的话,测试用例:

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}

每次输出将间隔3秒钟。这是可以采用Mock技术,就是模拟time.Sleep,通过依赖注入 的方式来代替真正的time.Sleep,然后通过断言来监视调用。

将依赖关系定义为一个接口,只有在实际使用Countdown时使用真实的sleeper,测试时使用spysleeper。同时考虑时间间隔,上边的测试并没有反应每次输出时间隔3秒, 比如说实际程序并没有sleep动作,或者只有行有sleep动作,一样可以通过测试, 如果要反应真实的执行顺序,需要打印出每一个动作的,例如:

  1. Sleep
  2. Print N
  3. Sleep
  4. Print N-1
  5. Sleep
  6. ...

代码示例:

主程序中定义Sleeper接口,并定义实际使用的接口实现

type Sleeper interface {
	Sleep()
}

type ConfigurableSleeper struct {
	duration time.Duration
	sleep    func(time.Duration)
}

func (c *ConfigurableSleeper) Sleep() {
	c.sleep(c.duration)
}

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer, sleeper Sleeper) {
	for i := countdownStart; i > 0; i-- {
		sleeper.Sleep()
		fmt.Fprintln(out, i)
	}

	sleeper.Sleep()
	fmt.Fprint(out, finalWord)
}

func main() {
	sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
	Countdown(os.Stdout, sleeper)
}

测试用例,采用mock技术来实现Sleeper接口,同时每个调用都打印各自的调用动作。

type CountdownOperationsSpy struct {
	Calls []string
}

func (s *CountdownOperationsSpy) Sleep() {
	s.Calls = append(s.Calls, sleep)
}

func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
	s.Calls = append(s.Calls, write)
	return
}

const write = "write"
const sleep = "sleep"

type SpyTime struct {
	durationSlept time.Duration
}

func (s *SpyTime) Sleep(duration time.Duration) {
	s.durationSlept = duration
}

func TestCountdown(t *testing.T) {

	t.Run("prints 3 to Go!", func(t *testing.T) {
		buffer := &bytes.Buffer{}
		Countdown(buffer, &CountdownOperationsSpy{})

		got := buffer.String()
		want := `3
2
1
Go!`

		if got != want {
			t.Errorf("got %q want %q", got, want)
		}
	})

	t.Run("sleep before every print", func(t *testing.T) {
		spySleepPrinter := &CountdownOperationsSpy{}
		Countdown(spySleepPrinter, spySleepPrinter)

		want := []string{
			sleep,
			write,
			sleep,
			write,
			sleep,
			write,
			sleep,
			write,
		}

		if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
			t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
		}
	})
}

func TestConfigurableSleeper(t *testing.T) {
	sleepTime := 5 * time.Second

	spyTime := &SpyTime{}
	sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}
	sleeper.Sleep()

	if spyTime.durationSlept != sleepTime {
		t.Errorf("should have slept for %v but slept for %v", sleepTime, spyTime.durationSlept)
	}
}

mock会使的一个简单的程序变得繁复,对于简单的功能用处不大,但对于一些比较复杂的操作, 有着必要性。

在使用时需要注意:

  • 在测试时想要的是行为还是实现细节
  • 如果要重构,需要做多少修改
  • 一个测试需要经过多少次模拟动作,如果超过3个,就需要重新考虑设计

没有对代码中重要区域进行mock可能导致难以测试,例如,调用一个可能失败的服务,怎么在 特定状态下测试系统。

*** 并发测试

代码如下:

import (
    "time"
)

type WebsiteChecker func(string) bool

func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
    results := make(map[string]bool)

    for _, url := range urls {
        go func(u string) {
            results[u] = wc(u)
        }(url)
    }

    time.Sleep(2 * time.Second)

    return results
}

对于存在并发的方法,可以使用benchmark来进行测试,既可以测试方法效率又可以查看是否 存在并发问题:

func slowStubWebsiteChecker(_ string) bool {
    time.Sleep(20 * time.Millisecond)
    return true
}

func BenchmarkCheckWebsites(b *testing.B) {
    urls := make([]string, 100)
    for i := 0; i < len(urls); i++ {
        urls[i] = "a url"
    }

    for i := 0; i < b.N; i++ {
        CheckWebsites(slowStubWebsiteChecker, urls)
    }
}

基准测试使用一百个网址的slice来对CheckWebsites进行测试, 并使用WebsiteChecker的模拟实现,故意放慢速度到20毫秒。

这时,运行go test -bench=./..将会出现并发问题。

这个问题可以使用race标志来运行测试,go test -race来找到错误。

可以看到对应同一内存地址,进行了同时写入。

对于这个例子可以使用chan来解决。

race同时也可以用在运行时的并发检测,如go run -race来运行。

对于上边的测试用例,比较的都是字符串,如果比较结构体等,需要使用反射进行深层比较, 就是reflect.DeepEqual方法。同时针对单元测试应该保证有效代码的测试覆盖率。

go-fuzz

go-fuzz是Go语言的随机测试工具,是覆盖驱动的fuzzing解决方案。Fuzzing主要是应用在 解析复杂输入的包(文本或者二进制)。模糊测试是一项使用随机数据加载程序的测试技术, 是对常规测试的补充,并且使开发者可以发现那些在手工生成的输入下难以发现的bug。 模糊测试在Go程序中很容易设置,并且可以适应于几乎所有类型的代码。

数据库的SQL输入就是这样的一种复杂输入,使用go-fuzz可以比较全面的发现解析器可能 存在的问题。

在go中由两个工具包适用于模糊测试:

二者用途不同:

  • gofuzz 提供了一个可以用随机值填充你的 Go 结构体的包。而你需要做的是编写测试代码,并且调用这个包来获取随机数据。 当你想要模糊测试结构化数据的时候,这个包是完美的。这里是使用随机数据对一个结构体进行 50000 次模糊测试的例子, 其中指针/切片/map有50%的几率被设置为空
  • go-fuzz 基于已经在大多数知名的软件或库中发现了上百个bug的 American Fuzzy Lop。 Go-Fuzz 会连续运行,并且根据提供的样本生成随机的字符串。之后必须解析这些字符串, 并且明确地将其标记为是否可用于测试。任何有趣的生成的数据都会被该工具所报告, 这些数据增加了代码的覆盖率或者导致崩溃。该工具十分适合那些管理诸如 XML,JSON, 图像等字符串信息的程序。这里是该工具运行以及发行问题的预览,被称为 crasher。

数据库测试体系

参考sqlite的testing

测试工具

TCL脚本语言测试,可以应对大部分的交互式测试。

SQL逻辑测试,可以参考sqlite的逻辑测试

sqlfuzz,参考上边的go-fuzz工具。

异常测试

异常测试目的在于出现问题时验证数据库正确行为的测试。一个数据库在正常情况下可以正常的运行,并输出格式正确的结果。 但需要对无效输入做出理性响应并在系统故障时进行异常测试验证。

内存不足测试

数据库对于内存回收异常重要,引发的OOM将是灾难性的。需要通过模拟OOM错误来完成内存不足测试。 如果内存分配失败时如何处理。

I/O错误测试

验证数据库是否对失败的I/O操作做出合理响应。例如磁盘驱动器已满,磁盘故障,使用网络文件系统时网络中断,系统配置或权限更改等等一起的故障。 是否都能做到正确的响应。

崩溃测试

崩溃测试目的在证明如果应用或操作系统崩溃,或者数据库在运行过程中出现了电源故障等,数据库不会损坏。

崩溃测试可以在仿真情况下完成,例如使用虚拟系统。

测试方法可以是在写操作的中间随机崩溃,重新打开并读取测试数据库,验证写操作是否成功完成或已完全回滚。

复合故障测试

堆叠多个故障,运行测试确保从崩溃恢复时发生I/O或OOM故障时,数据库没有损坏,并且行为正常。

模糊测试

SQL模糊

使用American Fuzzy Lop Fuzzer的SQL Fuzz,就是go-fuzz进行模糊测试。

Google OSS模糊测试

其他模拟器。

格式错误的数据库文件

例如把数据库文件添加一个或多个字节,数据库是否可使用,是否可修复。

边界值检查

各种类型的最大长度,表的最大列数等边缘测试,验证允许的值都能正确执行,超出限制时是否正确返回错误。

回归测试

每当有针对数据库的bug被报告后,需要将bug存在的问题添加到测试用例中,回归测试可以确保不会将以前已修复的错误重新引入到数据库中。

资源泄漏测试

当分配资源后并不释放时,就会发生资源泄漏。