Skip to main content
Go for JVM Engineers

Testing the Go Way

Ravinder··6 min read
GoJVMJavaTestingTableTestsTDD
Share:
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 caching

Table-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.

flowchart TD TT[TestAdd] --> S1[t.Run\npositive numbers] TT --> S2[t.Run\nzeros] TT --> S3[t.Run\nnegative and positive] TT --> S4[t.Run\noverflow check] S1 -->|pass| P1[✓] S2 -->|pass| P2[✓] S3 -->|pass| P3[✓] S4 -->|fail| F1[✗ FAIL]

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.go files, discovered by naming convention — no annotation scanning, no test runner plugin, no XML configuration.
  • Table-driven tests with t.Run replace @ParameterizedTest; each subtest is independently named, filterable, and parallelisable with t.Parallel().
  • Use t.Cleanup for per-test teardown and TestMain for package-level setup/teardown — the equivalents of @AfterEach and @BeforeAll without 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.
Share: