Making “goconvey” Work With “gomega” Assertion

Simplify writing tests

Oliver Nguyen
Better Programming
Published in
7 min readJan 4, 2023

--

Photo by Safar Safarov on Unsplash

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 for convey.Convey(). It calls conveyGomegaSetup() to setup gomega before calling convey.Convey().
  • gomega requires *testing.T and Fail() to be set up before using Ω(). goconvey requires *testing.T at the top level Convey(). Our function conveyGomegaSetup() detects the top level Convey() then sets up necessary things for gomega.
  • Ωx() is a wrapper around gomega.Expect(). It returns a gomega.Assertion object. We can use To(), ToNot() and other assertion methods on it to make assertions with gomega matchers.
  • Our CI runs go test -v ./... -ginkgo.v. I have to create a workaround to make the command work with xconvey/non-ginkgo package. Without the import of ginkgo, the command with fail (error: flag ginkgo.v is not defined). But ginkgo 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 with xconvey must NOT import ginkgo. The result is that we can not mix gomega with xconvey 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.

--

--

Oliver Nguyen
Oliver Nguyen

Written by Oliver Nguyen

A software developer sharing about Go and JavaScript ⎯ olivernguyen.io

No responses yet