Contents

When a test fails in CI, the first thing you read is its name. And for most Go projects, that name looks like this:

go test -v output
=== RUN   TestCreateUser_WhenEmailInvalid_ReturnsError
--- FAIL: TestCreateUser_WhenEmailInvalid_ReturnsError (0.01s)

You can decode it. TestCreateUser is the subject, WhenEmailInvalid is the condition, ReturnsError is the expectation. But it takes a moment. And when you’re scanning twenty failures across five packages, those moments add up.

This post looks at why Go test output is hard to read at scale, how BDD-style structure helps, and what it takes to turn test runs into something that reads like a specification.

The Go test naming problem

Go’s test runner requires test function names to start with Test and use CamelCase or underscores. This forces you to encode three separate ideas, namely the subject, the condition, and the expectation, into a single identifier:

func TestCreateUser_WhenEmailInvalid_ReturnsError(t *testing.T) { ... }
func TestCreateUser_WhenEmailAlreadyExists_ReturnsDuplicate(t *testing.T) { ... }
func TestCreateUser_WhenInputValid_CreatesUser(t *testing.T) { ... }
func TestCreateUser_WhenInputValid_SendsWelcomeEmail(t *testing.T) { ... }
func TestDeleteUser_WhenUserExists_SoftDeletes(t *testing.T) { ... }
func TestDeleteUser_WhenUserNotFound_ReturnsNotFound(t *testing.T) { ... }

Six test functions, but the structure is invisible. You have to read every name to understand that there are two subjects (CreateUser and DeleteUser), three conditions for create, two for delete, and that two of the create expectations share the same condition.

Subtests improve this somewhat:

func TestCreateUser(t *testing.T) {
    t.Run("when email is invalid", func(t *testing.T) {
        t.Run("returns error", func(t *testing.T) {
            // ...
        })
    })
    t.Run("when input is valid", func(t *testing.T) {
        t.Run("creates the user", func(t *testing.T) {
            // ...
        })
    })
}

The code itself is more structured. But the output isn’t:

go test -v output
=== RUN   TestCreateUser
=== RUN   TestCreateUser/when_email_is_invalid
=== RUN   TestCreateUser/when_email_is_invalid/returns_error
--- PASS: TestCreateUser/when_email_is_invalid/returns_error (0.00s)
--- PASS: TestCreateUser/when_email_is_invalid (0.00s)
=== RUN   TestCreateUser/when_input_is_valid
=== RUN   TestCreateUser/when_input_is_valid/creates_the_user
--- PASS: TestCreateUser/when_input_is_valid/creates_the_user (0.01s)
--- PASS: TestCreateUser/when_input_is_valid (0.01s)
--- PASS: TestCreateUser (0.02s)
PASS

Every subtest name is repeated three times: once in === RUN, again in its own --- PASS, and again in its parent’s --- PASS. The nesting is encoded by slash-separated paths, not indentation. For a single test function with four subtests, that’s twelve lines of output. Scale to a real test suite and the signal-to-noise ratio collapses.

What BDD structure looks like

Behavior-driven development (BDD) describes what a system does in terms of contexts and expectations — “when X, it should Y” — instead of jamming both into a single function name. (BDD in Go: What Behavior-Driven Development Actually Means covers the methodology and what gotest deliberately borrows from it.) A test for a user service might read:

UserService → Create → when email is valid → creates the user

In gotest, two methods on *gotest.T express this structure:

Both are thin wrappers around t.Run; they create standard Go subtests. The difference is semantic: When is for conditions, It is for assertions. Here’s what the user service example looks like:

service_test.go
type UserServiceTestSuite struct {
    svc *UserService
}

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

func (s *UserServiceTestSuite) TestCreate(t *gotest.T) {
    t.When("email is valid", func(w *gotest.T) {
        user := User{Name: "Alice", Email: "alice@example.com"}
        err := s.svc.Create(user)

        w.It("creates the user", func(it *gotest.T) {
            gotest.NoError(it, err)
        })
        w.It("sends a welcome email", func(it *gotest.T) {
            gotest.Equal(it, 1, s.svc.EmailsSent())
        })
    })

    t.When("email already exists", func(w *gotest.T) {
        _ = s.svc.Create(User{Email: "bob@example.com"})
        err := s.svc.Create(User{Email: "bob@example.com"})

        w.It("returns ErrDuplicate", func(it *gotest.T) {
            gotest.ErrorIs(it, err, ErrDuplicate)
        })
    })
}

func (s *UserServiceTestSuite) TestDelete(t *gotest.T) {
    t.It("soft-deletes the user", func(it *gotest.T) {
        s.svc.Create(User{Email: "charlie@example.com"})
        err := s.svc.Delete("charlie@example.com")
        gotest.NoError(it, err)
    })
}

A few things to notice:

Tests as specification output

The structure in the code is only half the value. The other half is what you see when the tests run. gotest spec transforms the test output into an indented tree:

gotest spec ./...
UserService (136ms)
  Create (130ms)
    email is valid (129ms)
       creates the user (8ms)
       sends a welcome email (120ms)
    email already exists (<1ms)
       returns ErrDuplicate (<1ms)
  Delete (5ms)
     soft-deletes the user (5ms)

1 suites, 4 behaviors: 4 passed

Compare this with the gotest ./... -v output for the same tests — the standard verbose stream, since suites run through the gotest runner. The spec output is:

When a test fails, the tree structure tells you exactly where:

gotest spec ./...
UserService (136ms)
  Create (130ms)
    email is valid (129ms)
       creates the user (8ms)
       sends a welcome email (120ms)
    email already exists (<1ms)
       returns ErrDuplicate (<1ms)
  Delete (5ms)
     soft-deletes the user (5ms)

1 suites, 4 behaviors: 3 passed, 1 failed

The red cross at “sends a welcome email” under “email is valid” is a sentence: UserService Create, when email is valid, fails to send a welcome email. You know what’s broken without reading any code.

When and It can nest arbitrarily

Both When and It delegate to t.Run, so they nest to any depth. This is useful when a single context has sub-conditions:

func (s *OrderServiceTestSuite) TestCheckout(t *gotest.T) {
    t.When("the cart is not empty", func(w *gotest.T) {
        w.When("payment succeeds", func(w *gotest.T) {
            w.It("creates an order", func(it *gotest.T) {
                // ...
            })
            w.It("clears the cart", func(it *gotest.T) {
                // ...
            })
        })
        w.When("payment fails", func(w *gotest.T) {
            w.It("does not create an order", func(it *gotest.T) {
                // ...
            })
        })
    })
}

The spec output reflects the nesting:

gotest spec ./...
OrderService (19ms)
  Checkout (18ms)
    the cart is not empty (17ms)
      payment succeeds (15ms)
         creates an order (12ms)
         clears the cart (3ms)
      payment fails (2ms)
         does not create an order (2ms)

1 suites, 3 behaviors: 3 passed

Each level of When narrows the context. The spec reads as a decision tree: checkout, when the cart is not empty, when payment succeeds, creates an order. You can trace any path from root to leaf and get a complete behavioral statement.

The spec command

gotest spec works by running your suites through the same pipeline as plain gotest, capturing the go test -json event stream, and transforming the structured test events into the indented tree. It accepts the same package patterns as go test:

# spec output for all packages
gotest spec ./...

# write a markdown spec to a file
gotest spec --format=md --output=spec.md ./...

# filter by test name (a regular go test flag)
gotest spec -run UserService ./...

# disable color for CI logs
gotest spec --no-color ./...

There is also gotest spec --static, which renders the same tree straight from your source — no run, no status icons, no durations — for reviewing what the tests promise before executing anything.

The rendering strips naming conventions automatically — and applies the BDD vocabulary:

Suite and method names are bold. Passing expectations get a green checkmark, failing ones a red cross, skipped ones a yellow tilde. The summary line at the bottom shows suite and behavior counts. The same spec tree is also available inside your editor — see the gotest VS Code Extension.

Tests that document themselves

The deeper value of spec output is that it doubles as documentation. When someone new joins the team and asks “what does the order service do?”, you can point them at gotest spec ./pkg/order/... instead of a wiki page that’s three sprints out of date — and it stays accurate because it’s generated from the tests. Making that work in practice, from behavior-focused labels to publishing the spec, is the subject of Go Tests as Living Documentation.

Credit where it’s due: Ginkgo pioneered BDD-style specs in Go, and its Describe/It blocks are where many Go developers first saw tests read like sentences. gotest aims for the same readability without a runtime DSL or reflection — When and It compile down to plain t.Run calls. One caveat applies either way: deeply nested When blocks can become as hard to read as the underscore names they replaced, so treat two or three levels as the ceiling for a spec that still scans.

Turn your next test suite into a readable spec with gotest spec.

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