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:
=== 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:
=== 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:
t.When(label, fn): establishes a context. “When the email is invalid,” “when the user already exists.”t.It(label, fn): states an expectation. “It creates the user,” “it returns an error.”
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:
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:
- Setup runs inside
Whenblocks. The condition and its setup live together.When("email already exists", ...)creates the duplicate inside the block, not in a separate fixture. - Multiple
Itblocks perWhen. “creates the user” and “sends a welcome email” share the same setup but assert different things. - Suite and method names carry meaning.
UserServiceTestSuitebecomes the subject.TestCreatebecomes the behavior.When/Itprovide context and expectation. (If this suite structure is new to you, Your First Go Test Suite in 10 Minutes covers the setup.) - You can skip
When.TestDeletegoes straight toItbecause the test doesn’t need a conditional context.
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:
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:
- 9 lines instead of 20+. No repetition, no
=== RUN/--- PASSnoise. - Hierarchical. 2-space indentation shows structure at a glance. Suite and method names are bold. Expectations get checkmarks.
- Human-readable names.
UserServiceTestSuitebecomes “UserService” (suffix stripped).TestCreatebecomes “Create” (prefix stripped). No CamelCase, no underscores.
When a test fails, the tree structure tells you exactly where:
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:
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:
UserServiceTestSuite→ UserService (dropsTestSuitesuffix)TestCreate→ Create (dropsTestprefix)- Underscored subtest names (
sends_a_welcome_emailingo testoutput) → the label as you wrote it (“sends a welcome email”) t.When("email is valid")→ “when email is valid” — gotest reads which subtests came fromWhenout of your source, so you write the condition and it supplies the connective.Itlabels render verbatim; the checkmark plays the role of “it”. (The subtest name staysemail_is_valid—-runfilters and snapshot keys are untouched.)
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