Advanced Testing Advanced¶
Introduction¶
Go's testing ecosystem is deceptively powerful. The testing package, combined with interfaces for mockability, built-in fuzz testing (Go 1.18+), and the httptest package, provides everything needed for production-grade test suites — without heavy frameworks. Senior Go engineers write tests that are fast, deterministic, and catch real bugs.
Why This Matters
In interviews, testing questions reveal engineering maturity. Can you design testable code? Do you know when to mock vs. use real dependencies? Can you write benchmarks that actually measure what matters? These skills separate senior engineers from the rest.
Mocking Strategies¶
Interface-Based Mocking (The Go Way)¶
Go's implicit interfaces make mocking trivial — define a small interface, and any struct can satisfy it.
// Define a narrow interface for what you need
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
SaveUser(ctx context.Context, u *User) error
}
// Production implementation
type PostgresUserStore struct {
db *sql.DB
}
func (s *PostgresUserStore) GetUser(ctx context.Context, id string) (*User, error) {
var u User
err := s.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Name, &u.Email)
return &u, err
}
// Hand-written mock
type MockUserStore struct {
GetUserFunc func(ctx context.Context, id string) (*User, error)
SaveUserFunc func(ctx context.Context, u *User) error
}
func (m *MockUserStore) GetUser(ctx context.Context, id string) (*User, error) {
return m.GetUserFunc(ctx, id)
}
func (m *MockUserStore) SaveUser(ctx context.Context, u *User) error {
return m.SaveUserFunc(ctx, u)
}
Using the Mock in Tests¶
func TestGetUserHandler(t *testing.T) {
store := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice", Email: "alice@example.com"}, nil
}
return nil, ErrNotFound
},
}
handler := NewUserHandler(store)
t.Run("user found", func(t *testing.T) {
req := httptest.NewRequest("GET", "/users/123", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
})
t.Run("user not found", func(t *testing.T) {
req := httptest.NewRequest("GET", "/users/999", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
})
}
mockgen (Code Generation)¶
# Install mockgen
go install go.uber.org/mock/mockgen@latest
# Generate mock from interface
mockgen -source=store.go -destination=mock_store_test.go -package=mypackage
# Or from an interface name
mockgen -destination=mock_store_test.go -package=mypackage mymodule/store UserStore
func TestWithMockgen(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockUserStore(ctrl)
store.EXPECT().
GetUser(gomock.Any(), "123").
Return(&User{ID: "123", Name: "Alice"}, nil).
Times(1)
svc := NewUserService(store)
user, err := svc.FindUser(context.Background(), "123")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("got %q, want %q", user.Name, "Alice")
}
}
Interview Tip
"I prefer hand-written mocks for small interfaces (1–3 methods) and mockgen for larger ones. Hand-written mocks are more readable and don't require code generation in CI. The key design principle is accept interfaces, return structs — this makes every dependency mockable."
Fuzz Testing (Go 1.18+)¶
Fuzz testing automatically generates inputs to find edge cases, crashes, and panics.
func FuzzParseUserID(f *testing.F) {
// Seed corpus — known good inputs
f.Add("user-123")
f.Add("user-0")
f.Add("")
f.Add("user-999999999999")
f.Fuzz(func(t *testing.T, input string) {
id, err := ParseUserID(input)
if err != nil {
return // invalid input is fine
}
// Property: roundtrip must be stable
serialized := id.String()
reparsed, err := ParseUserID(serialized)
if err != nil {
t.Fatalf("roundtrip failed: ParseUserID(%q) returned %q, which failed to parse: %v",
input, serialized, err)
}
if reparsed != id {
t.Fatalf("roundtrip mismatch: %v != %v", reparsed, id)
}
})
}
# Run fuzz test for 30 seconds
go test -fuzz=FuzzParseUserID -fuzztime=30s
# Failing inputs saved to testdata/fuzz/FuzzParseUserID/
# They become part of the corpus and run as regular tests
Fuzz Testing JSON Parsing¶
func FuzzJSONRoundtrip(f *testing.F) {
f.Add([]byte(`{"name":"alice","age":30}`))
f.Add([]byte(`{}`))
f.Fuzz(func(t *testing.T, data []byte) {
var user User
if err := json.Unmarshal(data, &user); err != nil {
return
}
encoded, err := json.Marshal(user)
if err != nil {
t.Fatalf("Marshal failed after successful Unmarshal: %v", err)
}
var user2 User
if err := json.Unmarshal(encoded, &user2); err != nil {
t.Fatalf("re-Unmarshal failed: %v", err)
}
if user != user2 {
t.Fatalf("roundtrip mismatch: %+v != %+v", user, user2)
}
})
}
Integration Tests¶
Build Tags for Test Isolation¶
//go:build integration
package mypackage
import (
"testing"
"database/sql"
_ "github.com/lib/pq"
)
func TestDatabaseIntegration(t *testing.T) {
db, err := sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Test real database operations
store := NewPostgresUserStore(db)
ctx := context.Background()
user := &User{ID: "test-1", Name: "Integration Test", Email: "test@example.com"}
if err := store.SaveUser(ctx, user); err != nil {
t.Fatalf("SaveUser: %v", err)
}
got, err := store.GetUser(ctx, "test-1")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.Name != user.Name {
t.Errorf("got name %q, want %q", got.Name, user.Name)
}
}
# Run only unit tests (default)
go test ./...
# Run integration tests too
go test -tags=integration ./...
TestMain for Setup/Teardown¶
//go:build integration
package mypackage
import (
"database/sql"
"fmt"
"os"
"testing"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
var err error
testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open db: %v\n", err)
os.Exit(1)
}
// Run migrations or seed data
if err := runMigrations(testDB); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
os.Exit(1)
}
code := m.Run()
// Cleanup
testDB.Close()
os.Exit(code)
}
testcontainers-go¶
//go:build integration
package mypackage
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupPostgres(t *testing.T) (string, func()) {
t.Helper()
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatal(err)
}
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://test:test@%s:%s/testdb?sslmode=disable", host, port.Port())
cleanup := func() { container.Terminate(ctx) }
return dsn, cleanup
}
func TestWithContainer(t *testing.T) {
dsn, cleanup := setupPostgres(t)
defer cleanup()
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Run real integration tests against ephemeral container
store := NewPostgresUserStore(db)
// ...
}
Golden File Testing¶
Golden files compare output against a known-good reference file, auto-updating with a flag.
var update = flag.Bool("update", false, "update golden files")
func TestRenderTemplate(t *testing.T) {
data := TemplateData{
Title: "Hello",
Items: []string{"Go", "Rust", "Python"},
}
var buf bytes.Buffer
if err := RenderTemplate(&buf, data); err != nil {
t.Fatal(err)
}
got := buf.Bytes()
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
os.MkdirAll("testdata", 0o755)
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("golden file missing (run with -update): %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch (run with -update to accept):\n%s",
diff(string(want), string(got)))
}
}
# Normal test run — compare against golden files
go test ./...
# Update golden files when output intentionally changes
go test -run TestRenderTemplate -update
Benchmark Tests¶
Basics with b.Run and b.ReportAllocs¶
func BenchmarkJSONMarshal(b *testing.B) {
user := &User{ID: "123", Name: "Alice", Email: "alice@example.com"}
b.Run("encoding/json", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(user)
}
})
b.Run("sonic", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = sonic.Marshal(user)
}
})
}
Benchmark with Different Input Sizes¶
func BenchmarkSort(b *testing.B) {
for _, size := range []int{100, 1000, 10000, 100000} {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
b.StopTimer()
data := make([]int, size)
for j := range data {
data[j] = rand.Intn(size)
}
b.StartTimer()
sort.Ints(data)
}
})
}
}
# Run benchmarks
go test -bench=BenchmarkSort -benchmem -count=5
# Compare with benchstat
go install golang.org/x/perf/cmd/benchstat@latest
go test -bench=. -count=10 > old.txt
# ... make changes ...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt
httptest Package¶
Testing HTTP Handlers¶
func TestUserHandler(t *testing.T) {
store := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
handler := NewUserHandler(store)
req := httptest.NewRequest("GET", "/users/123", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
resp := rec.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
t.Fatalf("decode: %v", err)
}
if user.Name != "Alice" {
t.Errorf("name = %q; want %q", user.Name, "Alice")
}
}
Testing HTTP Clients with httptest.NewServer¶
func TestAPIClient(t *testing.T) {
// Spin up a fake server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/users/123" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("auth header = %q; want %q", got, "Bearer test-token")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(User{ID: "123", Name: "Alice"})
}))
defer srv.Close()
// Point client at fake server
client := NewAPIClient(srv.URL, "test-token")
user, err := client.GetUser(context.Background(), "123")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %q; want %q", user.Name, "Alice")
}
}
Testing TLS¶
func TestTLSClient(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "secure")
}))
defer srv.Close()
// srv.Client() returns an *http.Client configured to trust the test server's cert
resp, err := srv.Client().Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if got := strings.TrimSpace(string(body)); got != "secure" {
t.Errorf("body = %q; want %q", got, "secure")
}
}
Testing Concurrent Code¶
func TestConcurrentMap(t *testing.T) {
m := NewConcurrentMap[string, int]()
const goroutines = 100
const iterations = 1000
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
m.Set(key, id*iterations+j)
if val, ok := m.Get(key); ok {
_ = val // use the value
}
}
}(i)
}
wg.Wait()
// Verify final state
if m.Len() != goroutines*iterations {
t.Errorf("expected %d entries, got %d", goroutines*iterations, m.Len())
}
}
Using -race Flag¶
Testing with Timeouts¶
func TestSlowOperation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := make(chan string, 1)
go func() {
result <- SlowOperation()
}()
select {
case got := <-result:
if got != "expected" {
t.Errorf("got %q, want %q", got, "expected")
}
case <-ctx.Done():
t.Fatal("test timed out")
}
}
Test Fixtures¶
func TestProcessCSV(t *testing.T) {
// testdata/ directory is special — ignored by go build, included in go test
f, err := os.Open(filepath.Join("testdata", "sample.csv"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
records, err := ProcessCSV(f)
if err != nil {
t.Fatal(err)
}
if len(records) != 5 {
t.Errorf("got %d records, want 5", len(records))
}
}
Test Helper Functions¶
func newTestUser(t *testing.T, opts ...func(*User)) *User {
t.Helper()
u := &User{
ID: uuid.NewString(),
Name: "Test User",
Email: "test@example.com",
}
for _, opt := range opts {
opt(u)
}
return u
}
func withName(name string) func(*User) {
return func(u *User) { u.Name = name }
}
func TestSomething(t *testing.T) {
user := newTestUser(t, withName("Alice"))
// ...
}
Quick Reference¶
| Technique | When to Use | Key Package/Tool |
|---|---|---|
| Hand-written mock | Small interfaces (1–3 methods) | Struct with func fields |
| mockgen | Large interfaces, strict call verification | go.uber.org/mock |
| Fuzz testing | Parsers, serialization, validation | testing.F (Go 1.18+) |
| Build tags | Separate integration from unit tests | //go:build integration |
| TestMain | Global setup/teardown | testing.TestMain |
| testcontainers-go | Real database/service in tests | testcontainers-go |
| Golden files | Template output, CLI output | testdata/*.golden |
| Benchmarks | Performance regression, comparison | testing.B, benchstat |
| httptest | HTTP handlers and clients | net/http/httptest |
-race flag |
Concurrency correctness | go test -race |
t.Helper() |
Custom assertion/setup functions | testing.T |
t.Parallel() |
Speed up independent tests | testing.T |
t.Cleanup() |
Deferred resource cleanup | testing.T |
Best Practices¶
- Accept interfaces, return structs — makes every dependency mockable without frameworks
- Use
t.Helper()in all test helper functions so error lines point to the caller - Use
t.Parallel()for independent tests to speed up the suite - Use
t.Cleanup()overdeferfor test teardown — it runs even if the test callst.Fatal - Keep the
testdata/directory for fixtures — Go tooling ignores it during builds - Run
-racein CI — always, no exceptions - Use table-driven tests for multiple input/output combinations
- Name subtests descriptively with
t.Run("descriptive name", ...)for readable output - Don't mock what you don't own — wrap third-party libraries behind your own interface, then mock that
- Test behavior, not implementation — assert outcomes, not internal method calls
Common Pitfalls¶
Race Conditions in Tests
Tests that pass without -race may hide data races. Always run go test -race in CI. A passing test with a race condition is worse than a failing test.
// BAD: shared variable without synchronization
func TestBad(t *testing.T) {
var count int
for i := 0; i < 10; i++ {
go func() { count++ }() // DATA RACE
}
time.Sleep(time.Second)
}
// GOOD: use sync primitives
func TestGood(t *testing.T) {
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count.Add(1)
}()
}
wg.Wait()
}
Flaky Tests from time.Sleep
Never use time.Sleep to wait for goroutines. Use sync.WaitGroup, channels, or t.Deadline().
TestMain Must Call os.Exit
If you define TestMain, you must call os.Exit(m.Run()). Otherwise, tests appear to pass but never actually run.
Benchmark Compiler Optimization
The compiler may optimize away unused results. Assign benchmark results to a package-level variable:
Performance Considerations¶
t.Parallel()can dramatically reduce test suite time for I/O-bound tests- Build tags keep slow integration tests out of the fast feedback loop
b.ResetTimer()— call after expensive setup in benchmarks to exclude setup timeb.StopTimer()/b.StartTimer()— isolate the code you're measuringbenchstat— use statistical comparison (run benchmarks with-count=10) to detect real regressions vs noise-shortflag — usetesting.Short()to skip slow tests in local dev:
Interview Tips¶
Interview Tip
"For testing strategy, I follow the test pyramid: many fast unit tests with mocked dependencies, fewer integration tests with real services via testcontainers, and minimal end-to-end tests. I use build tags to separate integration tests so go test ./... stays fast."
Interview Tip
"I design for testability from the start by accepting interfaces as constructor parameters. This means I can inject mocks in tests and real implementations in production — without any test framework or dependency injection container."
Interview Tip
"Fuzz testing is invaluable for parsers and serialization code. I've used it to find edge cases in custom protocol parsers that unit tests would never catch. The property I test most often is roundtrip stability: marshal → unmarshal → marshal should produce identical output."
Key Takeaways¶
- Interface-based mocking is Go's primary testing strategy — no frameworks needed for small interfaces
- Fuzz testing (Go 1.18+) automatically finds edge cases; use it for parsers, validators, and serialization
- Build tags + TestMain cleanly separate unit and integration tests
- testcontainers-go provides ephemeral real databases for integration tests
- Golden files are ideal for testing complex output (templates, CLI tools, code generators)
- httptest provides both
NewRecorder(handler testing) andNewServer(client testing) - Always run
-racein CI — race conditions are the most dangerous class of Go bugs - Benchmarks require care — use
b.ReportAllocs(), prevent compiler elimination, and compare withbenchstat