Testing the Go Way
JUnit's annotation-based lifecycle is so familiar to JVM engineers that testing without @Test, @BeforeEach, and @ParameterizedTest can feel like testing without a safety net. Go's testing package is deliberately minimal — no annotations, no lifecycle annotations, no assertion library in the standard library. Once you accept that simplicity as a constraint rather than a gap, the resulting tests are easier to read, easier to debug, and faster to compile.
The Basic Shape of a Go Test
Test files live alongside the package they test, named *_test.go. The test binary is built and run by go test.
// JUnit 5
@Test
void add_twoPositiveNumbers_returnsSum() {
assertEquals(5, Calculator.add(2, 3));
}// Go testing
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2, 3) = %d; want 5", got)
}
}t.Errorf marks the test as failed but continues running. t.Fatalf marks failed and stops the current test immediately. There is no assertEquals in the standard library — comparisons are plain Go code.
Run tests:
go test ./... # all packages
go test -v ./pkg/calculator/... # verbose
go test -run TestAdd ./... # filter by name regex
go test -count=1 ./... # disable test cachingTable-Driven Tests — the Go ParameterizedTest
Table-driven tests are idiomatic Go. Instead of a separate parameterized test annotation, you define a slice of test cases and loop over them with t.Run.
// JUnit 5 parameterized
@ParameterizedTest
@CsvSource({"2,3,5", "0,0,0", "-1,1,0"})
void testAdd(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}// Go table-driven
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"zeros", 0, 0, 0},
{"negative and positive", -1, 1, 0},
{"overflow check", math.MaxInt, 1, math.MinInt},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
}
})
}
}t.Run creates a named subtest. Failed subtests show as TestAdd/overflow_check, making failures easy to pinpoint. In Go 1.22+, loop variable capture is fixed — the tc variable is scoped per iteration, so closures capture it correctly without the tc := tc copy idiom.
Setup and Teardown Without Annotations
Go has no @BeforeEach / @AfterEach. Setup runs before t.Run calls; teardown uses t.Cleanup.
func TestDB(t *testing.T) {
db := setupTestDB(t) // creates and seeds the DB
t.Cleanup(func() {
db.Close() // always runs, even if test panics
})
t.Run("insert user", func(t *testing.T) {
// uses db
})
t.Run("fetch user", func(t *testing.T) {
// uses db
})
}TestMain provides package-level setup/teardown — the equivalent of JUnit's @BeforeAll:
func TestMain(m *testing.M) {
// Package-wide setup
pool, err := startDockerPostgres()
if err != nil {
log.Fatal(err)
}
code := m.Run() // run all tests in this package
pool.Purge() // teardown
os.Exit(code)
}Interfaces as the Mocking Seam
Go has no Mockito. Mocking is done by defining a small interface and providing a fake implementation. Because Go interfaces are satisfied structurally, you do not need to annotate production code.
// Production interface
type UserStore interface {
Find(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
// Fake for tests
type fakeUserStore struct {
users map[string]User
err error
}
func (f *fakeUserStore) Find(_ context.Context, id string) (User, error) {
if f.err != nil {
return User{}, f.err
}
return f.users[id], nil
}
func (f *fakeUserStore) Save(_ context.Context, u User) error {
f.users[u.ID] = u
return f.err
}
func TestUserService_Get(t *testing.T) {
store := &fakeUserStore{
users: map[string]User{"1": {ID: "1", Name: "Alice"}},
}
svc := NewUserService(store)
u, err := svc.Get(context.Background(), "1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Name != "Alice" {
t.Errorf("got name %q; want Alice", u.Name)
}
}For generated mocks, gomock and testify/mock are popular. Prefer hand-written fakes for simple cases — they are easier to understand in code review.
Testable Examples
The testing package supports Example* functions that double as documentation and verified tests. The // Output: comment is compared against stdout at test time.
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output:
// 5
}These appear in go doc output and on pkg.go.dev, and they fail the test suite if the output changes. Spring's test documentation annotations produce no equivalent compiled-verified artifact.
Integration Tests and Build Tags
Use build tags to separate unit and integration tests:
//go:build integration
package store_test
import (
"testing"
)
func TestUserStore_Integration(t *testing.T) {
// runs only with: go test -tags integration ./...
}# Unit tests only (fast)
go test ./...
# Integration tests included
go test -tags integration ./...Benchmarks
Benchmarks live in the same _test.go files and follow the BenchmarkXxx(*testing.B) convention:
func BenchmarkAdd(b *testing.B) {
for range b.N {
Add(2, 3)
}
}go test -bench=. -benchmem ./...JVM engineers used to JMH will find Go benchmarks simpler to write but less rigorous about JIT warm-up effects (the Go compiler does less speculative optimisation than HotSpot). For micro-benchmarks, benchstat compares two benchmark runs statistically.
Key Takeaways
- Go tests are functions in
*_test.gofiles, discovered by naming convention — no annotation scanning, no test runner plugin, no XML configuration. - Table-driven tests with
t.Runreplace@ParameterizedTest; each subtest is independently named, filterable, and parallelisable witht.Parallel(). - Use
t.Cleanupfor per-test teardown andTestMainfor package-level setup/teardown — the equivalents of@AfterEachand@BeforeAllwithout the annotation coupling. - Mocking is done through small interfaces and hand-written fakes; structural typing means you define the interface at the test site without touching production code.
Example*functions are compiled, executed, and verified against a// Output:comment — use them for documentation that provably stays correct.- Build tags (
//go:build integration) segregate slow integration tests from fast unit tests cleanly, without Maven profile gymnastics.