Making “goconvey” Work With “gomega” Assertion
Simplify writing tests
At work, we used gomega
for testing. It worked well until one day, it became too verbose and goconvey
became a better fit. To prevent the repository from using two different sets of assertion functions, I decided to write an adapter to make goconvey
work with gomega
assertion functions.
gomega
gomega
is a testing framework for Go. Here’s what it looks like:
package example_test
import (
"fmt"
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func Test(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Example Suite")
}
var _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
It("Test 1.1:", func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
})
It("Test 1.2:", func() {
s += " -> test1.2"
Ω(s).To(Equal("Start -> test1 -> test1.2"))
})
})
Describe("Execute test 2", func() {
BeforeEach(func() {
s += " -> test2"
})
It("Test 2.1:", func() {
s += " -> test2.1"
})
It("Test 2.2:", func() {
s += " -> test2.2"
Ω(s).To(Equal("Start -> test2 -> test2.2"))
})
})
})
gomega
/ginkgo uses a DSL to describe the test cases. Ω()
is an alias of Expect()
. The execution order is as follows:
- When running
Test 1.1
, it will execute in the following order:
Describe("PathTraversal")
-> BeforeEach()
-> Describe("Executing test 1")
-> BeforeEach()
-> It("Test 1.1")
-> AfterEach() // print the output:
// Start -> test1 -> test1.1
- When running
Test 1.2
, it will execute in the following order:
Describe("PathTraversal")
-> BeforeEach()
-> Describe("Executing test 1")
-> BeforeEach()
-> It("Test 1.2")
-> AfterEach() // print the output:
// Start -> test1 -> test1.2
- And so on.
goconvey
goconvey
is another testing framework for Go. Besides writing tests, it also has a nice web server to display the results with coverage reports. But in this article, let’s only focus on the test part. Here’s what it looks like:
package example_test
import (
"fmt"
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func Test(t *testing.T) {
Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
Convey("Executing test 1", func() {
s += " -> test1"
So(s, ShouldEqual, "Start -> test1")
Convey("Test 1.1:", func() {
s += " -> test1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1")
})
Convey("Test 1.2:", func() {
s += " -> test1.2"
So(s, ShouldEqual, "Start -> test1 -> test1.2")
})
})
Convey("Execute test 2", func() {
s += " -> test2"
So(s, ShouldEqual, "Start -> test2")
Convey("Test 2.1:", func() {
s += " -> test2.1"
})
Convey("Test 2.2:", func() {
s += " -> test2.2"
So(s, ShouldEqual, "Start -> test2 -> test2.2")
})
})
})
}
Similar to the gomega
example, the execution order is:
- When running
Test 1.1
, it will execute in the following order:
Convey("PathTraversal")
-> Convey("Executing test 1")
-> Convey("Test 1.1")
-> defer func() // print the output:
// Start -> test1 -> test1.1
- When running
Test 1.2
, it will execute in the following order:
Convey("PathTraversal")
-> Convey("Execute test 1")
-> Convey("Test 1.2")
-> defer func() // print the output:
// Start -> test1 -> test1.2
- And so on.
The Problem and Why goconvey Is a Better Fit
From the two examples above, they are quite similar in test structure and can achieve the same execution order. Both of them can output Start -> test1 -> test1.1\nStart -> test1 -> test1.2...
, but gomega
is more verbose with the Describe
, BeforeEach
, AfterEach
functions. On the other hand, goconvey
is more concise and easy to read. But this wasn’t a problem for a long time, and we were still happily dealing with it.
Until one day, we needed to test a function with many execution branching. Suddenly the test code with gomega
became a mess.
Let’s add another level with two sub-branches Test 1.1.1
and Test 1.1.2
to the gomega
example:
package example_test
var _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
It("Test 1.1:", func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
It("Test 1.1.1:", func() {
s += " -> test1.1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.1"))
})
It("Test 1.1.2:", func() {
s += " -> test1.1.2"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.2"))
})
})
/* ... */
})
/* ... */
})
It won’t work because gomega
does not allow nested It()
. We have to refactor the tests to make it work, as shown below:
package example_test
var _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
Context("Test 1.1:", func() {
BeforeEach(func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
)
}
It("Test 1.1.1:", func() {
s += " -> test1.1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.1"))
})
It("Test 1.1.1:", func() {
s += " -> test1.1.2"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.2"))
})
})
/* ... */
})
/* ... */
})
In real-world code, imagine we have to move a lot of lines in and out of It()
and BeforeEach()
. Each time we want to add another level of branch. Too many lines changed. It’s annoying, error-prone, and hard to edit, read, and review.
But with goconvey
, the experience is smooth. We need to add another level of Convey()
, and the existing lines don’t need to be changed. Here’s what that looks like:
package example_test
func Test(t *testing.T) {
Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
Convey("Executing test 1", func() {
s += " -> test1"
So(s, ShouldEqual, "Start -> test1")
Convey("Test 1.1:", func() {
s += " -> test1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1")
Convey("Test 1.1.1:", func() {
s += " -> test1.1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1 -> test1.1.1")
})
Convey("Test 1.1.2:", func() {
s += " -> test1.1.2"
So(s, ShouldEqual, "Start -> test1 -> test1.1 -> test1.1.2")
})
})
/* ... */
})
/* ... */
})
}
Make goconvey Work With gomega
But we still want to use gomega
’s Ω()
functions. Keeping two sets of assertions in our source code will be confusing, especially for new people. Let’s make it work with goconvey
.
Here’s what the final code looks like (full version can be found here):
package xconvey
import (
// Workaround for ginkgo flags used in CI test commands. Without this import, ginkgo flags are not registered and
// the command "go test -v ./... -ginkgo.v" will fail. But for some other reason, ginkgo can not be imported from
// both test and non-test packages (error: flag redefined: ginkgo.seed). So test files using this package (xconvey)
// must NOT import ginkgo.
_ "github.com/onsi/ginkgo"
"github.com/onsi/gomega"
gomegatypes "github.com/onsi/gomega/types"
"github.com/smartystreets/goconvey/convey"
)
func Convey(items ...any) {
defer conveyGomegaSetup(items...)()
convey.Convey(items...)
}
func conveyGomegaSetup(items ...any) func() {
if len(items) >= 2 {
testT, ok := items[1].(*testing.T)
if ok {
gomega.Default = gomega.NewWithT(testT).
ConfigureWithFailHandler(func(message string, callerSkip ...int) {})
return func() {
/* clean up */
}
}
}
return func() {}
}
func Ωx(actual any, extra ...any) gomega.Assertion {
assertion := gomega.Expect(actual, extra...)
return conveyGomegaAssertion{actual: actual, assertion: assertion}
}
type conveyGomegaAssertion struct {
actual any
assertion gomega.Assertion
}
func (a conveyGomegaAssertion) To(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
if len(optionalDescription) > 0 {
panic("optionalDescription is not supported")
}
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
if !success {
return matcher.FailureMessage(a.actual)
}
return ""
})
return true
}
func (a conveyGomegaAssertion) ToNot(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
if len(optionalDescription) > 0 {
panic("optionalDescription is not supported")
}
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
if success {
return matcher.NegatedFailureMessage(a.actual)
}
return ""
})
return true
}
And here’s how it is used:
package example_test
import (
"fmt"
"testing"
. "github.com/onsi/gomega"
"github.com/example/xconvey"
)
func Test(t *testing.T) {
Ω := xconvey.Ωx // adapter to make goconvey work with gomega
xconvey.Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
xconvey.Convey("Executing test 1", func() {
s += " -> test1"
Ω(s).To(Equal("Start -> test1"))
})
/* ... */
})
}
Some Notes
xconvey.Convey()
is our wrapper function forconvey.Convey()
. It callsconveyGomegaSetup()
to setupgomega
before callingconvey.Convey()
.gomega
requires*testing.T
andFail()
to be set up before usingΩ()
.goconvey
requires*testing.T
at the top levelConvey()
. Our functionconveyGomegaSetup()
detects the top levelConvey()
then sets up necessary things forgomega
.Ωx()
is a wrapper aroundgomega.Expect()
. It returns agomega.Assertion
object. We can useTo()
,ToNot()
and other assertion methods on it to make assertions withgomega
matchers.- Our CI runs
go test -v ./... -ginkgo.v
. I have to create a workaround to make the command work withxconvey
/non-ginkgo
package. Without the import ofginkgo
, the command with fail (error: flagginkgo.v
is not defined). Butginkgo
can not be imported into both test and non-test packages (error: flag redefined:ginkgo.seed
). So I have to import it into the non-test package (xconvey
) to make its flags always present in tests and make a requirement that test packages withxconvey
must NOT importginkgo
. The result is that we can not mixgomega
withxconvey
tests in the same package.
Conclusion
In the end, it works well. We can use goconvey
to write tests with gomega
. The code is clean and easy to read. We have the best of both worlds: keep using the familiar set of assertions while having the flexibility of goconvey
.
Also published at OliverNguyen.io/w/goconvey.gomega
Want to Connect?
Connect with me twitter.com/@_OliverNguyen. I share information about
software development, JavaScript, Go, and other interesting things I learn.