0%

Ginkgo 测试框架

Ginkgo /ˈɡɪŋkoʊ / 是Go语言的一个行为驱动开发(BDD, Behavior-Driven Development)风格的测试框架,通常和库Gomega一起使用。Ginkgo在一系列的“Specs”中描述期望的程序行为。Ginkgo 集成了Go语言的测试机制,你可以通过 go test 来运行Ginkgo测试套件。本文所有的代码可以在我的 Github 中找到。

技术特点

Behavior Driven Development

Ginkgo最大的特点就是对BDD风格的支持。比如:

1
2
3
Describe("delete app api", func() {
It("should delete app permanently", func() {...})
It("should delete app failed if services existed", func() {...})

Ginkgo 定义的 DSL 语法(Describe/Context/It)可以非常方便的帮助大家组织和编排测试用例。在BDD模式中,测试用例的标题书写,要非常注意表达,要能清晰的指明用例测试的业务场景。只有这样才能极大的增强用例的可读性,降低使用和维护的心智负担。

可读性这一点,在自动化测试用例设计原则上,非常重要。因为测试用例不同于一般意义上的程序,它在绝大部分场景下,看起来都像是一段段独立的方法,每个方法背后隐藏的业务逻辑也是细小的,不具通识性。这个问题在用例量少的情况下,还不明显。但当用例数量上到一定量级,你会发现,如果能快速理解用例到底是能做什么的,真的非常重要。而这正是 BDD 能补足的地方。

不过还是要强调,Ginkgo 只是提供对 BDD 模式的支持,你的用例最终呈现的效果,还是依赖你自己的书写。

进程级并行,稳定高效

相应的我们知道,BDD 框架,因为其DSL的深度嵌套支持,会存在一些共享上下文的资源,如此的话想做线程级的并发会比较困难。而Ginkgo巧妙的避开了这个问题,它通过在运行时,运行多个被测服务的进程,来达到真正的并行,稳定性大大提高。其使用姿势也非常简单,ginkgo -p命令就可以。在实践中,我们通常使用32核以上的服务器来跑集测,执行效率非常高。

这里有个细节,Ginkgo 虽然并行执行测试用例,但其输出的日志和测试报告格式,仍然是整齐不错乱的,这是如何做到的呢?原来,通过源码会发现,ginkgo CLI 工具在并行跑用例时,其内部会起一个监听随机端口的本地服务器,来做不同进程之间的消息同步,以及日志和报告的聚合工作,是不是很巧妙?

使用实战

安装

1
$ go get -u github.com/onsi/ginkgo/ginkgo

起步

创建套件

假设我有一个 books 的包,当前非常简单,有函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package books

type Book struct {
Title string
Author string
Pages int
}

func (b *Book) CategoryByLength() string {

if b.Pages >= 300 {
return "NOVEL"
}

return "SHORT STORY"
}

假设我们想给 books 包编写 Ginkgo 测试,则首先需要使用命令创建一个Ginkgo test suite:

1
2
$ cd pkg/books
$ ginkgo bootstrap

上述命令会生成文件:

/pkg/books/books_suite_test.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package books_test

import (
// 使用点号导入,把这两个包导入到当前命名空间
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"testing"
)

func TestBooks(t *testing.T) {
// 将Ginkgo的Fail函数传递给Gomega,Fail函数用于标记测试失败,这是Ginkgo和Gomega唯一的交互点
// 如果Gomega断言失败,就会调用Fail进行处理
RegisterFailHandler(Fail)

// 启动测试套件
// 如果任意 specs(说明)失败了,Ginkgo 会自动使 testing.T 失败
RunSpecs(t, "Books Suite")
}

现在,使用命令 ginkgo或者 go test即可执行测试套件。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ginkgo
Running Suite: Books Suite
==========================
Random Seed: 1618566161
Will run 0 of 0 specs


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

Ginkgo ran 1 suite in 3.113619498s
Test Suite Passed

添加Spec

上面的空测试套件没有什么价值,我们需要在此套接下编写测试(Spec)。虽然可以在 books_suite_test.go 中编写测试,但是推荐分离到独立的文件中,特别是包中有多个需要被测试的源文件的情况下。

执行命令 ginkgo generate book 可以为源文件 books.go 生成测试:

pkg/books/book_test.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package books_test

import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/SimpCosm/godemo/ginkgo/books"
)

// 顶级的Describe容器

// Describe块用于组织Specs,其中可以包含任意数量的:
// BeforeEach:在Spec(It块)运行之前执行,嵌套Describe时最外层BeforeEach先执行
// AfterEach:在Spec运行之后执行,嵌套Describe时最内层AfterEach先执行
// JustBeforeEach:在It块,所有BeforeEach之后执行
// Measurement

// 可以在Describe块内嵌套Describe、Context、When块
var _ = Describe("Book", func() {

})

我们可以添加一些Specs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package books_test

import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"

"github.com/SimpCosm/godemo/ginkgo/books"
)

// 使用Describe、Context容器来组织Spec
var _ = Describe("Books", func() {
var (
// 通过闭包在BeforeEach和It之间共享数据
longBook books.Book
shortBook books.Book
)
// 此函数用于初始化Spec的状态,在It块之前运行。如果存在嵌套Describe,则最
// 外面的BeforeEach最先运行
BeforeEach(func() {
longBook = books.Book{
Title: "Les Miserables",
Author: "Victor Hugo",
Pages: 1488,
}

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

Describe("Categorizing book length", func() {
Context("With more than 300 pages", func() {
// 通过It来创建一个Spec
It("should be a novel", func() {
// Gomega的Expect用于断言
Expect(longBook.CategoryByLength()).To(Equal("NOVEL"))
})
})

Context("With fewer than 300 pages", func() {
It("should be a short story", func() {
Expect(shortBook.CategoryByLength()).To(Equal("SHORT STORY"))
})
})
})
})

运行上述测试显示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ginkgo
Running Suite: Books Suite
==========================
Random Seed: 1618571780
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 1.8345466s
Test Suite Passed

断言失败

除了调用Gomega之外,你还可以调用Fail函数直接断言失败:

1
Fail("Failure reason")

Fail会记录当前进行的测试,并且触发panic,当前Spec的后续断言不会再进行。

通常情况下Ginkgo会 从panic中恢复,并继续下一个测试 。但是,如果你启动了一个Goroutine,并在其中触发了断言失败,则不会自动恢复,必须手工调用GinkgoRecover

1
2
3
4
5
6
7
8
9
10
11
It("panics in a goroutine", func(done Done) {
go func() {
// 如果doSomething返回false则下面的defer会确保从panic中恢复
defer GinkgoRecover()
// Ω和Expect功能相同
Ω(doSomething()).Should(BeTrue())

// 在Goroutine中需要关闭done通道
close(done)
}()
})

记录日志

Ginkgo提供了一个全局可用的io.Writer,名为GinkgoWriter,供你写入。GinkgoWriter在测试运行时聚合输入,并且只有在测试失败时才将其转储到stdout。当以详细模式运行时(ginkgo -vgo test -ginkgo.v),GinkgoWriter会立即将其输入重定向到stdout

当Ginkgo测试套件中断(通过^ C)时,Ginkgo将发出写入GinkgoWriter的任何内容。这样可以更轻松地调试卡住的测试。 当与--progress配对使用时将会特别有用,它指示Ginkgo在运行您的BeforeEachesItsAfterEaches等时向GinkgoWriter发出通知。

传递参数

直接使用flag包即可:

1
2
3
4
var myFlag string
func init() {
flag.StringVar(&myFlag, "myFlag", "defaultvalue", "myFlag is used to control my behavior")
}

执行测试时使用 ginkgo -- --myFlag=xxx 传递参数。

测试结构

单个Specs: It

你可以在Describe、Context这两种容器块内编写Spec,每个Spec写在It块中。

您可以通过在DescribeContext容器块中设置 It 块来添加单个 spec:

1
2
3
4
5
6
7
8
9
10
11
12
13
var _ = Describe("Book", func() {
It("can be loaded from JSON", func() {
book := NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488
}`)

Expect(book.Title).To(Equal("Les Miserables"))
Expect(book.Author).To(Equal("Victor Hugo"))
Expect(book.Pages).To(Equal(1488))
})
})

It 也可以放在顶层,虽然这种情况并不常见。

Specify 别名

为了确保您的 specs 阅读自然,SpecifyPSpecifyXSpecifyFSpecify块可用作别名,以便在相应的It替代品看起来不像自然语言的情况下使用。

Specify块的行为与It块相同,可以在It块(以及PItXItFIt块)的地方使用。

Specify替换It的示范如下:

1
2
3
4
5
6
7
8
Describe("The foobar service", func() {
Context("when calling Foo()", func() {
Context("when no ID is provided", func() {
Specify("an ErrNoID error is returned", func() {
})
})
})
})

提取通用步骤:BeforeEach

您可以使用BeforeEach块在多个测试用例中去除重复的步骤以及共享通用的设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var _ = Describe("Book", func() {
var book Book

BeforeEach(func() {
book = NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488
}`)
})

It("can be loaded from JSON", func() {
Expect(book.Title).To(Equal("Les Miserables"))
Expect(book.Author).To(Equal("Victor Hugo"))
Expect(book.Pages).To(Equal(1488))
})

It("can extract the author's last name", func() {
Expect(book.AuthorLastName()).To(Equal("Hugo"))
})
})

BeforeEach在每个 spec 之前运行,从而确保每个 spec 都具有状态的原始副本。使用闭包变量共享公共状态(在本例中为var book Book)。您还可以在AfterEach块中执行清理操作。

BeforeEachAfterEach块中设置断言也很常见。例如,这些断言,可以断言在为 spec 准备状态时没有发生错误。

存在容器嵌套时,最外层BeforeEach先运行。

AfterEach

多个Spec共享的、测试清理逻辑,可以放到AfterEach块中。存在容器嵌套时,最内层AfterEach先运行。

Describe/Context

Ginkgo允许您使用DescribeContext容器在套件中富有表现力的组织 specs ,两者的区别:

  1. Describe用于描述你的代码的一个行为
  2. Context用于区分上述行为的不同情况,通常为参数不同导致

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 这是关于Book服务测试
var _ = Describe("Book", func() {
var (
book Book
err error
)

BeforeEach(func() {
book, err = NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488
}`)
})
// 测试加载Book行为
Describe("loading from JSON", func() {
// 如果正常解析JSON
Context("when the JSON parses succesfully", func() {
It("should populate the fields correctly", func() {
// 期望 相等
Expect(book.Title).To(Equal("Les Miserables"))
Expect(book.Author).To(Equal("Victor Hugo"))
Expect(book.Pages).To(Equal(1488))
})

It("should not error", func() {
// 期望 没有发生错误
Expect(err).NotTo(HaveOccurred())
})
})
// 如果无法解析JSON
Context("when the JSON fails to parse", func() {
BeforeEach(func() {
// 这是一个BDD反模式,可以用JustBeforeEach
book, err = NewBookFromJSON(`{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488oops
}`)
})

It("should return the zero-value for the book", func() {
// 期望 为零
Expect(book).To(BeZero())
})

It("should error", func() {
// 期望 发生了错误
Expect(err).To(HaveOccurred())
})
})
})

Describe("Extracting the author's last name", func() {
It("should correctly identify and return the last name", func() {
Expect(book.AuthorLastName()).To(Equal("Hugo"))
})
})
})

通常,容器块中的唯一代码应该是 It 块或 BeforeEach / JustBeforeEach / JustAfterEach / AfterEach 块或闭包变量声明。在容器块中进行断言通常是错误的。 在容器块中初始化闭包变量也是错误的。如果你的一个 It 改变了这个变量,后期 It 将会收到改变后的值。这是一个测试污染的案例,很难追查。始终在BeforeEach块中初始化变量。

分离创建和配置 JustBeforeEach

上面的例子说明了BDD风格测试中常见的反模式。我们的顶级 BeforeEach 使用有效的 JSON 创建了一个新的 book ,但是较低级别的 Context 使用无效的JSON创建的 book 执行。这使我们重新创建并覆盖原始的 book 。幸运的是,使用Ginkgo的 JustBeforeEach 块,这些代码重复是不必要的。

JustBeforeEach 块保证在所有 BeforeEach 块运行之后,并且在 It 块运行之前运行。我们可以使用这个特性来清除 Book spec:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var _ = Describe("Book", func() {
var (
book Book
err error
json string
)
// 准备默认JSON
BeforeEach(func() {
json = `{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488
}`
})

JustBeforeEach(func() {
// 按需,根据默认数据/无效JSON创建book,避免NewBookFromJSON的重复调用(如果代价很高的话……)
book, err = NewBookFromJSON(json)
})

Describe("loading from JSON", func() {
Context("when the JSON parses succesfully", func() {
})

Context("when the JSON fails to parse", func() {
BeforeEach(func() {
// 覆盖默认JSON为无效JSON
json = `{
"title":"Les Miserables",
"author":"Victor Hugo",
"pages":1488oops
}`
})
})
})
})

在上面的例子中,JustBeforeEach解耦了创建(Creation)和配置(Configuration)这两个阶段。现在,对每一个Itbook实际上只创建一次。这个失败的JSON上下文可以简单地将无效的json值分配给BeforeEach中的json变量。

分离诊断收集和销毁 JustAfterEach

在销毁(可能会破坏有用的状态)之前,在每一个It块之后,有时运行一些代码是很有用的。比如,测试失败后,执行一些诊断的操作。我们可以在上面的示例中使用它来检查测试是否失败,如果失败,则输出实际的book

1
2
3
4
5
6
JustAfterEach(func() {
if CurrentGinkgoTestDescription().Failed {
fmt.Printf("Collecting diags just after failed test in %s\n", CurrentGinkgoTestDescription().TestText)
fmt.Printf("Actual book was %v\n", book)
}
})

您可以在不同的嵌套级别使用多个JustAfterEach。Ginkgo将首先从内到外运行所有JustAfterEach,然后它将从内到外运行AfterEach。虽然功能强大,但这会导致测试套件混乱 - 因此合理地使用嵌套的JustAfterEach

就像JustBeforeEach一样,JustAfterEach是一个很容易被滥用的强大工具。好好利用它。

紧跟着It之后运行,在所有AfterEach执行之前。

全局设置和销毁 BeforeSuite/AfterSuite

有时您希望在整个测试之前运行一些设置代码和在整个测试之后运行一些清理代码。例如,您可能需要启动并销毁外部数据库。

Ginkgo提供了BeforeSuiteAfterSuite来实现这一点。通常,您可以在引导程序文件的顶层定义它们。例如,假设您需要设置外部数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package books_test

import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"

"your/db"

"testing"
)

var dbRunner *db.Runner
var dbClient *db.Client

func TestBooks(t *testing.T) {
RegisterFailHandler(Fail)

RunSpecs(t, "Books Suite")
}

var _ = BeforeSuite(func() {
dbRunner = db.NewRunner()
err := dbRunner.Start()
Expect(err).NotTo(HaveOccurred())

dbClient = db.NewClient()
err = dbClient.Connect(dbRunner.Address())
Expect(err).NotTo(HaveOccurred())
})

var _ = AfterSuite(func() {
dbClient.Cleanup()
dbRunner.Stop()
})

BeforeSuite 函数在任何 spec运行之前运行。如果BeforeSuite运行失败则没有 spec将会运行,测试套件运行结束。

AfterSuite函数在所有的 spec运行之后运行,无论是否有任何测试的失败。由于AfterSuite通常有一些代码来清理持久的状态,所以当你使用control+c 打断运行的测试时,Ginkgo也将会运行AfterSuite。要退出AfterSuite的运行,再次输入control+c

通过传递带有Done参数的函数,可以异步运行BeforeSuiteAfterSuite

您只能在测试套件中定义一次BeforeSuiteAfterSuite不需要设置多次!)

最后,当并行运行时,每个并行进程都将运行BeforeSuiteAfterSuite函数。在这里查看有关并行运行测试的更多信息。

记录复杂的It: By

按照规则,您应该记录您的ItBeforEach, 等精炼到位。有时这是不可能的,特别是在集成式测试中测试复杂的工作流时。在这些情况下,您的测试块开始隐藏通过单独查看代码难以收集的叙述。在这些情况下,Ginkgo 通过By来提供帮助,此块用于给逻辑复杂的块添加文档。这里有一个很好的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var _ = Describe("Browsing the library", func() {
BeforeEach(func() {
By("Fetching a token and logging in")

authToken, err := authClient.GetToken("gopher", "literati")
Exepect(err).NotTo(HaveOccurred())

err := libraryClient.Login(authToken)
Exepect(err).NotTo(HaveOccurred())
})

It("should be a pleasant experience", func() {
By("Entering an aisle")

aisle, err := libraryClient.EnterAisle()
Expect(err).NotTo(HaveOccurred())

By("Browsing for books")

books, err := aisle.GetBooks()
Expect(err).NotTo(HaveOccurred())
Expect(books).To(HaveLen(7))

By("Finding a particular book")

book, err := books.FindByTitle("Les Miserables")
Expect(err).NotTo(HaveOccurred())
Expect(book.Title).To(Equal("Les Miserables"))

By("Check the book out")

err := libraryClient.CheckOut(book)
Expect(err).NotTo(HaveOccurred())
books, err := aisle.GetBooks()
Expect(books).To(HaveLen(6))
Expect(books).NotTo(ContainElement(book))
})
})

传递给By的字符串是通过GinkgoWriter发出的。如果测试成功,您将看不到Ginkgo绿点之外的任何输出。但是,如果测试失败,您将看到失败之前的每个步骤的打印输出。使用ginkgo -v总是输出所有步骤打印。

By 采用一个可选的fun()类型函数。当传入这样的一个函数时,By将会立刻调用该函数。这将允许您组织您的多个It到一组步骤,但这纯粹是可选的。在实际应用中,每个By函数是一个单独的回调,这一特性限制了这种方法的可用性。

Spec Runner

Pending Spec

你可以标记一个Spec或容器为Pending,这样默认情况下不会运行它们。定义块时使用P或X前缀:

1
2
3
4
5
6
7
8
9
PDescribe("some behavior", func() { ... })
PContext("some scenario", func() { ... })
PIt("some assertion")
PMeasure("some measurement")

XDescribe("some behavior", func() { ... })
XContext("some scenario", func() { ... })
XIt("some assertion")
XMeasure("some measurement")

默认情况下Ginkgo会为每个Pending的Spec打印描述信息,使用命令行选项 --noisyPendings=false 禁止该行为。

Skiping Spec

P或X前缀会在编译期将Spec标记为Pending,你也可以在运行期跳过特定的Spec:

1
2
3
4
5
6
It("should do something, if it can", func() {
if !someCondition {
// 跳过此Spec,不需要Return语句
Skip("special condition wasn't met")
}
})

Focused Specs

一个很常见的需求是,可以选择运行Spec的一个子集。Ginkgo提供两种机制满足此需求:

将容器或Spec标记为Focused,这样默认情况下Ginkgo仅仅运行Focused Spec:

1
2
3
FDescribe("some behavior", func() { ... })
FContext("some scenario", func() { ... })
FIt("some assertion", func() { ... })

在命令行中传递正则式: —focus=REGEXP 或/和 —skip=REGEXP,则Ginkgo仅仅运行/跳过匹配的Spec

Parallel Specs

Ginkgo支持并行的运行Spec,它实现方式是,创建go test子进程并在其中运行共享队列中的Spec。

使用 ginkgo -p可以启用并行测试,Ginkgo会自动创建适当数量的节点(进程)。你也可以指定节点数量: ginkgo -nodes=N。

如果你的测试代码需要和外部进程交互,或者创建外部进程,在并行测试上下文中需要谨慎的处理。最简单的方式是在BeforeSuite方法中为每个节点创建外部资源。

如果所有Spec需要共享一个外部进程,则可以利用SynchronizedBeforeSuite、SynchronizedAfterSuite:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var _ = SynchronizedBeforeSuite(func() []byte {
// 在第一个节点中执行
port := 4000 + config.GinkgoConfig.ParallelNode

dbRunner = db.NewRunner()
err := dbRunner.Start(port)
Expect(err).NotTo(HaveOccurred())

return []byte(dbRunner.Address())
}, func(data []byte) {
// 在所有节点中执行
dbAddress := string(data)

dbClient = db.NewClient()
err = dbClient.Connect(dbAddress)
Expect(err).NotTo(HaveOccurred())
})

上面的例子,为所有节点创建共享的数据库,然后为每个节点创建独占的客户端。 SynchronizedAfterSuite的回调顺序则正好相反:

1
2
3
4
5
6
7
var _ = SynchronizedAfterSuite(func() {
// 所有节点
dbClient.Cleanup()
}, func() {
// 第一个节点
dbRunner.Stop()
})

异步测试

在平时的代码中,我们经常会看到需要做异步处理的测试用例。但是这块的逻辑如果处理不好,用例可能会因为死锁或者未设置超时时间而异常卡住,非常的恼人。好在Ginkgo专门提供了原生的异步支持,能大大降低此类问题的风险。类似用法:

1
2
3
4
5
6
It("should post to the channel, eventually", func() {
c := make(chan string, 0)

go DoSomething(c)
Expect(<-c).To(ContainSubstring("Done!"))
})

这个测试会阻塞直到接受到通道c的响应。对于这种测试,一个死锁或超时是常见的错误模式。对于这种情况,一个常见模式是在底部添加一个 select 语句 ,并包括一个<-time.After(X)通道来指定超时。

Ginkgo 有这种内置模式。在所有无容器块(It, BeforeEach, AfterEach, JustBeforeEach, JustAfterEach, 和 Benchmark)中body函数能接受一个可选的done Done 参数:

1
2
3
4
5
6
7
It("should post to the channel, eventually", func(done Done) {
c := make(chan string, 0)

go DoSomething(c)
Expect(<-c).To(ContainSubstring("Done!"))
close(done)
}, 0.2)

Done 是一个 chan interface{}。当 Ginkgo 检测到 done Done 参数已经被请求了,它会运行 用 goroutine 运行 body 函数,并将它包裹到一个应用超时断言的必要逻辑中。你必须要么关闭 done 通道,要么发送一些东西(任何东西都行)给它来告诉 Ginkgo 你的测试已经结束。如果你的测试超时不结束,Ginkgo会让测试失败并进行下一个。

默认的超时是 1 秒。你可以在 body 函数后面传递一个 float64 (秒为单位)修改超时时间。

Gomega 对于丰富的异步代码断言有额外支持。确保查看了 Eventually 在 Gomega 是如何工作的。

针对分布式系统,我们在验收一些场景时,可能需要等待一段时间,目标结果才生效。而这个时间会因为不同集群负载而有所不同。所以简单的硬编码来sleep一个固定时间,很明显不合适。这种场景下若是使用Ginkgo对应的matcher库GomegaEventually功能就非常的贴切,在大大提升用例稳定性的同时,最大可能的减少无用的等待时间。

性能测试

使用Measure块可以进行性能测试,所有It能够出现的地方,都可以使用Measure。和It一样,Measure会生成一个新的Spec。

传递给Measure的闭包函数必须具有 Benchmarker 入参:

1
2
3
4
5
6
7
8
9
10
11
12
13
Measure("it should do something hard efficiently", func(b Benchmarker) {
// 执行一段逻辑并即时
runtime := b.Time("runtime", func() {
output := SomethingHard()
Expect(output).To(Equal(17))
})

// 断言 执行时间 小于 0.2 秒
Ω(runtime.Seconds()).Should(BeNumerically("<", 0.2), "SomethingHard() shouldn't take too long.")

// 录制任意数据
b.RecordValue("disk usage (in MB)", HowMuchDiskSpaceDidYouUse())
}, 10)

执行时间、你录制的任意数据的最小、最大、平均值均会在测试完毕后打印出来。

CLI

运行测试

1
2
3
4
5
6
7
# 运行当前目录中的测试
ginkgo
# 运行其它目录中的测试
ginkgo /path/to/package /path/to/other/package ...

# 递归运行所有子目录中的测试
ginkgo -r ...

传递参数

传递参数给测试套件:

1
ginkgo -- PASS-THROUGHS-ARGS

跳过某些包

1
2
# 跳过某些包
ginkgo -skipPackage=PACKAGES,TO,SKIP

超时控制

选项 -timeout 用于控制套件的最大运行时间,如果超过此时间仍然没有完成,认为测试失败。默认24小时。

调试信息

选项 说明
—reportPassed 打印通过的测试的详细信息
—v 冗长模式
—trace 打印所有错误的调用栈
—progress 打印进度信息

其他选项

选项 说明
-race 启用竞态条件检测
-cover 启用覆盖率测试
-tags 指定编译器标记

Gomega

这时Ginkgo推荐使用的断言(Matcher)库。

联用

和Ginkgo

1
gomega.RegisterFailHandler(ginkgo.Fail)

和Go测试框架

1
2
3
4
5
6
7
8
func TestFarmHasCow(t *testing.T) {
// 创建Gomega对象
g := NewGomegaWithT(t)

f := farm.New([]string{"Cow", "Horse"})
// 进行断言
g.Expect(f.HasCow()).To(BeTrue(), "Farm should have cow")
}

断言

Ω/Expect

两种断言语法本质是一样的,只是命名风格有些不同:

1
2
3
4
5
6
Ω(ACTUAL).Should(Equal(EXPECTED))
Expect(ACTUAL).To(Equal(EXPECTED))

Ω(ACTUAL).ShouldNot(Equal(EXPECTED))
Expect(ACTUAL).NotTo(Equal(EXPECTED))
Expect(ACTUAL).ToNot(Equal(EXPECTED))

错误处理

对于返回多个值的函数:

1
2
3
4
5
6
func DoSomethingHard() (string, error) {}

result, err := DoSomethingHard()
// 断言没有发生错误
Ω(err).ShouldNot(HaveOccurred())
Ω(result).Should(Equal("foo"))

对于仅仅返回一个error的函数:

1
2
3
func DoSomethingHard() (string, error) {}

Ω(DoSomethingSimple()).Should(Succeed())

断言注解

进行断言时,可以提供格式化字符串,这样断言失败可以方便的知道原因:

1
2
3
4
5
Ω(ACTUAL).Should(Equal(EXPECTED), "My annotation %d", foo)

Expect(ACTUAL).To(Equal(EXPECTED), "My annotation %d", foo)

Expect(ACTUAL).To(Equal(EXPECTED), func() string { return "My annotation" })

简化输出

断言失败时,Gomega打印牵涉到断言的对象的递归信息,输出可能很冗长。

format包提供了一些全局变量,调整这些变量可以简化输出。

变量 = 默认值 说明
format.MaxDepth = 10 打印对象嵌套属性的最大深度
format.UseStringerRepresentation = false 默认情况下,Gomega不会调用Stringer.String()或GoStringer.GoString()方法来打印对象的字符串表示字符串表示通常人类可读但是信息量较小设置为true则打印字符串表示,可以简化输出
format.PrintContextObjects = false 默认情况下,Gomega不会打印context.Context接口的内容,因为通常非常冗长
format.TruncatedDiff = true 截断长字符串,仅仅打印差异

异步断言

Gomega提供了两个函数,用于异步断言。

传递给Eventually、Consistently的函数,如果返回多个值,则第一个返回值用于匹配,其它值断言为nil或零值。

Eventually

阻塞并轮询参数,直到能通过断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 参数是闭包,调用函数
Eventually(func() []int {
return thing.SliceImMonitoring
}).Should(HaveLen(2))

// 参数是通道,读取通道
Eventually(channel).Should(BeClosed())
Eventually(channel).Should(Receive())

// 参数也可以是普通变量,读取变量
Eventually(myInstance.FetchNameFromNetwork).Should(Equal("archibald"))

// 可以和gexec包的Session配合
Eventually(session).Should(gexec.Exit(0)) // 命令最终应当以0退出
Eventually(session.Out).Should(Say("Splines reticulated")) // 检查标准输出

可以指定超时、轮询间隔:

1
2
3
Eventually(func() []int {
return thing.SliceImMonitoring
}, TIMEOUT, POLLING_INTERVAL).Should(HaveLen(2))

Consistently

检查断言是否在一定时间段内总是通过:

1
2
3
Consistently(func() []int {
return thing.MemoryUsage()
}, DURATION, POLLING_INTERVAL).Should(BeNumerically("<", 10))

Consistently也可以用来断言最终不会发生的事件,例如下面的例子:

1
Consistently(channel).ShouldNot(Receive())

修改默认间隔

默认情况下,Eventually每10ms轮询一次,持续1s。Consistently每10ms轮询一次,持续100ms。调用下面的函数修改这些默认值:

1
2
3
4
SetDefaultEventuallyTimeout(t time.Duration)
SetDefaultEventuallyPollingInterval(t time.Duration)
SetDefaultConsistentlyDuration(t time.Duration)
SetDefaultConsistentlyPollingInterval(t time.Duration)

这些调用会影响整个测试套件。

Matcher

相等性

1
2
3
4
5
6
7
8
9
10
// 使用reflect.DeepEqual进行比较
// 如果ACTUAL和EXPECTED都为nil,断言会失败
Ω(ACTUAL).Should(Equal(EXPECTED))

// 先把ACTUAL转换为EXPECTED的类型,然后使用reflect.DeepEqual进行比较
// 应当避免用来比较数字
Ω(ACTUAL).Should(BeEquivalentTo(EXPECTED))

// 使用 == 进行比较
BeIdenticalTo(expected interface{})

接口相容

1
Ω(ACTUAL).Should(BeAssignableToTypeOf(EXPECTED interface))

空值/零值

1
2
3
4
5
// 断言ACTUAL为Nil
Ω(ACTUAL).Should(BeNil())

// 断言ACTUAL为它的类型的零值,或者是Nil
Ω(ACTUAL).Should(BeZero())

布尔值

1
2
Ω(ACTUAL).Should(BeTrue())
Ω(ACTUAL).Should(BeFalse())

错误

1
2
3
4
5
6
7
8
9
Ω(ACTUAL).Should(HaveOccurred())

err := SomethingThatMightFail()
// 没有错误
Ω(err).ShouldNot(HaveOccurred())


// 如果ACTUAL为Nil则断言成功
Ω(ACTUAL).Should(Succeed())

可以对错误进行细粒度的匹配:

1
Ω(ACTUAL).Should(MatchError(EXPECTED))

上面的EXPECTED可以是:

  1. 字符串:则断言ACTUAL.Error()与之相等
  2. Matcher:则断言ACTUAL.Error()与之进行匹配
  3. error:则ACTUAL和error基于reflect.DeepEqual()进行比较
  4. 实现了error接口的非Nil指针,调用 errors.As(ACTUAL, EXPECTED)进行检查

不符合以上条件的EXPECTED是不允许的。

通道

1
2
3
4
5
6
7
8
9
10
11
12
13
// 断言通道是否关闭
// Gomega会尝试读取通道进行判断,因此你需要注意:
// 如果是缓冲通道,你需要先将通道读干净
// 如果你后续需要再次读取通道,注意此断言的影响
Ω(ACTUAL).Should(BeClosed())
Ω(ACTUAL).ShouldNot(BeClosed())

// 断言能够从通道里面读取到消息
// 此断言会立即返回,如果通道已经关闭,则下面的断言失败
Ω(ACTUAL).Should(Receive(<optionalPointer>))

// 断言能够无阻塞的发送消息
Ω(ACTUAL).Should(BeSent(VALUE))

文件

1
2
3
4
5
6
// 文件或目录存在
Ω(ACTUAL).Should(BeAnExistingFile())
// 断言是普通文件
Ω(ACTUAL).Should(BeARegularFile())
// 断言是目录
BeADirectory

字符串

1
2
3
4
5
6
7
8
9
10
11
12
// 子串判断                        fmt.Sprintf(STRING, ARGS...)
Ω(ACTUAL).Should(ContainSubstring(STRING, ARGS...))

// 前缀判断
Ω(ACTUAL).Should(HavePrefix(STRING, ARGS...))

// 后缀判断
Ω(ACTUAL).Should(HaveSuffix(STRING, ARGS...))


// 正则式匹配
Ω(ACTUAL).Should(MatchRegexp(STRING, ARGS...))

JSON/XML/YAML

1
2
3
Ω(ACTUAL).Should(MatchJSON(EXPECTED))
Ω(ACTUAL).Should(MatchXML(EXPECTED))
Ω(ACTUAL).Should(MatchYAML(EXPECTED))

ACTUAL、EXPECTED可以是string、[]byte、Stringer。如果两者转换为对象是reflect.DeepEqual的则匹配。

集合

string, array, map, chan, slice都属于集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 断言为空
Ω(ACTUAL).Should(BeEmpty())

// 断言长度
Ω(ACTUAL).Should(HaveLen(INT))

// 断言容量
Ω(ACTUAL).Should(HaveCap(INT))

// 断言包含元素
Ω(ACTUAL).Should(ContainElement(ELEMENT))

// 断言等于 其中之一
Ω(ACTUAL).Should(BeElementOf(ELEMENT1, ELEMENT2, ELEMENT3, ...))


// 断言元素相同,不考虑顺序
Ω(ACTUAL).Should(ConsistOf(ELEMENT1, ELEMENT2, ELEMENT3, ...))
Ω(ACTUAL).Should(ConsistOf([]SOME_TYPE{ELEMENT1, ELEMENT2, ELEMENT3, ...}))

// 断言存在指定的键,仅用于map
Ω(ACTUAL).Should(HaveKey(KEY))
// 断言存在指定的键值对,仅用于map
Ω(ACTUAL).Should(HaveKeyWithValue(KEY, VALUE))

数字/时间

1
2
3
4
5
6
7
8
9
10
11
12
13
// 断言数字意义(类型不感知)上的相等
Ω(ACTUAL).Should(BeNumerically("==", EXPECTED))

// 断言相似,无差不超过THRESHOLD(默认1e-8)
Ω(ACTUAL).Should(BeNumerically("~", EXPECTED, <THRESHOLD>))


Ω(ACTUAL).Should(BeNumerically(">", EXPECTED))
Ω(ACTUAL).Should(BeNumerically(">=", EXPECTED))
Ω(ACTUAL).Should(BeNumerically("<", EXPECTED))
Ω(ACTUAL).Should(BeNumerically("<=", EXPECTED))

Ω(number).Should(BeBetween(0, 10))

比较时间时使用BeTemporally函数,和BeNumerically类似。

Panic

断言会发生Panic:

1
Ω(ACTUAL).Should(Panic())

And/Or

1
2
3
4
5
6
7
8
9
10
11
12
13
Expect(number).To(SatisfyAll(
BeNumerically(">", 0),
BeNumerically("<", 10)))
// 或者
Expect(msg).To(And(
Equal("Success"),
MatchRegexp(`^Error .+$`)))



Ω(ACTUAL).Should(SatisfyAny(MATCHER1, MATCHER2, ...))
// 或者
Ω(ACTUAL).Should(Or(MATCHER1, MATCHER2, ...))

自定义Matcher

如果内置Matcher无法满足需要,你可以实现接口:

1
2
3
4
5
type GomegaMatcher interface {
Match(actual interface{}) (success bool, err error)
FailureMessage(actual interface{}) (message string)
NegatedFailureMessage(actual interface{}) (message string)
}

辅助工具

ghttp

用于测试HTTP客户端,此包提供了Mock HTTP服务器的能力。

gbytes

gbytes.Buffer实现了接口io.WriteCloser,能够捕获到内存缓冲的输入。配合使用 gbytes.Say 能够对流数据进行有序的断言。

gexec

简化了外部进程的测试,可以:

  1. 编译Go二进制文件
  2. 启动外部进程
  3. 发送信号并等待外部进程退出
  4. 基于退出码进行断言
  5. 将输出流导入到gbytes.Buffer进行断言

gstruct

此包用于测试复杂的Go结构,提供了结构、切片、映射、指针相关的Matcher。

对所有字段进行断言:

1
2
3
4
5
6
7
8
9
10
actual := struct{
A int
B bool
C string
}{5, true, "foo"}
Expect(actual).To(MatchAllFields(Fields{
"A": BeNumerically("<", 10),
"B": BeTrue(),
"C": Equal("foo"),
})

不处理某些字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
Expect(actual).To(MatchFields(IgnoreExtras, Fields{
"A": BeNumerically("<", 10),
"B": BeTrue(),
// 忽略C字段
})


Expect(actual).To(MatchFields(IgnoreMissing, Fields{
"A": BeNumerically("<", 10),
"B": BeTrue(),
"C": Equal("foo"),
"D": Equal("bar"), // 忽略多余字段
})

一个复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
coreID := func(element interface{}) string {
return strconv.Itoa(element.(CoreStats).Index)
}
Expect(actual).To(MatchAllFields(Fields{
// 忽略此字段
"Name": Ignore(),
// 时间断言
"StartTime": BeTemporally(">=", time.Now().Add(-100 * time.Hour)),
// 解引用后再断言
"CPU": PointTo(MatchAllFields(Fields{
"Time": BeTemporally(">=", time.Now().Add(-time.Hour)),
"UsageNanoCores": BeNumerically("~", 1E9, 1E8),
"UsageCoreNanoSeconds": BeNumerically(">", 1E6),
// 包含匹配的元素, 抽取ID的函数
"Cores": MatchElements(coreID, IgnoreExtras, Elements{
// ID: Matcher
"0": MatchAllFields(Fields{
Index: Ignore(),
"UsageNanoCores": BeNumerically("<", 1E9),
"UsageCoreNanoSeconds": BeNumerically(">", 1E5),
}),
"1": MatchAllFields(Fields{
Index: Ignore(),
"UsageNanoCores": BeNumerically("<", 1E9),
"UsageCoreNanoSeconds": BeNumerically(">", 1E5),
}),
}),
}))
"Logs": m.Ignore(),
}))

总结

自动化测试用例不应该仅仅是QA手中的工具,而应该尽可能多的作为业务验收服务,输出到CICD、灰度验证、线上验收等尽可能多的场景,以服务于整个业务线,利用 Ginkgo 我们可以很容易做到这一点:

  • CICD: 在定义suite时,使用RunSpecWithDefaultReporters方法,可以让测试结果既输出到stdout,还可以输出一份Junit格式的报告。这样就可以通过类似Jenkins的工具方便的呈现测试结果,而不用任何其他的额外操作。
  • TaaS(Test as a Service): 通过ginkgo build或者原生的go test -c命令,可以方便的将测试用例,编译成 package.test 的二进制文件。如此的话,我们就可以方便的进行测试服务分发。典型的,如交付给SRE同学,辅助其应对线上灰度场景下的测试验收。

参考资料