Loading

单元测试工具以及使用规范

原文博客:https://nosae.top

单元测试工具以及使用规范

本文聚焦于测试工具的“使用规范”,而不会罗列测试工具的所有功能。

测试工具比如 ginkgo 提供了许多看起来很好用的功能,比如各种装饰器、GinkGoHelper 等,但它们同时增加了代码的理解成本,遵循 KISS 原则,切勿滥用。

单元测试工具

  • Ginkgo(测试框架):用于组织 UT 的结构。提供并发测试、UT 乱序、测试报告等功能。
  • Gomega(断言库):提供各种类型的断言。
  • testify/mock(mock 库):编译时局部替换接口的实现。
  • Gomonkey(mock 库):运行时全局替换类方法/函数的实现。
  • mockery(mock codegen cli 工具):生成基于 testify 的 mock 类代码,省去自己手写的麻烦。
  • miniredis(redis mock 库):本地启动一个基于内存的 redis 服务端。

其中:

  • Ginkgo + Gomega 让我们能以 DSL 的形式编写测试用例
  • testify 本身包含了 assertion 和 mock 模块,为了统一风格,我们仅使用 Gomega 进行断言,mock 则交给 testify 的 mock 模块和 gomonkey。

如何保证各 UT 之间互不影响

  1. 避免使用 gomonkey:首先 monkey patching 的影响周期是整个程序运行时,如果忘记 Reset 会影响其它 UT 而难以发现问题所在;其次,gomonkey 不是线程安全的,并且运行测试需要关闭内联优化;最后,gomonkey 原理是 runtime hack,不一定与未来的 go 版本兼容。

    避免使用 gomonkey 的根本方法是设计良好的生产代码:避免使用全局函数,类设计都尽可能地抽象出接口,使用依赖注入,面向接口编程。如此,既增强了生产代码的可扩展性,也易于写出高质量 UT。

  2. 避免使用全局变量:将变量的可见性限定在某个上下文中,比如 ginkgo 的容器节点内,下面会说到。

ginkgo 引入的概念

  • 容器节点(Describe、Context 等):将测试用例进行分组,并用于限制设置节点的作用范围。
  • 设置节点(BeforeEach、AfterEach 等):在 UT 前后、suite 前后等做一些公共初始化/收尾工作。
  • 测试节点(It、Specify 等):一个测试节点就是一个 UT。

对于测试中的公共变量,规范用法是:在容器节点中声明变量,在设置节点中初始化变量,在测试节点中使用变量

举个例子:

var _ = Describe("Books", func() {
  // 在容器节点中声明变量
  var foxInSocks, lesMis *books.Book

  BeforeEach(func() {
    // 在设置节点中初始化变量
    lesMis = &books.Book{
      Title:  "Les Miserables",
      Author: "Victor Hugo",
      Pages:  2783,
    }

    foxInSocks = &books.Book{
      Title:  "Fox In Socks",
      Author: "Dr. Seuss",
      Pages:  24,
    }
  })

  Describe("Categorizing books", func() {
    Context("with more than 300 pages", func() {
      It("should be a novel", func() {
        // 在测试节点中使用变量
        Expect(lesMis.Category()).To(Equal(books.CategoryNovel))
      })
    })

    Context("with fewer than 300 pages", func() {
      It("should be a short story", func() {
        Expect(foxInSocks.Category()).To(Equal(books.CategoryShortStory))
      })
    })
  })
})

ginkgo 还有一个概念叫 spec,在我看来其实 spec = 测试节点 = UT。之所以提到 spec 是因为测试日志是这样的:

Running Suite: Books Suite - path/to/books
==========================================================
Random Seed: 1634748172

Will run 2 of 2 specs
••

Ran 2 of 2 Specs in 0.000 seconds
SUCCESS! -- 2 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in Xs
Test Suite Passed

例子中有两个测试节点,所以日志显示有两个 spec。

使用容器节点来组织测试用例

ginkgo 有三种容器节点:DescribeContextWhen,它们只在语义上有差别。

  • Describe:这些是 XX 类/功能的测试

  • Context: 这些是 XX 场景下的测试

    When 有点多余,用上面两个足够了)

另外,容器节点还会将其包含的设置节点只作用于同一容器节点内的测试节点上,不会作用到其它容器节点内的测试节点上。

开始使用 ginkgo

参考 官方教程

  1. 安装 ginkgo cli 工具,用于生成 suite 的初始模板代码(不想安装完全手写也可以,代码量并不多)

  2. (每个包只执行一次)在待测试包下执行 ginkgo bootstrap 生成 suite 入口,文件名为 PACKAGE_suite_test.go,生成得到的内容如下:

    package books_test
    
    import (
      . "github.com/onsi/ginkgo/v2"
      . "github.com/onsi/gomega"
      "testing"
    )
    
    func TestBooks(t *testing.T) {
      // 设置gomega断言失败的处理函数为Fail,用于断言失败时报告当前UT失败,并自动开始下一个UT
      RegisterFailHandler(Fail)
      // 逐个运行当前包下的所有spec
      RunSpecs(t, "Books Suite")
    }
    
  3. (每个包根据实际情况执行多次)在 suite 同目录下执行不同的 ginkgo generate <SUBJECT> 生成测试文件,文件名为 SUBJECT_test.go,内容如下:

    package books_test
    
    import (
      . "github.com/onsi/ginkgo/v2"
      . "github.com/onsi/gomega"
    
      "path/to/books"
    )
    
    var _ = Describe("Books", func() {
    // 待添加测试节点
    })
    

在 UT 中日志打印

使用 GinkgoWriter 来打印日志

ginkgo 提供了全局变量 GinkgoWriter 用来给我们打印日志,它的输出格式与 fmt 一样,没有任何修饰(比如时间戳、行数)。只有失败的 UT 的日志才会最终被打印,比如:

var _ = Describe("V1", func() {
    It("test goroutine", func() {
      	// 这行会被打印
        GinkgoWriter.Println("hi from goroutine 1")
        Fail("oh no")

    })
    It("test goroutine 2", func() {
      	// 这行不会被打印
        GinkgoWriter.Print("hi from goroutine 2")
      	// 这行会被打印,因为没使用GinkgoWriter
        fmt.Println("hi")
    })
})

如果你希望打印的日志与生产代码的风格统一,GinkgoWriter 本身实现了 io.Writer,因此你可以很方便地将它包装到你自己的日志类中,将输出重定向到 GinkgoWriter 即可。

执行 ginkgo -v 无论 UT 是否成功都一律打印日志。

在 UT 中的子协程断言

使用 GinkgoRecover 在子协程中处理断言失败

如果子协程内可能断言失败,那么需要在子协程的开头加上一句 defer GinkgoRecover(),否则,当子协程内断言失败时,整个进程直接结束,而不是自动运行下一个 UT。

func TestV1(t *testing.T) {
  RegisterFailHandler(Fail)
  RunSpecs(t, "V1 Suite")
}

var _ = Describe("V1", func() {
  It("test goroutine", func() {
    go func() {
      // 如果不加这行,Fail之后整个进程结束,下面第二个UT不会运行
      defer GinkgoRecover()
      Fail("fail")
    }()
    <-time.After(time.Second * 3)
  })
  It("test goroutine 2", func() {
    fmt.Println("hi")
  })
})

table-driven 风格的 UT

优先考虑将 UT 写成 table-driven 风格

table-driven 使用 DescribeTable 节点实现,table-driven 目的是减少没必要的代码重复,并且增强测试可读性,直接看一个例子:

Describe("book", func() {
  var book *books.Book

  BeforeEach(func() {
    book = &books.Book{
      Title: "Les Miserables",
      Author: "Victor Hugo",
      Pages: 2783,
    }
    Expect(book.IsValid()).To(BeTrue())
  })

  DescribeTable("Extracting the author's first and last name",
    func(author string, isValid bool, firstName string, lastName string) {
      book.Author = author
      Expect(book.IsValid()).To(Equal(isValid))
      Expect(book.AuthorFirstName()).To(Equal(firstName))
      Expect(book.AuthorLastName()).To(Equal(lastName))
    },
    Entry("When author has both names", "Victor Hugo", true, "Victor", "Hugo"),
    Entry("When author has one name", "Hugo", true, "", "Hugo"),
    Entry("When author has a middle name", "Victor Marie Hugo", true, "Victor", "Hugo"),
  )
})

BeforeEach 会作用到每个 Entry 上。

UT 的随机数种子

使用 GinkgoRandomSeed 作为种子设置我们的随机数生成器

ginkgo 会为每次运行生成一个随机数种子,并在测试开始时打印出来,比如:

Random Seed: 1634748172

可以在代码中通过 GinkgoRandomSeed 获取这个种子的值,并将其作为我们随机数生成器的种子。这样做的好处在于,当某次测试失败的时候,下次可以使用 ginkgo --seed=1634748172 指定种子,一键复现失败结果。

其次,ginkgo 也会依赖这个种子将 UT 的运行顺序打乱,打乱 UT 顺序有助于发现我们是否破坏了测试用例之间的独立性。将种子固定,能让 UT 运行顺序与上次相同,一键复现失败结果。

UT 的并行化运行

使用 ginkgo -p 并行运行 UT,以提升运行效率以及发现 UT 间的依赖

ginkgo 默认串行运行所有 UT,使用 ginkgo -p 将基于 UT 的粒度并行测试,提升测试程序的运行效率(尤其是跑 CI 的时候)。ginkgo -p 根据机器核数自动确定并行度,也可以 ginkgo -procs=N 手动指定并行度。

注意,ginkgo 是通过多进程来实现并行的,在单个进程内依然是串行,所以不用担心 UT 之间并发读写的问题。

指定运行某个 UT

在容器节点或测试节点名称前加上 F(或者使用 Focus 装饰器)指定只运行某些 UT

ginkgo 底层依然依赖的是 go test,但是 go test 只认 TestXXX(t *testing.T),所以理论上不能只运行 suite 内的某个 UT(IDE 不能点击绿色的绿色 Run 箭头运行某个 It 节点)。

因此 ginkgo 提供了以编程的方式指定只运行某些 UT:在容器节点或测试节点名称前加上 F(或者使用 Focus 装饰器):

func TestBooks(t *testing.T) {
  RegisterFailHandler(Fail)
  RunSpecs(t, "funcs suite")
}

// 该容器节点内的UT全部被运行
var _ = FDescribe("func1", func() {
  ...
})

var _ = Describe("func2", func() {
  // 当前容器节点内只有这个UT被运行
  FIt("it1", func() {
    ...
  })
  // 这个UT不会被运行
  It("it2", func() {
    ...
  })
})

最后,执行命令 ginkgo unfocus 一键删除这些 F 前缀,不然 CI 的时候就只跑你这几个 UT 了。

给 UT 传入 Context

不要自己创建 context.Context,而是使用 SpecContext

如果待测试方法需要传入一个 context.Context,让 ginkgo 自动注入到你的 UT 中,然后将它传给你的待测试方法:

It("it1", func(ctx SpecContext) {
  db.GetUser(ctx, 1)
})

好处在于,如果设置了超时装饰器比如 NodeTimeout,那么 SpecContext 会超时取消,比如:

It("it1", func(ctx SpecContext) {
  db.GetUser(ctx, 1)
}, NodeTimeout(time.Second))

注意,SpecContext.Deadline 被 ginkgo 重写了,不会返回任何有效期限。但 Done 的语义与 context.Context 一样,都是超时关闭

ginkgo 在 CI 中的最佳实践

参考 https://onsi.github.io/ginkgo/#recommended-continuous-integration-configuration

使用断言

以上介绍了使用 ginkgo 如何规范地将 UT 组织起来,下面介绍如何使用 gomega 进行断言。断言分为两类:同步断言与异步断言

// 同步断言
Expect(ACTUAL). // 返回一个同步断言Assertion
	Should(GomegaMatcher) // 调用Assertion的方法并传入一个匹配器进行断言

// 异步断言
Eventually(ACTUAL). // 返回一个异步断言AsyncAssertion
	Should(GomegaMatcher) // 调用AsyncAssertion的方法并传入一个匹配器进行断言

先看看最常用的同步断言

等值断言

// 基础使用
Expect(ACTUAL).Should(Equal(EXPECTED)) // To是Should的别名
Expect(ACTUAL).ShouldNot(Equal(EXPECTED)) // ToNot和NotTo是ShouldNot的别名

// 添加断言失败时的说明信息
Expect(ACTUAL).Should(Equal(EXPECTED), "service %s leak", serviceId)

error 断言

err := Foo()
Expect(err).ShouldNot(HaveOccurred())
Expect(err).Should(Succeed()) // 或者写成这样更加简洁

nil 断言

var ptr *any
ptr := DoSomething()
Expect(ptr).ToNot(BeNil())

异步断言

当测试场景为「子协程、进程甚至外部系统对某个变量进行写入,在 UT 中读取该变量并检查是否为预期值」我们将会用到异步断言(注意这里的“变量”可以是内存、数据库等任何可读写的东西)。简单来说就是变量不是由当前 UT 协程同步写入的,因此 UT 协程需要不断轮询该变量检查是否为预期值。

异步断言有两种函数,EventuallyConsistently。他们的区别是在超时时间内:

  • Eventually:只要出现一次匹配成功就断言成功,全部匹配失败才断言失败
  • Consistently:只要出现一次匹配失败就断言失败,全部匹配成功才断言成功

Eventually

举一个最简单的例子:

status := atomic.Bool{}
func WriteStatus() {
  time.Sleep(100 * time.Millisecond)
  status.Store(true)
}

func ReadStatus() bool {
  return status.Load()
}

var _ = Describe("...", func () {
  go WriteStatus()
  // 阻塞直到 ReadStatus 返回 true
  Eventually(ReadStatus).Should(BeTrue)
})

Eventually 有默认超时时间和轮询间隔,分别是 1 秒和 10 毫秒,上面的例子中每 10 毫秒调用一次 ReadStatus 检查是否为 true,如果超过 1 秒还没返回 true,该 UT 失败。

我们可以对 Eventually 进行配置,例如:

Eventually(ReadStatus).
	WithPolling(time.Second). // 轮询间隔1s
  WithTimeout(10 * time.Second). // 超时时长10s
  Should(BeTrue)

我们还能在被轮询的函数内进行同步断言,但是不能用全局的 Expect 函数,而是使用传入的 Gomega 参数:

func ReadStatus(g Gomega) bool {
  g.Expect(...).Should(...)
  return status.Load()
}

除了对函数进行轮询,还可以对 chan 进行断言:

c := make(chan bool)
go DoStuff(c)
Eventually(c).Should(BeClosed()) // chan关闭则断言成功
Eventually(c).Should(Receive()) // 从chan读到了值则断言成功
Eventually(c).Should(Receive(Equal(true))) // 从chan读到了true则断言成功
var result bool
Eventually(c).Should(Receive(&result)) // 从chan读到了值则断言成功,并将该值赋给变量
Eventually(c).Should(Receive(&result, Equal(true))) // 从chan读到了true则断言成功,并将该值赋给变量

Consistently

使用方法与 Eventually 一样,只是语义不同。

使用 testify/mock + mockery 进行 mocking

testify/mock 只能用于 mock 接口

生成 mock 类步骤如下(使用 mockery 来生成代码的方式):

  1. 安装 mockery 命令行工具
  2. 在根目录下执行 mockery init MOD_NAMEMOD_NAME 是整个项目 module 的名字),生成 .mockery.yaml 配置文件,并按需修改它的内容
  3. 根目录执行 mockery,这一步是按照配置文件来生成 mock 文件,里面包含了 mock 类相关代码

比如当前有一个 UserRepo 接口:

package v1

type UserRepo interface {
    Get(id int64) (*User, error)
}

type User struct {
    Name string
}

生成得到的 mock 文件内容大致为:

func NewMockUserRepo(t interface {
	mock.TestingT
	Cleanup(func())
}) *MockUserRepo {
	mock := &MockUserRepo{}
	mock.Mock.Test(t)

	t.Cleanup(func() { mock.AssertExpectations(t) })

	return mock
}

type MockUserRepo struct {
	mock.Mock
}

func (_mock *MockUserRepo) Get(id int64) (*User, error) {
	args := _mock.Called(id)
  return ret.Get(0).(*User), ret.Error(1)
}

使用方式:

var _ = Describe("...", func() {
  It("...", func() {
    // 创建mock对象
    obj := NewMockUserRepo(GinkgoT())
    
    // 使用由mockery生成的,类型安全的EXPECT
    obj.EXPECT().Get(1).Return(&User{"Lucy"}, nil)
    obj.EXPECT().Get(mock.Anything).Return(&User{"Lucy"}, nil)
    // 如果当前mockery版本不支持EXPECT,则使用On
    obj.On("Get", 1).Return(&User{"Lucy"}, nil)
    obj.On("Get", mock.Anything).Return(&User{"Lucy"}, nil)
    
    
    // 将对象传入待测试函数
    result := DoSomething(obj)
    // 断言
    ...
  })
})

使用 gomonkey 进行 mocking

非必要的情况下不要使用 gomonkey,如果发现不得不用的情况,请改造代码,不能改造代码的情况下,记得 Reset。如果是临时使用,用完即删

使用方式参考 examples

使用 miniredis 进行 redis mocking

实际上 miniredis 无需你手动 mock,而是基于内存直接 mock 了一个 redis 服务器给你用。这样一来,除了无需手动 mock 之外,甚至与任何 redis 客户端都能无缝集成,无论是对单元测试还是集成测试都极其友好。下面是使用方式:

func TestSomething(t *testing.T) {
	s := miniredis.RunT(t)

  // 直接对miniredis 服务端对象端进行redis操作
	s.Set("key", "value")
	s.HSet("some", "other", "key")
  
  // 与redis客户端框架集成,比如go-redis
  c := redis.NewClient(&redis.Options{
    Addr: s.Addr(),
	})
	_ = c.Set(ctx, "key", "value", 0)
  
  // 模拟客户端的redis操作返回错误
  c := s.SetError("foobar")
  _, err := c.Get(ctx, "key").Result() // err为foobar
  s.SetError("") // 清除错误
  
  // 对于TTL,miniredis不会真的去计算已经过了多少秒,而是通过FastForward模拟时间的流逝
	s.Set("foo", "bar")
	s.SetTTL("foo", 10*time.Second)
	s.FastForward(11 * time.Second)
	if s.Exists("foo") {
		t.Fatal("'foo' should not have existed anymore")
	}
}

与 Ginkgo 集成:

func TestV1(t *testing.T) {
  RegisterFailHandler(Fail)
  RunSpecs(t, "V1 Suite")
}

var r *miniredis.Miniredis
var c *redis.Client

var _ = BeforeSuite(func() {
  var err error
  r, err = miniredis.Run()
  Expect(err).Should(Succeed())

  c = redis.NewClient(&redis.Options{
    Addr: r.Addr(),
  })
})

var _ = Describe("V1", func() {
  BeforeEach(func() {
    r.FlushAll()
  })
  It("...", func(ctx SpecContext) {
    r.Set("foo", "bar")
    value, err := c.Get(ctx, "foo").Result()
    Expect(c.Get(ctx, "foo").Result()).Should(Equal("bar"))
  })
})

参考

ginkgo

gomega

mockery

miniredis

posted @ 2026-01-03 23:18  NOSAE  阅读(8)  评论(0)    收藏  举报