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."
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).
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
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
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
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
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
- Use
t.Helper(): In custom assertion functions, mark them witht.Helper()so stack traces point to the failing test line, not the utility function. - Favor Fakes over Stubs: In-memory implementations (e.g.
map-backed store) verify state transitions much better than shallow mock call counts. - Use
-raceFlag: Always run tests withgo 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
Systems architect and software engineer building high-performance distributed platforms.