Ginkgo /ˈɡɪŋkoʊ / 是Go语言的一个行为驱动开发(BDD, Behavior-Driven Development)风格的测试框架,通常和库Gomega一起使用。Ginkgo在一系列的“Specs”中描述期望的程序行为。
Ginkgo集成了Go语言的测试机制,你可以通过go test来运行Ginkgo测试套件。
go get -u github.com/onsi/ginkgo/ginkgo
假设我们想给books包编写Ginkgo测试,则首先需要使用命令创建一个Ginkgo test suite:
cd pkg/books ginkgo bootstrap
上述命令会生成文件:
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)
// 启动测试套件
RunSpecs(t, "Books Suite")
}
现在,使用命令ginkgo或者go test即可执行测试套件。
上面的空测试套件没有什么价值,我们需要在此套接下编写测试(Spec)。虽然可以在books_suite_test.go中编写测试,但是推荐分离到独立的文件中,特别是包中有多个需要被测试的源文件的情况下。
执行命令ginkgo generate book可以为源文件book.go生成测试:
package books_test
import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
// 为了方便,被测试包被导入当前命名空间
. "ginkgo-study/pkg/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:
// 使用Describe、Context容器来组织Spec
var _ = Describe("Book", func() {
var (
// 通过闭包在BeforeEach和It之间共享数据
longBook Book
shortBook Book
)
// 此函数用于初始化Spec的状态,在It块之前运行。如果存在嵌套Describe,则最
// 外面的BeforeEach最先运行
BeforeEach(func() {
longBook = Book{
Title: "Les Miserables",
Author: "Victor Hugo",
Pages: 1488,
}
shortBook = 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"))
})
})
})
})
除了调用Gomega之外,你还可以调用Fail函数直接断言失败:
Fail("Failure reason")
Fail会记录当前进行的测试,并且触发panic,当前Spec的后续断言不会再进行。
通常情况下Ginkgo会从panic中恢复,并继续下一个测试。但是,如果你启动了一个Goroutine,并在其中触发了断言失败,则不会自动恢复,必须手工调用GinkgoRecover:
It("panics in a goroutine", func(done Done) {
go func() {
// 如果doSomething返回false则下面的defer会确保从panic中恢复
defer GinkgoRecover()
// Ω和Expect功能相同
Ω(doSomething()).Should(BeTrue())
// 在Goroutine中需要关闭done通道
close(done)
}()
})
全局的GinkgoWriter可以用于写日志。默认情况下GinkgoWriter仅仅在测试失败时将日志Dump到标准输出,以冗长模式(ginkgo -v 或 go test -ginkgo.v)运行Ginkgo时则会立即输出。
如果通过Ctrl + C中断测试,则Ginkgo会立即输出写入到GinkgoWriter的内容。联用--progress则Ginkgo会在BeforeEach/It/AfterEach之前输出通知到GinkgoWriter,这个特性便于诊断卡住的测试。
直接使用flag包即可:
var myFlag string
func init() {
flag.StringVar(&myFlag, "myFlag", "defaultvalue", "myFlag is used to control my behavior")
}
执行测试时使用ginkgo -- --myFlag=xxx传递参数。
你可以在Describe、Context这两种容器块内编写Spec,每个Spec写在It块中。
为了贴合自然语言,可以使用It的别名Specify:
Describe("The foobar service", func() {
Context("when calling Foo()", func() {
Context("when no ID is provided", func() {
// 应该返回ErrNoID错误
Specify("an ErrNoID error is returned", func() {
})
})
})
})
多个Spec共享的、测试准备逻辑,可以放到BeforeEach块中。
在BeforeEach、AfterEach块中进行断言是允许的。
存在容器嵌套时,最外层BeforeEach先运行。
多个Spec共享的、测试清理逻辑,可以放到AfterEach块中。存在容器嵌套时,最内层AfterEach先运行。
两者的区别:
下面是一个例子:
// 这是关于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"))
})
})
})
上面的例子中,内层Spec需要尝试从无效JSON创建Book,因此它调用NewBookFromJSON对book变量进行覆盖。这种做法是推荐的,应该使用JustBeforeEach,这种块在任何BeforeEach执行完毕后执行:
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)这两个阶段。
紧跟着It之后运行,在所有AfterEach执行之前。
在整个测试套件执行之前/之后,进行准备/清理。和套件代码写在一起:
func TestBooks(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Books Suite")
}
var _ = BeforeSuite(func() {
dbClient = db.NewClient()
err = dbClient.Connect(dbRunner.Address())
Expect(err).NotTo(HaveOccurred())
})
var _ = AfterSuite(func() {
dbClient.Cleanup()
})
这两个块都支持异步执行,只需要给函数传递一个Done参数即可。
此块用于给逻辑复杂的块添加文档:
var _ = Describe("Browsing the library", func() {
BeforeEach(func() {
By("Fetching a token and logging in")
})
It("should be a pleasant experience", func() {
By("Entering an aisle")
})
})
传递给By的字符串会发送给GinkgoWriter,如果测试失败你可以看到。
你可以传递一个可选的函数给By,此函数会立即执行。
使用Measure块可以进行性能测试,所有It能够出现的地方,都可以使用Measure。和It一样,Measure会生成一个新的Spec。
传递给Measure的闭包函数必须具有Benchmarker入参:
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)
执行时间、你录制的任意数据的最小、最大、平均值均会在测试完毕后打印出来。
# 运行当前目录中的测试 ginkgo # 运行其它目录中的测试 ginkgo /path/to/package /path/to/other/package ... # 递归运行所有子目录中的测试 ginkgo -r ...
传递参数给测试套件:
ginkgo -- PASS-THROUGHS-ARGS
# 跳过某些包 ginkgo -skipPackage=PACKAGES,TO,SKIP
选项-timeout用于控制套件的最大运行时间,如果超过此时间仍然没有完成,认为测试失败。默认24小时。
| 选项 | 说明 |
| --reportPassed | 打印通过的测试的详细信息 |
| --v | 冗长模式 |
| --trace | 打印所有错误的调用栈 |
| --progress | 打印进度信息 |
| 选项 | 说明 |
| -race | 启用竞态条件检测 |
| -cover | 启用覆盖率测试 |
| -tags | 指定编译器标记 |
你可以标记一个Spec或容器为Pending,这样默认情况下不会运行它们。定义块时使用P或X前缀:
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禁止该行为。
P或X前缀会在编译期将Spec标记为Pending,你也可以在运行期跳过特定的Spec:
It("should do something, if it can", func() {
if !someCondition {
// 跳过此Spec,不需要Return语句
Skip("special condition wasn't met")
}
})
一个很常见的需求是,可以选择运行Spec的一个子集。Ginkgo提供两种机制满足此需求:
FDescribe("some behavior", func() { ... })
FContext("some scenario", func() { ... })
FIt("some assertion", func() { ... })
在命令行中传递正则式:--focus=REGEXP 或/和 --skip=REGEXP,则Ginkgo仅仅运行/跳过匹配的Spec
Ginkgo支持并行的运行Spec,它实现方式是,创建go test子进程并在其中运行共享队列中的Spec。
使用ginkgo -p可以启用并行测试,Ginkgo会自动创建适当数量的节点(进程)。你也可以指定节点数量:ginkgo -nodes=N。
如果你的测试代码需要和外部进程交互,或者创建外部进程,在并行测试上下文中需要谨慎的处理。最简单的方式是在BeforeSuite方法中为每个节点创建外部资源。
如果所有Spec需要共享一个外部进程,则可以利用SynchronizedBeforeSuite、SynchronizedAfterSuite:
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的回调顺序则正好相反:
var _ = SynchronizedAfterSuite(func() {
// 所有节点
dbClient.Cleanup()
}, func() {
// 第一个节点
dbRunner.Stop()
})
这时Ginkgo推荐使用的断言(Matcher)库。
注册Fail处理器即可:
gomega.RegisterFailHandler(ginkgo.Fail)
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")
}
两种断言语法本质是一样的,只是命名风格有些不同:
Ω(ACTUAL).Should(Equal(EXPECTED)) Expect(ACTUAL).To(Equal(EXPECTED)) Ω(ACTUAL).ShouldNot(Equal(EXPECTED)) Expect(ACTUAL).NotTo(Equal(EXPECTED)) Expect(ACTUAL).ToNot(Equal(EXPECTED))
对于返回多个值的函数:
func DoSomethingHard() (string, error) {}
result, err := DoSomethingHard()
// 断言没有发生错误
Ω(err).ShouldNot(HaveOccurred())
Ω(result).Should(Equal("foo"))
对于仅仅返回一个error的函数:
func DoSomethingHard() (string, error) {}
Ω(DoSomethingSimple()).Should(Succeed())
进行断言时,可以提供格式化字符串,这样断言失败可以方便的知道原因:
Ω(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(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")) // 检查标准输出
可以指定超时、轮询间隔:
Eventually(func() []int {
return thing.SliceImMonitoring
}, TIMEOUT, POLLING_INTERVAL).Should(HaveLen(2))
检查断言是否在一定时间段内总是通过:
Consistently(func() []int {
return thing.MemoryUsage()
}, DURATION, POLLING_INTERVAL).Should(BeNumerically("<", 10))
Consistently也可以用来断言最终不会发生的事件,例如下面的例子:
Consistently(channel).ShouldNot(Receive())
默认情况下,Eventually每10ms轮询一次,持续1s。Consistently每10ms轮询一次,持续100ms。调用下面的函数修改这些默认值:
SetDefaultEventuallyTimeout(t time.Duration) SetDefaultEventuallyPollingInterval(t time.Duration) SetDefaultConsistentlyDuration(t time.Duration) SetDefaultConsistentlyPollingInterval(t time.Duration)
这些调用会影响整个测试套件。
// 使用reflect.DeepEqual进行比较
// 如果ACTUAL和EXPECTED都为nil,断言会失败
Ω(ACTUAL).Should(Equal(EXPECTED))
// 先把ACTUAL转换为EXPECTED的类型,然后使用reflect.DeepEqual进行比较
// 应当避免用来比较数字
Ω(ACTUAL).Should(BeEquivalentTo(EXPECTED))
// 使用 == 进行比较
BeIdenticalTo(expected interface{})
Ω(ACTUAL).Should(BeAssignableToTypeOf(EXPECTED interface))
// 断言ACTUAL为Nil Ω(ACTUAL).Should(BeNil()) // 断言ACTUAL为它的类型的零值,或者是Nil Ω(ACTUAL).Should(BeZero())
Ω(ACTUAL).Should(BeTrue()) Ω(ACTUAL).Should(BeFalse())
Ω(ACTUAL).Should(HaveOccurred()) err := SomethingThatMightFail() // 没有错误 Ω(err).ShouldNot(HaveOccurred()) // 如果ACTUAL为Nil则断言成功 Ω(ACTUAL).Should(Succeed())
可以对错误进行细粒度的匹配:
Ω(ACTUAL).Should(MatchError(EXPECTED))
上面的EXPECTED可以是:
不符合以上条件的EXPECTED是不允许的。
// 断言通道是否关闭 // Gomega会尝试读取通道进行判断,因此你需要注意: // 如果是缓冲通道,你需要先将通道读干净 // 如果你后续需要再次读取通道,注意此断言的影响 Ω(ACTUAL).Should(BeClosed()) Ω(ACTUAL).ShouldNot(BeClosed()) // 断言能够从通道里面读取到消息 // 此断言会立即返回,如果通道已经关闭,则下面的断言失败 Ω(ACTUAL).Should(Receive(<optionalPointer>)) // 断言能够无阻塞的发送消息 Ω(ACTUAL).Should(BeSent(VALUE))
// 文件或目录存在 Ω(ACTUAL).Should(BeAnExistingFile()) // 断言是普通文件 Ω(ACTUAL).Should(BeARegularFile()) // 断言是目录 BeADirectory
// 子串判断 fmt.Sprintf(STRING, ARGS...) Ω(ACTUAL).Should(ContainSubstring(STRING, ARGS...)) // 前缀判断 Ω(ACTUAL).Should(HavePrefix(STRING, ARGS...)) // 后缀判断 Ω(ACTUAL).Should(HaveSuffix(STRING, ARGS...)) // 正则式匹配 Ω(ACTUAL).Should(MatchRegexp(STRING, ARGS...))
Ω(ACTUAL).Should(MatchJSON(EXPECTED)) Ω(ACTUAL).Should(MatchXML(EXPECTED)) Ω(ACTUAL).Should(MatchYAML(EXPECTED))
ACTUAL、EXPECTED可以是string、[]byte、Stringer。如果两者转换为对象是reflect.DeepEqual的则匹配。
string, array, map, chan, slice都属于集合。
// 断言为空
Ω(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))
// 断言数字意义(类型不感知)上的相等
Ω(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:
Ω(ACTUAL).Should(Panic())
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无法满足需要,你可以实现接口:
type GomegaMatcher interface {
Match(actual interface{}) (success bool, err error)
FailureMessage(actual interface{}) (message string)
NegatedFailureMessage(actual interface{}) (message string)
}
用于测试HTTP客户端,此包提供了Mock HTTP服务器的能力。
gbytes.Buffer实现了接口io.WriteCloser,能够捕获到内存缓冲的输入。配合使用gbytes.Say能够对流数据进行有序的断言。
简化了外部进程的测试,可以:
此包用于测试复杂的Go结构,提供了结构、切片、映射、指针相关的Matcher。
对所有字段进行断言:
actual := struct{
A int
B bool
C string
}{5, true, "foo"}
Expect(actual).To(MatchAllFields(Fields{
"A": BeNumerically("<", 10),
"B": BeTrue(),
"C": Equal("foo"),
})
不处理某些字段:
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"), // 忽略多余字段
})
一个复杂的例子:
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(),
}))
[…] 一个行为驱动测试框架,参考:Ginkgo学习笔记 […]
good note! thanks for sharing