Contents

Go’s testing package is one of the best things about the language: no framework to install, no annotations to learn, just func Test* and go test. But past a few dozen test files, most projects hit the same wall. There is no way to group related tests, no per-test setup hook, and no way for two packages to share a database container.

That is not because the design is wrong, but because testing is deliberately minimal. It gives you a test runner and an assertion-free T type. Everything else is left to the developer: organizing tests into groups, managing setup and teardown, sharing expensive infrastructure, expressing what a test means rather than just what it does.

gotest exists to fill those gaps without replacing what works. This post explains the problems it targets, the principles behind its design, and why those principles lead to code generation rather than a runtime framework.

The gaps in Go’s testing package

Most Go projects hit the same set of problems as their test suite grows past a few dozen files:

These are not theoretical concerns. They are the reasons most Go projects beyond a certain size end up either adopting a framework like testify/suite or building an ad-hoc system of helper functions, global variables, and TestMain orchestration.

What testify/suite does (and its trade-offs)

testify/suite is the most widely used answer to these problems. It gives you struct-based test suites with SetupTest/TearDownTest lifecycle hooks, and it works. But it makes trade-offs that gotest is designed to avoid:

testify/suite is a good tool. gotest is not a reaction against it. For a small suite, the registration line is negligible and a build step is not worth adding; testify or plain go test is the right choice there. The trade-offs above matter as a project grows, and looking at them together, they are not independent problems. They share a root cause.

Static discovery instead of reflection

testify/suite discovers tests at runtime through reflection. Once you choose reflection, the rest follows: you need a base type to reflect on (framework coupling), the developer must connect each suite to the test runner (registration boilerplate), and method signatures cannot be validated until the code actually runs (silent failures).

gotest asks a different question: what if discovery happens before the tests run? The suite struct is already in the source code. The method names are already there. A tool that reads Go source files can find everything reflection finds, without running any code, and produce the bridge functions that connect suites to go test. If that tool runs at build time, everything it produces is ordinary Go.

That premise shaped five design principles. They are not aspirations; they are constraints that every feature in gotest must satisfy.

Principle 1: Standard Go output, always

Every generated test is a func Test*(t *testing.T). Every line of output is standard go test output. Every CI system, IDE, coverage tool, and profiler works unchanged.

This is the highest-priority principle, and the others are subordinate to it. gotest does not replace go test. It generates the bridge code that connects your suite structs to go test’s entry point convention. What actually runs is go test, with standard func Test*(t *testing.T) functions that the generator produced.

This means there is no lock-in at the output level. CI pipelines that parse go test -json output do not need to know gotest exists. Coverage tools see standard Go test functions. Race detector, profiler, debugger: all unchanged. The generated code is the same code a careful developer would write by hand; gotest automates the wiring, not the execution.

Principle 2: The naming IS the API

Declaring a suite takes no configuration file, no struct tags, no annotations, and no registration calls. The entire API is naming conventions:

The goal is that a developer reads the naming conventions once and never opens documentation again. If you can read Go, you can read a gotest suite. There is nothing to decode, no interface to look up, no config to cross-reference.

In practice, a suite looks like this:

user_test.go
type UserServiceTestSuite struct {
    db   *sql.DB
    svc  *UserService
}

func (s *UserServiceTestSuite) BeforeEach(t *gotest.T) {
    s.db = setupTestDB(t.T())
    s.svc = NewUserService(s.db)
}

func (s *UserServiceTestSuite) AfterEach(t *gotest.T) {
    s.db.Close()
}

func (s *UserServiceTestSuite) TestCreate(t *gotest.T) {
    t.When("email is valid", func(w *gotest.T) {
        w.It("creates the user", func(it *gotest.T) {
            err := s.svc.Create("alice@example.com")
            gotest.NoError(it, err)
        })
    })
}

There is no registration call, no embedded base type, no interface to implement. The struct name ends in TestSuite, so the generator recognizes it. The method name starts with Test, so it becomes a test case. BeforeEach runs before every test method, and AfterEach cleans up after it. Everything else is plain Go.

The code generator reads these source files with go/parser, walks the AST looking for the naming patterns, and produces the lifecycle wiring. Discovery is purely static: no code runs during the generation step.

Principle 3: Zero runtime cost

At test execution time, there is no discovery or dispatch machinery in your call stack. The runtime surface is thin: the gotest.T wrapper that provides When/It methods and standalone assertion functions like gotest.Equal, plus a small runtime package the generated code calls for timeouts and panic containment. The orchestration itself is generated: struct initialization, t.Run calls, t.Cleanup calls, direct method invocations. No reflection, no interface dispatch, no type assertions in the wiring.

This has practical consequences. Stack traces point to your code, not to framework internals. Refactoring tools can follow the call chain because every call is a direct call. The gotest package that your tests import is a thin layer of helper types and functions; it has no transitive dependencies beyond the standard library.

The code generator runs before go test and produces Go source files. Those files are injected via Go’s -overlay flag, a built-in mechanism that maps virtual file paths to real files on disk. The compiler sees the generated files; your source directory does not. After the run, nothing is left behind. Your git status stays clean.

This extends to isolation. Each suite runs in its own OS process: the package’s test binary is compiled once and executed separately per suite, filtered to just that suite. A panicking test in one suite cannot crash another. Memory is isolated by the OS, not by convention. Goroutine leaks, global state mutations, and port conflicts stay contained to the process that caused them.

This structural isolation is what makes suite-level parallelism safe by default: suites run concurrently without any opt-in, because they cannot interfere with each other.

Principle 4: Invisible until needed

A developer who has never heard of gotest can read a test suite struct and understand what it does. The struct has fields, methods with descriptive names, and assertions that are standalone function calls. There is nothing to decode.

A developer who runs go test directly (without the CLI) loses nothing that was already there: existing flat func Test* tests run exactly as before. The suites simply stay inert — their func Test* entry points exist only in gotest’s overlay, so go test never sees them. The two runners split the work: go test runs the flat tests, gotest runs the suites.

And a developer who decides to stop using gotest can look at the generated code to see exactly what to write by hand. The generated file is a complete, readable Go test file. It is not a binary artifact or a compressed intermediate representation. It is the code you would have written yourself if you had the patience.

Principle 5: Adopt incrementally, eject freely

Existing func Test* tests coexist with suites in the same package. You do not need to convert everything at once. A single suite can live next to 50 flat test functions: go test keeps running the flat functions, gotest runs the suites (and reports the flat tests it leaves to go test), and a complete run is both commands. If you want to try it, Your First Go Test Suite in 10 Minutes is the place to start.

Ejecting is real work, but it is straightforward work. You replace *gotest.T parameters with *testing.T, swap gotest assertions for your preferred alternative, and write the func Test* entry points that the generator was producing for you. The generated code shows exactly what those entry points look like. There is no data to migrate, no configuration to unwind, no runtime state to reconstruct.

This is a deliberate contrast with frameworks that require embedding a base type or implementing an interface. Those create a structural dependency: your test code is framework code. With gotest, your test code is Go code that happens to follow naming conventions. The conventions are what the tool reads; they are not what the code depends on.

Why code generation

The principles above point toward code generation almost by necessity. If you want no reflection, you need a static discovery step. If you want standard go test output, you need standard test functions. If you want naming conventions instead of interfaces, you need a tool that reads source code and produces source code.

Code generation also solves the error-reporting problem that plagues reflection-based frameworks. If a lifecycle method has the wrong signature, the code generator rejects it at generation time with a clear error message, file name, and line number. With reflection, the method is silently ignored at runtime. You discover the problem when your BeforeEach never runs and your tests pass for the wrong reason.

The overlay filesystem injection is what makes this practical. Without it, code generation would mean generated files in your source tree: files to .gitignore, files that clutter your editor, files that go stale if you forget to regenerate. The -overlay flag removes all of that. The generated code exists only during the test run, in a content-addressable cache that handles invalidation automatically.

What adoption costs is the build step itself: gotest runs before go test, both locally and in CI. Go Tests in GitHub Actions shows what that looks like in a pipeline, and Go Test Watch Mode and Focused Tests covers the day-to-day loop once the caching kicks in.

Where each feature goes deeper

These principles are not abstract. They directly shaped every feature in gotest:

Each of these deserves a deeper look. The common thread is the same: gotest is not trying to replace Go’s testing model. It is trying to generate the code that Go’s testing model requires you to write by hand once your project outgrows flat functions and subtests.

See it in action — build your first gotest suite in 10 minutes.

go install github.com/mvrahden/go-test/cmd/gotest@latest