Go Testing Standards
Philosophy
- •Fakes over mocks — Test real implementation when possible; fakes when not
- •Stdlib first — Use
testingpackage; third-party only when it adds real value - •Test behavior, not implementation — Public API matters, internals don't
- •Fast and deterministic — Flaky tests are worse than no tests
Fakes Over Mocks
This is the 2025 consensus: prefer fakes over mocks.
| Type | When to Use |
|---|---|
| Real | Always prefer if fast and reliable |
| Fake | Dependency is slow/unreliable (DB, network, time) |
| Mock | Only when verifying specific call sequences |
// Interface defined where USED, not where implemented
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}
// Fake with logic (not predetermined responses)
type FakeClock struct {
current time.Time
}
func (c *FakeClock) Now() time.Time { return c.current }
func (c *FakeClock) Advance(d time.Duration) { c.current = c.current.Add(d) }
Key rules:
- •Define interfaces where they are used, not where implemented
- •Keep interfaces small (1-3 methods)
- •Fakes have logic; mocks have canned responses
Sources: Learn Go with Tests, Leapcell: gomock vs Fakes
Testing Time-Dependent Code
Cache TTLs, timeouts — all need deterministic time.
Option 1: Interface Injection (benbjohnson/clock)
import "github.com/benbjohnson/clock"
type Cache struct {
clock clock.Clock
}
// Production
cache := &Cache{clock: clock.New()}
// Test
mock := clock.NewMock()
cache := &Cache{clock: mock}
mock.Add(5 * time.Minute) // Advance time programmatically
Option 2: testing/synctest (Go 1.25+)
Runs code in a "bubble" with fake time that auto-advances when goroutines block:
import "testing/synctest"
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Time advances instantly when all goroutines are blocked
<-ctx.Done() // Returns immediately, fake clock jumped 5s
})
}
Note: Go 1.24 requires GOEXPERIMENT=synctest. Go 1.25 includes it in stdlib.
Sources: Go Blog: Testing Time, benbjohnson/clock, Go Blog: synctest
Testing HTML Handlers with httptest
The primary integration testing pattern for cooked. Use httptest.NewServer for full end-to-end tests and httptest.NewRecorder for unit-testing individual handlers.
Full Server Test
func TestRenderMarkdown(t *testing.T) {
// Set up a fake upstream that serves raw markdown
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("# Hello\n\nWorld"))
}))
defer upstream.Close()
// Start cooked with the fake upstream
srv := httptest.NewServer(cookedHandler(deps))
defer srv.Close()
resp, err := http.Get(srv.URL + "/" + upstream.URL + "/README.md")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("got status %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("got content-type %q, want text/html", ct)
}
}
Checking Response Headers
func TestResponseHeaders(t *testing.T) {
// ...setup...
resp, _ := http.Get(srv.URL + "/" + upstream.URL + "/file.md")
// Verify X-Cooked-* headers
if got := resp.Header.Get("X-Cooked-Cache"); got == "" {
t.Error("missing X-Cooked-Cache header")
}
if got := resp.Header.Get("X-Cooked-Content-Type"); got != "markdown" {
t.Errorf("X-Cooked-Content-Type = %q, want markdown", got)
}
}
Goroutine Leak Detection (goleak)
Detects goroutines that outlive tests — critical for long-running services.
Package-Wide (Recommended)
// main_test.go
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Per-Test (When Debugging)
func TestSpecificLeak(t *testing.T) {
defer goleak.VerifyNone(t)
// test code
}
Ignoring Known Leaks
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("net/http.(*persistConn).writeLoop"),
)
}
Note: goleak doesn't work well with t.Parallel() — use VerifyTestMain instead.
Sources: uber-go/goleak, Brandur: Goroutine Leaks
Fuzzing
Use for URL parsing, markdown preprocessing, HTML sanitization, and security-sensitive input handling.
File organization: Fuzz tests go in separate *_fuzz_test.go files, not mixed with unit tests.
url.go url_test.go # Unit tests url_fuzz_test.go # Fuzz tests
func FuzzParseUpstreamURL(f *testing.F) {
// Seed corpus
f.Add("https://example.com/file.md")
f.Add("http://cgit.internal/repo/plain/README.md")
f.Add("")
f.Add("not-a-url")
f.Fuzz(func(t *testing.T, input string) {
_, _ = ParseUpstreamURL(input) // Should not panic
})
}
Running
go test -fuzz=FuzzParseUpstreamURL -fuzztime=30s ./internal/... # Clear cache when large go clean -fuzzcache
Sources: Go Fuzzing Docs
Golden Files
The primary testing pattern for HTML output. Keep in testdata/golden/.
var update = flag.Bool("update", false, "update golden files")
func TestRenderOutput(t *testing.T) {
got := renderMarkdown(input)
golden := filepath.Join("testdata", "golden", t.Name()+".html")
if *update {
os.WriteFile(golden, got, 0644)
return
}
want, _ := os.ReadFile(golden)
if !bytes.Equal(got, want) {
t.Errorf("mismatch; run with -update to regenerate")
}
}
Run go test -update to regenerate. Always commit golden files.
Parallel Tests
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// test
})
}
Critical: Use t.Cleanup() not defer — defer runs when the outer function returns, before parallel subtests complete.
// Wrong - cleanup happens too early
t.Run("test", func(t *testing.T) {
t.Parallel()
defer cleanup() // Runs immediately!
})
// Correct
t.Run("test", func(t *testing.T) {
t.Parallel()
t.Cleanup(cleanup) // Runs after subtest completes
})
Always run with -race:
go test -race ./...
Sources: Parallel Table-Driven Tests
Project Commands
# All tests go test ./... # With race detection go test -race ./... # Coverage (use /tmp, not project root) go test -coverprofile=/tmp/coverage.out ./... go tool cover -func=/tmp/coverage.out | grep total
Key Files
- •
internal/*_test.go— Package tests - •
testdata/golden/— Golden HTML output files - •
testdata/fixtures/— Input test fixtures (markdown, code files)