ezpkg.io/conveyz: Understanding the Implementation of FConvey
The FConvey
function is a standout feature of ezpkg.io/conveyz, designed to simplify focused testing in Go. Unlike the original smartystreets/goconvey framework, which requires developers to set FocusConvey
at every nested block level, FConvey
is smarter. It ensures that focusing on a single block propagates to all relevant nested and parent blocks, streamlining the debugging process.
In this article, we dive deep into the implementation of FConvey
, why the original FocusConvey
does require manual setting at every level, and how FConvey
solves the problem.
The motivation behind ezpkg.io/conveyz
Before we dive into the implementation details, let’s first understand the motivation behind the creation of ezpkg.io/conveyz.
Ginkgo and Gomega are great, but verbose
At work, we use gomega and ginkgo for writing behaviour tests. They are great, but sometimes too verbose. We have to write a lot of boilerplate code to set up the test environment, and it’s hard to focus on the test itself.
- Each package has a
suite_test.go
file to set up the test environment, withRegisterFailHandler(Fail)
andRunSpecs()
. - Each file starts with
Describe
block thenvar
block, thenContext
blocks, then morevar
blocks, then moreContext
blocks, withBeforeEach
andAfterEach
blocks inside, then end up with a lot ofIt
blocks at the leaf. - Lists of variables are declared at the beginning of the
Describe
andContext
blocks, then initialized inBeforeEach
blocks, before being used inIt
blocks, orJustBeforeEach
blocks. - And even
JustBeforeEach
blocks to keep the execution logic separated. - So the execution is outer blocks to inner blocks:
Describe
thenBeforeEach
thenContext
thenBeforeEach
thenIt
.
Oh, no! It needs to jump toJustBeforeEach
beforeIt
. Finally,AfterEach
at the last. - And we have to repeat the same setup for each test file.
Well, you got it!
An example of profile_suite_test.go
:
package profile_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestProfile(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Profile Suite")
}
and user_test.go
:
package user_test
import (
"context"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
profilepb "connectly.ai/go/pb/profile"
"connectly.ai/go/services/profile"
"connectly.ai/go/services/profile/mocks"
)
var _ = Describe("UserService", func() {
var (
ctx = context.Background()
ctrl *gomock.Controller
mockRepo *mocks.MockProfileRepository
service profilepb.UserServiceServer
)
BeforeEach(func() {
var err error
ctrl = gomock.NewController(GinkgoT())
mockRepo = mocks.NewMockProfileRepository(ctrl)
service = profile.NewUserService(mockRepo)
})
AfterEach(func() {
ctrl.Finish()
})
Describe("SignUp", func() {
var (
req *profilepb.SignUpRequest
res *profilepb.SignUpResponse
err error
)
JustBeforeEach(func() {
res, err = service.SignUp(username, password)
})
BeforeEach(func() {
req = &profilepb.SignUpRequest{
Username: "admin",
Password: "password123",
}
})
Context("valid username & password", func() {
BeforeEach(func() {
mockRepo.EXPECT().
CreateUser(ctx, req.Username, req.Password).
Return(/*...*/)
})
It("should success", func() {
Ω(err).ToNot(HaveOccurred())
Ω(res.UserID).ToNot(BeEmpty())
})
})
Context("invalid username", func() {
BeforeEach(func() {
req.Password = "password123"
})
It("should error", func() {
Ω(err).To(HaveOccurred())
Ω(err.Error()).To(ContainSubstring("invalid username"))
})
})
Context("invalid password: too short", func() {
BeforeEach(func() {
req.Password = "****"
})
It("should error", func() {
Ω(err).To(HaveOccurred())
Ω(err.Error()).To(ContainSubstring("password too short"))
})
})
})
})
Convey: Focus on the test, not the setup
Compare the above code with the following from smartystreets/goconvey. It’s much simpler and easier to focus on the test itself.
- No need to set up the test environment in a separate file.
- You only need to use
Convey
andSo
to write the test. - The execution is straightforward:
Convey
thenConvey
thenConvey
thendefer()
. - Each run, it executes from the root block to each leaf block:
UserService → SignUp → valid username & password → should success → defer()
UserService → SignUp → invalid username → should error → defer()
package profile_test
import (
"context"
"testing"
. "github.com/smartystreets/goconvey/convey"
"go.uber.org/mock/gomock"
profilepb "connectly.ai/go/pb/profile"
"connectly.ai/go/services/profile"
"connectly.ai/go/services/profile/mocks"
)
func TestUserSignUp(t *testing.T) {
Convey("UserService", t, func() {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockProfileRepository(ctrl)
service := profile.NewSignUpService(mockRepo)
defer ctrl.Finish()
Convey("SignUp", func() {
req = &profilepb.SignUpRequest{
Username: "admin",
Password: "password123",
}
Convey("valid username & password", func() {
mockRepo.EXPECT().
CreateUser(ctx, req.Username, req.Password).
Return(/*...*/)
res, err = service.SignUp(ctx, req)
Convey("should success", func() {
So(err, ShouldBeNil)
So(res.UserID, ShouldNotBeEmpty)
})
})
Convey("invalid username", func() {
req.Username = "admin@"
res, err = service.SignUp(ctx, req)
Convey("should error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "invalid username")
})
})
Convey("invalid password: too short", func() {
req.Password = "****"
res, err = service.SignUp(ctx, req)
Convey("should error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "invalid password")
})
})
})
})
}
Problems: Incompatible assertions and repeated FocusConvey
While the code looks cleaner, there are still some problems:
- With ginkgo, you can focus on a single block by setting
FIt
orFContext
. Basically, add a singleF
and it will only run that block (and children), skipping others.
But in goconvey, you have to setFocusConvey
at every level. It’s not smart enough to propagate the focus to all relevant blocks. - And the assertions are not compatible. You have to change
Ω
toSo
,To
toShould
,ToNot
toShouldNot
,ToNotBeEmpty
toShouldNotBeEmpty
,To(HaveOccurred())
toShouldNotBeNil
,To(ContainSubstring("..."))
toShouldContainSubstring("...")
, and so on.
We don’t want to maintain two sets of assertions, and it’s hard to convince the team to switch to goconvey or even doing the rewrite.
Meet ezpkg.io/conveyz
So, I decided to create a new package, ezpkg.io/conveyz, to solve the above problems. It’s a wrapper around goconvey, with the following features:
Ω
to replaceSo
andTo
in goconvey, so you can use the same assertions as gomega.FConvey
to focus on a single block and propagate to all relevant blocks while skipping others.
The above code can be rewritten as:
package profile_test
import (
"context"
"testing"
. "ezpkg.io/conveyz"
"go.uber.org/mock/gomock"
profilepb "connectly.ai/go/pb/profile"
"connectly.ai/go/services/profile"
"connectly.ai/go/services/profile/mocks"
)
func TestUserSignUp(t *testing.T) {
Convey("UserService", t, func() {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockProfileRepository(ctrl)
service := profile.NewSignUpService(mockRepo)
defer ctrl.Finish()
Convey("SignUp", func() {
req = &profilepb.SignUpRequest{
Username: "admin",
Password: "password123",
}
Convey("valid username & password", func() {
mockRepo.EXPECT().
CreateUser(ctx, req.Username, req.Password).
Return( /*...*/ )
res, err = service.SignUp(ctx, req)
Convey("should success", func() {
Ω(err).ToNot(HaveOccurred)
Ω(res.UserID).ToNot(BeEmpty)
})
})
Convey("invalid username", func() {
req.Username = "admin@"
res, err = service.SignUp(ctx, req)
Convey("should error", func() {
Ω(err).To(HaveOccurred())
Ω(err.Error()).To(ContainSubstring, "invalid username")
})
})
Convey("invalid password: too short", func() {
req.Password = "****"
res, err = service.SignUp(ctx, req)
Convey("should error", func() {
Ω(err).To(HaveOccurred())
Ω(err.Error()).To(ContainSubstring("password too short"))
})
})
})
})
}
Solution: Make “gomega” Ω
work with “goconvey”
We can make Ω
work with goconvey by creating an adapter that implements gomega.Assertion:
- The adapter will call
convey.So()
with the actual value and the matcher. - It returns empty string if the matcher passes, or the error message if the matcher fails.
package conveyz
import (
"github.com/onsi/gomega"
gomegatypes "github.com/onsi/gomega/types"
"github.com/smartystreets/goconvey/convey"
)
// Ω is a reimplemented version of gomega.Expect to call goconvey.So under the hood.
func Ω(actual any, extra ...any) gomega.Assertion {
assertion := gomega.Expect(actual, extra...)
return gomegaAssertion{actual: actual, assertion: assertion}
}
type gomegaAssertion struct {
actual any
offset int
assertion gomega.Assertion
}
func (a gomegaAssertion) To(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
stack := stacktracez.StackTraceSkip(4)
return formatMsg(optionalDescription, stack, "%vUNEXPECTED: %v%v", colorz.Red, err, colorz.Yellow)
}
if !success {
stack := stacktracez.StackTraceSkip(4)
msg := matcher.FailureMessage(a.actual)
return formatMsg(optionalDescription, stack, "%s", msg)
}
return ""
})
return true
}
func (a gomegaAssertion) ToNot(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
stack := stacktracez.StackTraceSkip(4)
return formatMsg(optionalDescription, stack, "%vUNEXPECTED: %v%v", colorz.Red, err, colorz.Yellow)
}
if success {
stack := stacktracez.StackTraceSkip(4)
msg := matcher.NegatedFailureMessage(a.actual)
return formatMsg(optionalDescription, stack, "%s", msg)
}
return ""
})
return true
}
When failure, the output will look like this, with a nice error message and a focused stacktrace:
Failures:
* /Users/i/conn/go/services/profile/user_test.go
Line 160:
Expected
<bool>: true
to be false
go/sdks/errors/api/error_test.go:160 · api.TestUser.func1.2.1.1
go/sdks/errors/api/error_test.go:155 · api.TestUser.func1.2.1
go/sdks/errors/api/error_test.go:94 · api.TestUser.func1.2
go/sdks/errors/api/error_test.go:63 · api.TestUser.func1
go/sdks/errors/api/error_test.go:36 · api.TestUser
120 total assertions
--- FAIL: TestUser (0.01s)
And to make it easier for our engineers, we can re-export all the assertions from gomega:
func BeNil() types.GomegaMatcher {
return gomega.BeNil()
}
func BeTrue() types.GomegaMatcher {
return gomega.BeTrue()
}
func BeFalse() types.GomegaMatcher {
return gomega.BeFalse()
}
So to migrate tests from goconvey to ezpkg.io/conveyz, you don’t need to change the assertions, just do some cleanup:
- Replace the gomega/ginkgo imports with
. "ezpkg.io/conveyz"
. - Replace all
Describe
|Context
|It
withConvey
. - Remove
BeforeEach
and merge it with thevar
block to simplify the setup code. - Replace
AfterEach
with idiomatic Godefer
. - For
JustBeforeEach
, you can simply inline the code right before the assertion. If it needs more logic, you can extract to a function and call it right before the assertion.
This way, we can enjoy the best of both worlds: the simplicity of goconvey with the power of gomega!
But there is still one problem: to focus on a single Convey
block, you have to set FocusConvey
at every level, from the outermost Convey
to the innermost Convey
. Then delete each one after finishing your tests… 😮💨
Solution: Make FConvey
smarter
To understand how “ezpkg.io/conveyz” FConvey
works, let’s first look at how “ginkgo” FIt
works and why “goconvey” FocusConvey
requires manual setting at every level.
ginkgo: How a single FIt
can focus on a test
In ginkgo, inside each Describe
|Context
block, you can set FIt
or FContext
to focus on that block.
For example, with the following code: When you set FIt("should error (invalid username)")
, it will only exec that test and skip others.
Specifically, it execs in this order (🟢 for exec and 🟡 for skip):
- Start with 🟢
Describe("UserService")
→ 🟢BeforeEach
→ 🟢Context("SignUp")
→ 🟢BeforeEach
- → 🟡 skip
Context("valid username & password")
→ 🟡 skipBeforeEach
inside → 🟡 skip allIt
s inside - → 🟢 exec
Context("invalid username")
→ 🟢BeforeEach
- → 🟡 skip
It("should record failure")
- → ✅ exec
FIt("should error (invalid username)")
- → 🟡 skip
- → 🟡 skip all remaining non-focus
Context
s
It can be able to do that, because when run the Context("valid username & password")
, it calls BeforeEach
, but that BeforeEach
does not actually run any logic inside - it only registers the logic to execute later.
Then there are 2 scenarios:
- If ginkgo does not encounter any
FIt
(orPIt
for pending),
it will 🟢 execute all the registered logic. - If ginkgo encounters an
FIt
, it will only 🟢 execute thatIt
(and all logic to that path),
but 🟡 skip all other registered logic outside the execution path.
// 🟢 start with Describe
Describe("UserService", func() {
// 🟢 execute this BeforeEach block
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockRepo = mocks.NewMockProfileRepository(ctrl)
})
// 🟢 execute this Context
Context("SignUp", func() {
var req *SignUpRequest
// 🟢 execute this BeforeEach block
BeforeEach(func() {
req = &SignUpRequest{
Username: "admin",
Password: "password123",
}
})
// 🟡 all non-focused Context are skipped
Context("valid username & password", func() {
// 🟡 skip this BeforeEach block
BeforeEach(func() {
mockRepo.EXPECT().
CreateUser(ctx, req.Username, req.Password).
Return(/*...*/)
})
// 🟡 skip this It
It("should success", func() {
// ...
})
})
Context("invalid username", func() {
// 🟢 execute this BeforeEach block
BeforeEach(func() {
req.Username = "admin@"
})
// 🟡 skip this It
It("should record failure", func() {
// ...
})
// ✅👉 put FIt here to focus on the test 👈
FIt("should error (invalid username)", func() {
// ...
})
})
// 🟡 all non-focused Context are skipped
Context("invalid password: too short", func() {
BeforeEach(func() {
req.Password = "****"
})
It("should error (password too short)", func() {
// ...
})
})
})
// 🟡 all non-focused Context are skipped
Context("Login", func() {
Context("valid login", func() {
// ...
})
})
})
But “goconvey” FocusConvey
must be set at every level
Because if you only set FocusConvey
at the leaf level without setting it at the parent level, it will not skip the parent level.
- When it runs a parent
Convey
block, it will run all the code inside, including the before-each logic (theBeforeEach
blocks in previous “ginkgo” example). - Unknown “ginkgo”, which registers the before-each logic inside
BeforeEach
blocks without actually execute it, “goconvey” does not have that privilege:
It has to run theConvey
block to know if there is anyFocusConvey
inside!
So you get idea: No way to know if there is any FocusConvey
inside without actually running the Convey
block, including the before-each logic!
// 🟢 start with Convey
Convey("UserService", func() {
// 🟢 execute this "BeforeEach" block
ctrl = gomock.NewController(GinkgoT())
mockRepo = mocks.NewMockProfileRepository(ctrl)
// 🟢 execute this Context
Convey("SignUp", func() {
// 🟢 execute this BeforeEach block
req := &SignUpRequest{
Username: "admin",
Password: "password123",
}
// 🟡 all non-focused Convey are skipped
Convey("valid username & password", func() {
// 🟡 skip this "BeforeEach" block
// ❌ but how? goconvey does not have BeforeEach to
// register the logic beforehand without actually execute it ❌
// ❌ the Convey still needs to run to know if there is any FocusConvey inside ❌
mockRepo.EXPECT().
CreateUser(ctx, req.Username, req.Password).
Return(/*...*/)
// 🟡 skip this Convey
Convey("should success", func() {
// ...
})
})
Convey("invalid username", func() {
// 🟢 run this "BeforeEach" block
req.Username = "admin@"
// 🟡 skip this Convey
// ❌ but how? it still needs to run it to know if there is any FocusConvey inside ❌
Convey("should record failure", func() {
// ...
})
// ✅👉 put FocusConvey here to focus on the test 👈
FocusConvey("should error (invalid username)", func() {
// ...
})
})
// 🟡 all non-focused Convey are skipped
Convey("invalid password: too short", func() {
req.Password = "****"
Convey("should error (password too short)", func() {
// ...
})
})
})
// 🟡 all non-focused Convey are skipped
Convey("Login", func() {
Convey("valid login", func() {
// ...
})
})
})
ezpkg.io/conveyz: Set a single FConvey
to focus on a test
Okay, now we know why “goconvey” FocusConvey
requires manual setting at every level. But how does “ezpkg.io/conveyz” FConvey
workaround that limitation? What’s the magic behind it?
Turns out, it’s quite simple: Instead of running the Convey
blocks, we can first read the source code and find all the FConvey
(or SConvey
) inside! When we know locations of all the Convey
blocks, we can set the execution focus only on the path to the FConvey
block, and skip all others. ✅
Now you know the trick, it’s time to see the implementation of FConvey
in “ezpkg.io/conveyz”:
Read the source code for the current package
The first time any Convey
|FConvey
|SkipConvey
, we need to initialize everything: read the source code and find all FConvey
.
To read the source code, we can use runtime.Caller()
to get the current file path:
_, currentFile, _, ok := runtime.Caller(2)
Then read all the test files and store in to a fileMap
:
var fileMap = map[string]*testFile{}
func parseDir(dir string) (detected bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
if strings.IndexAny(entry.Name(), "_.") == 0 {
continue // files start with "_" or "." are ignored
}
path := filepath.Join(dir, entry.Name())
data := must(os.ReadFile(path))
file, _detected := parseFile(path, data)
fileMap[path] = file
detected = detected || _detected
}
return detected
}
Find any FConvey
inside the source code
To find any FConvey
inside the source code, we can simply read the file line by line and check if it contains FConvey(
or FocusConvey(
:
func parseFile(path, data string) (_ *testFile, detected bool) {
tf := &testFile{Path: path}
for _, line := range strings.Split(data, "\n") {
tl := testLine{
Indent: countTabs(line),
Text: line,
}
if strings.Contains(line, "FConvey(") || strings.Contains(line, "FocusConvey(") {
detected = true
tl.FConvey = true
} else if strings.Contains(line, "Convey(") {
tl.Convey = true
}
tf.Lines = append(tf.Lines, tl)
}
return tf, detected
}
Convert each Convey
to FocusConvey
or SkipConvey
The case for no FConvey
in all files is trivial: no need to do anything.
But if there is any FConvey
in any file, we need to convert the Convey
block to FocusConvey
or SkipConvey
to only execute the relevant blocks on the focus path and skip others.
func Convey(items ...any) {
defer setupGomega(items...)()
if !pkgHasFocusConvey() {
convey.Convey(items...)
return
}
switch shouldConvert() {
case 1:
convey.FocusConvey(items...)
case -1:
convey.SkipConvey(items...)
default:
convey.Convey(items...)
}
}
Implement shouldConvert()
Now we have all the FConvey
locations, let’s define some rules to know which Convey
should be executed and which should be skipped:
Case 1: If there is no FConvey
in the current file: it means the focused block is in another file, we can simply return -1
to convert the whole Convey
block to SkipConvey
.
Case 2: If there is any FConvey
in the current file: we need to check if any parent or child block has FocusConvey
(or FConvey
), and convert the Convey
block to FocusConvey
if it has.
Convey -> 🟢 convert to FocusConvey (parent)
Convey -> leave it as is
Convey -> leave it as is
FConvey -> ✅ convert to FocusConvey (.)
Convey -> 🟢 convert to FocusConvey (child)
Convey -> leave it as is
Convey -> leave it as is
The final code looks like this:
func shouldConvert() (out int) {
_, file, lineNumber, ok := runtime.Caller(2)
if !ok {
return 0
}
tf := fileMap[file]
if tf == nil {
return 0 // leave it as is
}
lineIndex := lineNumber - 1
{
// CASE 2: detect FocusConvey in child or parent blocks
// -> return 1: convert the Convey block to FocusConvey
line := tf.Lines[lineIndex]
// check if the child code lines inside this convey block has FocusConvey (or FConvey)
for i := lineNumber; i < len(tf.Lines); i++ {
if tf.Lines[i].Indent <= line.Indent && tf.Lines[i].Indent != 0 /* skip empty lines */ {
break
}
if tf.Lines[i].FConvey {
return 1 // convert to FocusConvey
}
}
// check if the parent code lines of this convey block has FocusConvey (or FConvey)
for parent := getParent(tf, lineIndex); parent > 0; parent = getParent(tf, parent) {
if tf.Lines[parent].FConvey {
return 1 // convert to FocusConvey
}
}
}
{
// CASE 1: detect if there is no FConvey or FocusConvey in the whole file
// -> return -1: convert the Convey block to SkipConvey
detected := false
for _, line := range tf.Lines {
if line.FConvey {
detected = true
break
}
}
if !detected {
return -1 // convert to SkipConvey
}
}
return 0
}
func getParent(tf *testFile, lineIndex int) int {
line := tf.Lines[lineIndex]
for i := lineIndex - 1; i >= 0; i-- {
if tf.Lines[i].Indent == 0 {
// skip empty lines
continue
}
if tf.Lines[i].Indent >= line.Indent {
// skip lines with same or greater indent
continue
}
if tf.Lines[i].Indent < line.Indent {
// found the parent
return i
}
}
return 0
}
And that’s it! Now you know how “ezpkg.io/conveyz” FConvey
works, and why it fixes the problem of “goconvey” FocusConvey
!
The best of both worlds
With ezpkg.io/conveyz, you can enjoy the simplicity of “goconvey” and the power of “gomega” in a single package. You can focus on the test itself, not the setup, and use the same assertions as gomega. And with FConvey
, you can focus on a single block and propagate to all relevant nested and parent blocks, streamlining the debugging process.
It also comes with other small and nice things like:
- Cleaner and focused stacktrace with better message.
FConvey
|SConvey
makego test
failure as default so you don’t accidentally skip the test.- SkipConveyAsTODO to actually skip the test without failing the
go test
.
So, what are you waiting for? Give ezpkg.io/conveyz a try and let me know what you think! Feel free to open an issue or submit a pull request.
I’m always happy to hear from you! 🚀
Let's stay connected!
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 , , , , or subscribe to my posts.