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@latest

Verify the installation:

gotest version

Set 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-demo

Write a simple counter type:

counter.go
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/gotest
counter_test.go
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) {
    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:

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:

output
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:

counter_test.go
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:

gotest spec ./...
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 ./... --spec to see spec output instead of the normal test results. The --spec flag 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:

boundary_test.go
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:

gotest spec ./...
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:

Scaffold your first suite today.

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