Test risky behavior, not private details. Prefer clear table-driven tests over fragile suites.
Stack and scope
| Ask | Role |
|---|---|
| testing | Test*, t.Run, t.Helper |
| httptest | In-memory HTTP requests |
| Gin | gin.New() / test mode / real handlers |
| fakes | Ports at the edge (repo, clock, bus) |
Use testify only if the project already depends on it. Concurrency: run with -race on the touched packages.
What to prioritize
- Authz and ownership
- Validation and HTTP status
- Mutations and domain errors (
404,409) - Regressions that already occurred (bug → test)
- Pure helpers and policies
Table-driven
func TestParseCursor(t *testing.T) {
tests := []struct {
name string
in string
want Cursor
wantErr bool
}{
{name: "vazio", in: "", want: Cursor{}},
{name: "inválido", in: "%%%", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCursor(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %#v want %#v", got, tt.want)
}
})
}
}
- Name: behavior + condition.
- Independent cases; no implicit global order.
t.Helper()in shared asserts.
Gin handlers
func TestCreateTodo_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/todos", createTodo) // injete deps fake
req := httptest.NewRequest(http.MethodPost, "/todos", bytes.NewBufferString(`{"title":"x"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
- Set up the minimal router needed (real auth middleware + fake deps).
- Prefer
ServeHTTPon the engine rather than testing only withCreateTestContextwhen the middleware matters. - Compare status + relevant JSON fields, not the fragile entire body.
Fakes and edges
- Interface at the edge (repository, mailer); in-memory fake in the test.
- Do not mock the system under test.
- Clock/UUID/rand: inject for determinism.
- Real DB: reserve for integration tests; isolate with schema/transaction per test.
Concurrency and flaky
go test -racein packages with goroutines.- Avoid
time.Sleep; use channels, conditions, or injected clocks. - Timeouts via
contextin tests that wait for events. - Clean global state (
ginmode ok; avoid mutable package vars).
Organization
internal/service/service_test.go # unit
internal/http/handlers/handler_test.go
_testexternal package (package foo_test) when testing the public API.- Internal package when internal whites are needed, with care.
- Small fixtures; builders instead of copied gigantic JSON.
Anti-patterns
- A single monolithic test without
t.Run - Asserting on an unstable error message as a contract
- Dependence on real network/clock without control
- Testing only the happy path of authz
- Excessive generated mocks for internal types
- Ignoring
-racein new concurrent code
Completion criteria
- Risks of the change covered (authz, validation, errors)
- Readable and deterministic table-driven tests
- Handlers exercised via HTTP when the contract matters
- Fakes only at the edge
go test(and-raceif concurrent) passing in the touched packages