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:
- No structural grouping. The stdlib gives you
func TestXandt.Runfor subtests. But subtests are closures inside flat functions, not first-class groups. If you have 30 test functions forUserServiceand 20 forOrderServicein the same package, they are one flat list. There is no way to say “these tests belong together” at the structural level. - No lifecycle hooks. There is no built-in “run this before each test” or “run this once before the suite.”
TestMaingives you one entry point per package, but it runs before all tests and has no per-test granularity.t.Cleanupruns after a test, but there is no corresponding setup hook. Go Test Lifecycle walks through what the stdlib gives you and where it stops. - No fixture management. If three test functions need a Postgres container, each one either starts its own (slow) or they share a package-level variable (fragile). The stdlib has no concept of “this resource has a lifecycle and these tests depend on it.”
- No cross-package sharing.
go testruns each package as a separate OS process. There is no way forpkg/userandpkg/orderto share a database container through Go code. Most teams fall back to Makefiles or CI scripts to start infrastructure before running tests. Sharing Test Fixtures Across Go Packages looks at this problem in depth. - No structure in output. Test names like
TestCreateUser_WhenEmailInvalid_ReturnsErrorencode meaning in underscores. Butgo test -vrenders them as flat lines. There is no hierarchical view that shows what a test suite covers at a glance.
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:
- Runtime reflection. testify/suite discovers test methods with
reflectat runtime. A typo in a lifecycle method name (SetUpTestinstead ofSetupTest) silently does nothing. The wrong method signature silently does nothing. You find out when your test passes for the wrong reason, not when you compile. - Registration boilerplate. Every suite needs a
func TestX(t *testing.T) { suite.Run(t, new(MySuite)) }wrapper. It is one line, but it is one line per suite that exists only to satisfy the framework. - Framework coupling. Every suite must embed
suite.Suite. Assertions go throughs.Equal,s.NoError, which are methods on the embedded struct, not standalone functions. Removing the framework means rewriting every assertion in every test. - No fixture graph. testify/suite has no concept of fixture dependencies. If suite A and suite B both need a database, and the database needs a container, the developer manages that wiring manually.
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:
- A struct whose name ends in
TestSuiteis a test suite. - A method whose name starts with
Testis a test case. - A method named
BeforeEachruns before every test. - A struct whose name ends in
Fixtureis a package fixture. - A struct whose name ends in
SharedFixtureis a cross-package shared fixture. - A method prefixed with
F_is focused (only focused tests run). A method prefixed withX_is excluded.
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:
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:
- Test suites are plain structs with
TestSuitesuffix, not types that embed a framework base. Lifecycle hooks are methods with conventional names, not interface implementations. More on organizing tests. - Parallel execution is safe by default at the suite level: each suite runs as a separate OS process, so suites cannot interfere with each other. Method-level parallelism is opt-in via
SuiteConfig{Parallel: true}, with a returningBeforeEachgiving each test its own isolated state. More in the reference. - Fixtures are structs with
Fixturesuffix. Dependencies between fixtures are expressed as pointer fields, forming a DAG that the generator resolves automatically. Shared fixtures cross the process boundary through JSON serialization. More on fixtures. - BDD structure uses
t.When()andt.It()method calls that map directly tot.Run. Thegotest speccommand renders this structure as a behavioral specification, because the hierarchy is already there in the test code. More on BDD-style tests. - AST-based discovery reads source files as syntax trees and never executes them. This keeps the generation step fast and means no user code runs during generation. More on code generation.
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