Contents
Plain go test carries you a long way — until you need setup that runs before every test, related cases grouped under a shared context, and output you can actually read. At that point most of us start hand-rolling lifecycle helpers and squinting at walls of --- PASS lines. In the next 10 minutes you’ll go from go install to a structured test suite with lifecycle hooks, BDD-style grouping, and spec output — all running on standard go test.
No prior gotest knowledge required. You need Go 1.25+ and a terminal.
Install gotest
gotest is a single binary. Install it with go install:
go install github.com/mvrahden/go-test/cmd/gotest@latestVerify the installation:
gotest versionSet up a project
Create a small Go module to work with. We will build a counter package and test it:
mkdir counter-demo && cd counter-demo
go mod init counter-demoWrite a simple counter type:
package counter
type Counter struct {
value int
}
func New() *Counter { return &Counter{} }
func (c *Counter) Value() int { return c.value }
func (c *Counter) Inc() { c.value++ }
func (c *Counter) Dec() { c.value-- }
func (c *Counter) Reset() { c.value = 0 }Nothing special so far. Now let’s test it.
Write a test suite
A gotest suite is a Go struct whose name ends in TestSuite. Test methods are pointer-receiver methods whose names start with Test. That is the entire API — naming conventions.
Add the gotest library to your module and create a test file:
go get github.com/mvrahden/go-test/pkg/gotestpackage counter
import "github.com/mvrahden/go-test/pkg/gotest"
type CounterTestSuite struct {
c *Counter
}
func (s *CounterTestSuite) BeforeEach(t *gotest.T) {
s.c = New()
}
func (s *CounterTestSuite) TestInc(t *gotest.T) {
s.c.Inc()
gotest.Equal(t, 1, s.c.Value())
}
func (s *CounterTestSuite) TestDec(t *gotest.T) {
s.c.Inc()
s.c.Inc()
s.c.Dec()
gotest.Equal(t, 1, s.c.Value())
}
func (s *CounterTestSuite) TestReset(t *gotest.T) {
s.c.Inc()
s.c.Inc()
s.c.Inc()
s.c.Reset()
gotest.Equal(t, 0, s.c.Value())
}A few things to notice:
CounterTestSuiteends inTestSuite, so gotest recognizes it as a suite.BeforeEachruns before every test method. Each test gets a freshCounter— no shared state between tests.- Assertions are standalone generic functions:
gotest.Equal(t, expected, actual). They stop the test on failure (like testify’sRequire). Type mismatches are caught at compile time. - No boilerplate. No
suite.Run(t, new(...)), no embedded base struct, no registration call. The struct name and method names are the entire declaration.
Run the suite
Use the gotest command instead of go test:
gotest ./...Behind the scenes, gotest reads your source files with go/parser, generates bridge functions that connect your suite to go test, injects them via Go’s -overlay flag, and runs go test. The generated code never touches your source tree. What runs is standard go test, what you see is standard go test output.
You should see all three tests pass:
ok counter-demo 0.3s
Add BDD structure
Flat test methods work, but as the number of tests grows, grouping related assertions under a shared context makes them easier to read. gotest provides t.When() for context and t.It() for behavioral expectations. Both are thin wrappers around t.Run.
Let’s rewrite the suite with BDD structure:
package counter
import "github.com/mvrahden/go-test/pkg/gotest"
type CounterTestSuite struct {
c *Counter
}
func (s *CounterTestSuite) BeforeEach(t *gotest.T) {
s.c = New()
}
func (s *CounterTestSuite) TestInc(t *gotest.T) {
t.When("incrementing once", func(w *gotest.T) {
s.c.Inc()
w.It("has value 1", func(it *gotest.T) {
gotest.Equal(it, 1, s.c.Value())
})
})
t.When("incrementing twice more", func(w *gotest.T) {
s.c.Inc()
s.c.Inc()
w.It("has value 3", func(it *gotest.T) {
gotest.Equal(it, 3, s.c.Value())
})
})
}
func (s *CounterTestSuite) TestDec(t *gotest.T) {
t.When("decrementing from 2", func(w *gotest.T) {
s.c.Inc()
s.c.Inc()
s.c.Dec()
w.It("has value 1", func(it *gotest.T) {
gotest.Equal(it, 1, s.c.Value())
})
})
t.When("decrementing past zero", func(w *gotest.T) {
s.c.Dec()
s.c.Dec()
w.It("goes negative", func(it *gotest.T) {
gotest.Equal(it, -1, s.c.Value())
})
})
}
func (s *CounterTestSuite) TestReset(t *gotest.T) {
t.When("resetting after increments", func(w *gotest.T) {
s.c.Inc()
s.c.Inc()
s.c.Inc()
s.c.Reset()
w.It("returns to zero", func(it *gotest.T) {
gotest.Equal(it, 0, s.c.Value())
})
})
}The structure reads like a specification: Counter → Inc → when incrementing once → it has value 1. Each When block establishes a context (the setup and action), and each It block makes an assertion about the outcome.
One rule to internalize: BeforeEach runs once per test method, not once per When block. Within a method, the When blocks execute in order and share the suite’s state — that is why the second block says “twice more” and asserts 3: it continues from the counter the first block left at 1, and TestDec’s last block needs two Dec calls to get below zero. If two contexts need genuinely independent state, put them in separate test methods.
Run it again:
gotest ./...Render the spec output
The test names encode a behavioral specification. The gotest spec command renders it as a readable tree:
gotest spec ./...Output:
Counter (<1ms) Inc (<1ms) incrementing once (<1ms) ✓ has value 1 (<1ms) incrementing twice more (<1ms) ✓ has value 3 (<1ms) Dec (<1ms) decrementing from 2 (<1ms) ✓ has value 1 (<1ms) decrementing past zero (<1ms) ✓ goes negative (<1ms) Reset (<1ms) resetting after increments (<1ms) ✓ returns to zero (<1ms)
This is generated from the test names and When/It labels. The suite name is stripped of its TestSuite suffix, method names lose their Test prefix, and the hierarchy is indented. The result is a behavioral specification that non-developers can read.
You can also run
gotest ./... --specto see spec output instead of the normal test results. The--specflag redirects the entire run through the spec renderer.
Add a second suite
Multiple suites can coexist in the same package. Each suite runs in its own OS process, so they are completely isolated from each other. Let’s add a suite that tests boundary behavior:
package counter
import "github.com/mvrahden/go-test/pkg/gotest"
type BoundaryTestSuite struct {
c *Counter
}
func (s *BoundaryTestSuite) BeforeEach(t *gotest.T) {
s.c = New()
}
func (s *BoundaryTestSuite) TestNewCounter(t *gotest.T) {
t.It("starts at zero", func(it *gotest.T) {
gotest.Equal(it, 0, s.c.Value())
})
}
func (s *BoundaryTestSuite) TestResetIdempotent(t *gotest.T) {
t.When("resetting a counter that is already zero", func(w *gotest.T) {
s.c.Reset()
w.It("stays at zero", func(it *gotest.T) {
gotest.Equal(it, 0, s.c.Value())
})
})
}Run gotest spec ./... again and both suites appear in the output:
Boundary (<1ms) NewCounter (<1ms) ✓ starts at zero (<1ms) ResetIdempotent (<1ms) resetting a counter that is already zero (<1ms) ✓ stays at zero (<1ms) Counter (<1ms) Inc (<1ms) incrementing once (<1ms) ✓ has value 1 (<1ms) incrementing twice more (<1ms) ✓ has value 3 (<1ms) Dec (<1ms) decrementing from 2 (<1ms) ✓ has value 1 (<1ms) decrementing past zero (<1ms) ✓ goes negative (<1ms) Reset (<1ms) resetting after increments (<1ms) ✓ returns to zero (<1ms)
Both suites ran in separate processes, concurrently. Neither can affect the other, even if one panics or leaks goroutines.
Focus on one test
During development, you often want to run just one suite or one test method. Prefix the name with F_:
func (s *CounterTestSuite) F_TestDec(t *gotest.T) {
// only this test method runs
}Only focused items run. Remove the F_ prefix when you’re done. If you forget and push it, gotest --ci will fail the build — the CI guard catches committed focus prefixes.
Similarly, X_ excludes a test:
func (s *CounterTestSuite) X_TestReset(t *gotest.T) {
// this test is skipped
}Both prefixes work on suites too: F_CounterTestSuite runs only that suite.
Use watch mode
For a tight feedback loop during development, use gotest watch:
gotest watch ./...Every time you save a file, gotest re-runs only the affected packages. Combine with F_ focus to iterate on a single test in under a second.
What to explore next
You’ve covered the core workflow: suites, lifecycle hooks, BDD structure, spec output, focus, and watch mode. Here’s where to go deeper:
- Fixtures. Package-scoped resources with lifecycle management and DAG-based dependencies. See Test Fixtures in Go.
- Assertions. The full assertion API:
Equal,NoError,Contains,Len,InDelta,ErrorIs,Eventually,Consistently, and more. See the Reference. - Migration. If you have existing testify/suite tests,
gotest migrate ./...converts them automatically. See Migrating from testify/suite. - Parallel execution. Suite-level parallelism is on by default (each suite is its own process). For method-level parallelism within a suite, see the lifecycle reference.
- CI integration. Failure-focused summaries, GitHub PR annotations, and coverage gates. See gotest in CI.
- VS Code extension. Test Explorer, CodeLens, coverage gutters, watch mode, and debug support. Install from the Marketplace.
Scaffold your first suite today.
go install github.com/mvrahden/go-test/cmd/gotest@latest