Make goconvey work with gomega assertion
At work, we use gomega
for testing. It works well, until one day it becomes too verbose and goconvey
becomes 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:
- 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 2.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. Beside writing tests, it also has a nice web server to display the results with coverage report. 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 it’s not quite a problem for a long time, and we are still happily dealing with it.
Until one day, we need to test a function with so many execution branches. Suddenly the test code with gomega becomes 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:
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, not easy to edit, read and review.
But with goconvey
, the experience is so smooth. We just need to add another level of Convey()
. Existing lines do not need to be changed:
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 how 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 usage:
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 in 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 subsequence 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.
Author
I'm Oliver Nguyen. A software maker working mostly in Go and JavaScript. I enjoy learning and seeing a better version of myself each day. Occasionally spin off new open source projects. Share knowledge and thoughts during my journey. Connect with me on , , , and .