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 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 in 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 subsequence 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.

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 .

Back Back