- name
- testing
- description
- MANDATORY - Load before writing tests. Covers fakes over mocks, golden files, goleak, httptest, fuzzing, parallel tests, synctest, race detection, test coverage, table-driven tests. Enforces project test conventions. Use when this capability is needed.
- metadata
- {"author":"air-gapped"}
# Go Testing Standards
## 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 |
```go
// 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](https://quii.gitbook.io/learn-go-with-tests/testing-fundamentals/working-without-mocks), [Leapcell: gomock vs Fakes](https://leapcell.io/blog/mastering-mocking-in-go-gomock-vs-interface-based-fakes)
---
## Testing Time-Dependent Code
Cache TTLs, timeouts — all need deterministic time.
### Option 1: Interface Injection (benbjohnson/clock)
```go
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:
```go
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](https://go.dev/blog/testing-time), [benbjohnson/clock](https://github.com/benbjohnson/clock), [Go Blog: synctest](https://go.dev/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
```go
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
```go
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)
```go
// main_test.go
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
```
### Per-Test (When Debugging)
```go
func TestSpecificLeak(t *testing.T) {
defer goleak.VerifyNone(t)
// test code
}
```
### Ignoring Known Leaks
```go
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](https://github.com/uber-go/goleak), [Brandur: Goroutine Leaks](https://brandur.org/fragments/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
```
```go
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
```bash
go test -fuzz=FuzzParseUpstreamURL -fuzztime=30s ./internal/...
# Clear cache when large
go clean -fuzzcache
```
Sources: [Go Fuzzing Docs](https://go.dev/doc/security/fuzz/)
---
## Golden Files
The primary testing pattern for HTML output. Keep in `testdata/golden/`.
```go
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
```go
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.
```go
// 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
})
```
Sources: [Parallel Table-Driven Tests](https://www.glukhov.org/post/2025/12/parallel-table-driven-tests-in-go/)
---
## Makefile Targets
Use Makefile targets, not bare `go test`:
```bash
make test # All tests
make test-race # With race detector
```
## Key Files
- `internal/*_test.go` — Package tests
- `testdata/golden/` — Golden HTML output files
- `testdata/fixtures/` — Input test fixtures (markdown, code files)
---
> Converted and distributed by [TomeVault](https://tomevault.io/claim/air-gapped) — claim your Tome and manage your conversions.
<!-- tomevault:4.0:skill_md:2026-04-13 -->
عرض على GitHub