#golang#testing#clean-code#best-practices

Pragmatic Testing in Go: Table-Driven Tests, Test Doubles, and httptest

"Build robust Go test suites using table-driven tests, hand-rolled fakes, and net/http/httptest without external mocking dependencies."

By huud
0 views
~4 min read
Pragmatic Testing in Go: Table-Driven Tests, Test Doubles, and httptest

Pragmatic Testing in Go: Table-Driven Tests, Test Doubles, and httptest

Writing maintainable, robust test suites in Go requires embracing standard library primitives rather than pulling in bloated assertion frameworks or complex reflection-based mocking libraries.

This guide explores the standard Go testing playbook: Table-Driven Tests, Lightweight Test Doubles (Fakes vs Mocks), and HTTP integration testing with net/http/httptest.


1. The Table-Driven Test Pattern

Table-driven tests are the idiomatic standard for Go unit testing. They consolidate test cases into a structured slice, maximizing coverage while keeping test logic DRY (Don't Repeat Yourself).

go
package validator_test

import (
	"testing"
)

func ValidateEmail(email string) bool {
	// Business validation logic...
	return len(email) >= 5 && len(email) <= 255
}

func TestValidateEmail(t *testing.T) {
	// Define table of test cases
	tests := []struct {
		name    string
		email   string
		wantOk  bool
	}{
		{name: "valid standard email", email: "eng@example.com", wantOk: true},
		{name: "empty string", email: "", wantOk: false},
		{name: "too short", email: "a@b", wantOk: false},
	}

	for _, tt := range tests {
		// Run each row as an isolated subtest
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel() // Runs subtests concurrently!

			got := ValidateEmail(tt.email)
			if got != tt.wantOk {
				t.Errorf("ValidateEmail(%q) = %v; want %v", tt.email, got, tt.wantOk)
			}
		})
	}
}

2. Test Doubles: Hand-Rolled Fakes over Mocking Frameworks

Heavy mocking frameworks (e.g. mockery, gomock) generate thousands of lines of fragile code that break whenever method signatures evolve. Because Go interfaces are small, hand-written function fakes provide cleaner, more flexible test doubles.

Defining Service with Consumer Interface

go
package orders

import "context"

type PaymentGateway interface {
	Charge(ctx context.Context, amount int64) (string, error)
}

type OrderService struct {
	gateway PaymentGateway
}

func (s *OrderService) Checkout(ctx context.Context, amount int64) (string, error) {
	return s.gateway.Charge(ctx, amount)
}

Hand-Written Fake Double

go
package orders_test

import (
	"context"
	"errors"
	"testing"
	"myapp/orders"
)

// FakeGateway allows per-test function overrides
type FakeGateway struct {
	ChargeFunc func(ctx context.Context, amount int64) (string, error)
}

func (f *FakeGateway) Charge(ctx context.Context, amount int64) (string, error) {
	if f.ChargeFunc != nil {
		return f.ChargeFunc(ctx, amount)
	}
	return "tx_success_default", nil
}

func TestCheckout_Failure(t *testing.T) {
	fake := &FakeGateway{
		ChargeFunc: func(ctx context.Context, amount int64) (string, error) {
			return "", errors.New("insufficient funds")
		},
	}

	svc := orders.OrderService{Gateway: fake}
	_, err := svc.Checkout(context.Background(), 5000)

	if err == nil || err.Error() != "insufficient funds" {
		t.Fatalf("expected insufficient funds error, got: %v", err)
	}
}

3. Testing HTTP APIs with httptest

The net/http/httptest package allows testing HTTP handlers in-memory without opening network ports.

Testing Handlers via httptest.ResponseRecorder

go
package api_test

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

func TestHealthCheckHandler(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rr := httptest.NewRecorder()

	HealthCheckHandler(rr, req)

	if rr.Code != http.StatusOK {
		t.Errorf("status code = %d; want %d", rr.Code, http.StatusOK)
	}

	expectedBody := `{"status":"ok"}`
	if rr.Body.String() != expectedBody {
		t.Errorf("body = %q; want %q", rr.Body.String(), expectedBody)
	}
}

Mocking External APIs via httptest.Server

go
func TestExternalPaymentAPI(t *testing.T) {
	// Spin up a real local HTTP server
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"charge_id": "ch_123"}`))
	}))
	defer server.Close()

	// Point client to server.URL
	client := NewClient(server.URL)
	res, err := client.DoPayment(100)
	if err != nil || res != "ch_123" {
		t.Fatalf("unexpected payment error: %v", err)
	}
}

Testing Principles for Senior Engineers

  1. Use t.Helper(): In custom assertion functions, mark them with t.Helper() so stack traces point to the failing test line, not the utility function.
  2. Favor Fakes over Stubs: In-memory implementations (e.g. map-backed store) verify state transitions much better than shallow mock call counts.
  3. Use -race Flag: Always run tests with go test -race ./... in CI to detect data races before deployment.

Conclusion

Go testing is intentionally minimalist. By combining Table-Driven subtests, httptest, and lightweight struct fakes, you build fast, robust test suites without third-party framework fragility.

About the Author

huud

huud

@huud

About →

Systems architect and software engineer building high-performance distributed platforms.