Go单元测试

如何写好单元测试

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。你或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。

如何写好单元测试呢?

首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock

然后,写可测试的代码。高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。

接下来将介绍如何使用 Go 语言的标准库 testing 进行单元测试。

一个简单的例子

package example

import "testing"

func TestAdd(t *testing.T) {
   if ans := Add(1, 2); ans != 3 {
      t.Errorf("1 + 2 expected be 3, but %d got", ans)
   }
   if ans := Add(-10, -20); ans != -30 {
      t.Errorf("-10 + -20 expected be -30, but %d got", ans)
   }
}
  • 测试用例名称一般命名为Test加上待测试方法名
  • 测试用的参数有且仅有一个,在这里是t *testing.T
  • 基准测试的参数是 * testing.B,* TestMain的参数是* testing.M类习惯

子测试

子测试是 Go 语言内置支持的,可以在某个测试用例中,根据测试场景使用 t.Run创建不同的子测试用例:

func TestMul(t *testing.T) {
   t.Run("pos", func(t *testing.T) {
      if Mul(2, 3) != 6 {
         t.Fatal("fail")
      }
   })
   t.Run("neg", func(t *testing.T) {
      if Mul(2, -3) != -6 {
         t.Fatal("fail")
      }
   })
}
  • 之前的例子测试失败时使用 t.Error/t.Errorf,这个例子中使用 t.Fatal/t.Fatalf,区别在于前者遇错不停,还会继续执行其他的测试用例,后者遇错即停。

对于多个子测试的场景,更推荐如下的写法(table-driven tests):

func TestMul(t *testing.T) {
   cases := []struct {
      Name           string
      A, B, Expected int
   }{
      {"pos", 2, 3, 6},
      {"neg", 2, -3, -6},
      {"zero", 2, 0, 0},
   }

   for _, c := range cases {
      t.Run(c.Name, func(t *testing.T) {
         if ans := Mul(c.A, c.B); ans != c.Expected {
            t.Fatalf("%d * %d expected %d, but %d got",
               c.A, c.B, c.Expected, ans)
         }
      })
   }
}

帮助函数

对一些重复的逻辑,抽取出来作为公共的帮助函数(helpers),可以增加测试代码的可读性和可维护性。 借助帮助函数,可以让测试用例的主逻辑看起来更清晰。

例如,我们可以将创建子测试的逻辑抽取出来:

package example

import "testing"

type calcCase struct {
   A, B, Expected int
}

func createMulTestCase(t *testing.T, c *calcCase) {
   if ans := Mul(c.A, c.B); ans != c.Expected {
      t.Fatalf("%d * %d expected %d, but %d got",
         c.A, c.B, c.Expected, ans)
   }
}

func TestMul(t *testing.T) {
   createMulTestCase(t, &calcCase{2, 3, 6})
   createMulTestCase(t, &calcCase{
      A:        2,
      B:        0,
      Expected: 1,
   })
}

我们故意创造了一个错误的条件,接下来我们来看一个这个错误的条件:

=== RUN   TestMul
    cal_test.go:11: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)

FAIL

可以看到,错误发生在第11行,也就是帮助函数 createMulTestCase 内部。18, 19, 20行都调用了该方法,我们第一时间并不能够确定是哪一行发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

修改 createMulTestCase,调用 t.Helper()

func createMulTestCase(t *testing.T, c *calcCase) {
   t.Helper()
   if ans := Mul(c.A, c.B); ans != c.Expected {
      t.Fatalf("%d * %d expected %d, but %d got",
         c.A, c.B, c.Expected, ans)
   }
}

再次运行go test就可以看到错误的信息了。

=== RUN   TestMul
    cal_test.go:19: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)

FAIL

这样就明确的可以看到是第19排出现的错误。

setup和teardown

如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,一般会写在 setup 和 teardown 函数中。例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 testing 提供了这样的机制:

package example

import (
   "fmt"
   "os"
   "testing"
)

func setup() {
   fmt.Println("Before all tests")
}

func teardown() {
   fmt.Println("After all tests")
}

func Test1(t *testing.T) {
   fmt.Println("I' m test1")
}

func Test2(t *testing.T) {
   fmt.Println("I' m test2")
}

func TestMain(m *testing.M) {
   setup()
   code := m.Run()
   teardown()
   os.Exit(code)
}
  • 在这个测试文件中,包含有2个测试用例,Test1Test2
  • 如果测试文件中包含函数 TestMain,那么生成的测试将调用 TestMain(m),而不是直接运行测试。
  • 调用 m.Run() 触发所有测试用例的执行,并使用 os.Exit() 处理返回的状态码,如果不为0,说明有用例失败。
  • 因此可以在调用 m.Run() 前后做一些额外的准备(setup)和回收(teardown)工作。

网络测试

TCP/HTTP

假设需要测试某个 API 接口的 handler 能够正常工作,例如 helloHandler

func helloHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("hello world"))
}

那我们可以创建真实的网络连接进行测试:

package example

import (
   "io/ioutil"
   "net"
   "net/http"
   "testing"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
   w.Write([]byte("hello world"))
}

func handleError(t *testing.T, err error) {
   t.Helper()
   if err != nil {
      t.Fatal("failed", err)
   }
}

func TestConn(t *testing.T) {
   ln, err := net.Listen("tcp", "127.0.0.1:0")
   handleError(t, err)
   defer ln.Close()

   http.HandleFunc("/hello", helloHandler)
   go http.Serve(ln, nil)

   resp, err := http.Get("http://" + ln.Addr().String() + "/hello")
   handleError(t, err)

   defer resp.Body.Close()
   body, err := ioutil.ReadAll(resp.Body)
   handleError(t, err)

   if string(body) != "hello world" {
      t.Fatal("expected hello world, but got", string(body))
   }
}
  • net.Listen("tcp", "127.0.0.1:0"):监听一个未被占用的端口,并返回 Listener。
  • 调用 http.Serve(ln, nil) 启动 http 服务。
  • 使用 http.Get 发起一个 Get 请求,检查返回值是否正确。
  • 尽量不对 httpnet 库使用 mock,这样可以覆盖较为真实的场景。

httptest

针对 http 开发的场景,使用标准库 net/http/httptest 进行测试更为高效。

上述的测试用例改写如下:

package example

import (
   "io/ioutil"
   "net/http"
   "net/http/httptest"
   "testing"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
   w.Write([]byte("hello world"))
}

func TestConn(t *testing.T) {
   req := httptest.NewRequest("get", "http://localhost:5555/metrics", nil)
   w := httptest.NewRecorder()
   helloHandler(w, req)
   bytes, _ := ioutil.ReadAll(w.Result().Body)

   if string(bytes) != "hello world" {
      t.Fatal("expected hello world, but got", string(bytes))
   }
}

使用 httptest 模拟请求对象(req)和响应对象(w),达到了相同的目的。

Benchmark基准测试

基准测试用例的定义如下:

func BenchmarkName(b *testing.B) {
    // ...
}
  • 函数名必须以 Benchmark 开头,后面一般跟待测试的函数名
  • 参数为 b *testing.B
  • 执行基准测试时,需要添加 -bench 参数。
func BenchmarkMul(b *testing.B) {
   for i := 0; i < b.N; i++ {
      fmt.Sprintf("hello")
   }
}

测试结果如下:

goos: windows
goarch: amd64
pkg: ginTest/example
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
BenchmarkMul
BenchmarkMul-8          28505390                37.13 ns/op
PASS

这是一份基准报告,基准报告每一列对应的含义如下:

type BenchmarkResult struct {
    N         int           // 迭代次数
    T         time.Duration // 基准测试花费的时间
    Bytes     int64         // 一次迭代处理的字节数
    MemAllocs uint64        // 总的分配内存的次数
    MemBytes  uint64        // 总的分配内存的字节数
}

如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer() 先重置定时器,例如:

func BenchmarkMul(b *testing.B) {
   // ...耗时操作
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      fmt.Sprintf("hello")
   }
}

使用 RunParallel 测试并发性能

import (
   "bytes"
   "html/template"
   "testing"
)

func BenchmarkParallel(b *testing.B) {
   templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
   b.RunParallel(func(pb *testing.PB) {
      var buf bytes.Buffer
      for pb.Next() {
         // 所有goroutine一起,循环一共执行b.N次
         buf.Reset()
         templ.Execute(&buf, "World")
      }
   })
}

测试结果如下:

goos: windows
goarch: amd64
pkg: ginTest/example
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
BenchmarkParallel
BenchmarkParallel-8      3969212               306.5 ns/op
PASS

你可能感兴趣的:(工作中遇到的问题,单元测试,golang,junit)