98 lines
3.2 KiB
Markdown
98 lines
3.2 KiB
Markdown
# Test-Driven Debugging
|
|
|
|
A failing test MUST be written before fixing a bug. Writing a failing test is often the fastest debugging path. It gives you a reproducible, isolated environment.
|
|
|
|
## Reproduce the Bug in a Test
|
|
|
|
```go
|
|
func TestBugDescription(t *testing.T) {
|
|
// Setup: exact conditions that trigger the bug
|
|
svc := NewService(testConfig)
|
|
|
|
// Act: the operation that fails
|
|
result, err := svc.Process(badInput)
|
|
|
|
// Assert: what should happen
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Status != "ok" {
|
|
t.Errorf("got status %q, want %q", result.Status, "ok")
|
|
}
|
|
}
|
|
```
|
|
|
|
## Expand Edge Cases with Table Tests
|
|
|
|
When debugging, add edge cases to find the boundary of the bug:
|
|
|
|
```go
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want time.Duration
|
|
wantErr bool
|
|
}{
|
|
{"valid", "5s", 5 * time.Second, false},
|
|
{"empty", "", 0, true},
|
|
{"negative", "-1s", -time.Second, false},
|
|
{"zero", "0s", 0, false},
|
|
{"overflow", "99999999h", 0, true},
|
|
{"whitespace", " 5s ", 0, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := ParseDuration(tt.input)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
## Useful Test Flags
|
|
|
|
```bash
|
|
go test -v ./... # verbose output
|
|
go test -run TestName -v ./pkg/... # single test
|
|
go test -count=1 ./... # disable cache
|
|
go test -timeout 10s ./... # short timeout (find hangs)
|
|
go test -parallel 1 ./... # sequential execution
|
|
go test -race ./... # race detector
|
|
go test -cover ./... # coverage summary
|
|
go test -coverprofile=c.out ./... && go tool cover -html=c.out # coverage report
|
|
go test -failfast ./... # stop on first failure
|
|
go test -shuffle=on ./... # randomize test order (Go 1.17+)
|
|
```
|
|
|
|
## Debugging Flaky Tests
|
|
|
|
Flaky tests (pass sometimes, fail sometimes) are usually caused by one of:
|
|
|
|
1. **Shared mutable state between tests** — global variables, package-level maps, singletons
|
|
- Fix: reset state in `TestMain` or use `t.Cleanup`
|
|
2. **Test order dependence** — one test sets up state another test relies on
|
|
- Diagnose: `go test -run TestSuspect -count=1` (run in isolation)
|
|
- Diagnose: `go test -shuffle=on` (randomize order)
|
|
- Fix: each test must set up its own preconditions
|
|
3. **Timing sensitivity** — `time.Sleep` in tests, race between goroutines
|
|
- Fix: use channels/waitgroups to synchronize, not sleeps
|
|
4. **Port conflicts** — tests binding to fixed ports
|
|
- Fix: use port `0` and read the assigned port
|
|
5. **File system pollution** — tests writing to shared temp directories
|
|
- Fix: use `t.TempDir()` for per-test directories
|
|
|
|
```bash
|
|
# Confirm flakiness by running many times
|
|
go test -count=100 -run TestSuspect ./pkg/... -failfast
|
|
|
|
# Check for parallelism issues
|
|
go test -parallel 1 -count=10 ./pkg/...
|
|
|
|
# Check for order dependence
|
|
go test -shuffle=on ./pkg/...
|
|
```
|