chore: initial plan
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
---
|
||||
name: golang-testing
|
||||
description: "Production-ready Golang tests — table-driven tests, testify suites and mocks, parallel tests, fuzzing, fixtures, goroutine leak detection with goleak, snapshot testing, code coverage, integration tests, idiomatic test naming. Use when writing or reviewing Go tests, choosing a testing approach, setting up Go test CI, or debugging flaky/slow tests. For testify-specific APIs see `samber/cc-skills-golang@golang-stretchr-testify`; for measurement methodology see `samber/cc-skills-golang@golang-benchmark`."
|
||||
user-invocable: true
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.2.5"
|
||||
openclaw:
|
||||
emoji: "🧪"
|
||||
homepage: https://github.com/samber/cc-skills-golang
|
||||
requires:
|
||||
bins:
|
||||
- go
|
||||
- gotests
|
||||
install:
|
||||
- kind: go
|
||||
package: github.com/cweill/gotests/gotests@latest
|
||||
bins: [gotests]
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent Bash(gotests:*) AskUserQuestion
|
||||
---
|
||||
|
||||
**Persona:** You are a Go engineer who treats tests as executable specifications. You write tests to constrain behavior, not to hit coverage targets.
|
||||
|
||||
**Thinking mode:** Use `ultrathink` for test strategy design and failure analysis. Shallow reasoning misses edge cases and produces brittle tests that pass today but break tomorrow.
|
||||
|
||||
**Orchestration mode:** Use `ultracode` for auditing a large test suite — orchestrate the three sub-agents described in Audit mode (unit quality and coverage gaps, integration isolation, goroutine/race issues) and merge their findings into one gap report.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Write mode** — generating new tests for existing or new code. Work sequentially through the code under test; use `gotests` to scaffold table-driven tests, then enrich with edge cases and error paths.
|
||||
- **Review mode** — reviewing a PR's test changes. Focus on the diff: check coverage of new behaviour, assertion quality, table-driven structure, and absence of flakiness patterns. Sequential.
|
||||
- **Audit mode** — auditing an existing test suite for gaps, flakiness, or bad patterns (order-dependent tests, missing `t.Parallel()`, implementation-detail coupling). Launch up to 3 parallel sub-agents split by concern: (1) unit test quality and coverage gaps, (2) integration test isolation and build tags, (3) goroutine leaks and race conditions.
|
||||
- **Debug mode** — a test is failing or flaky. Work sequentially: reproduce reliably, isolate the failing assertion, trace the root cause in production code or test setup.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-testing` skill takes precedence.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- gotests: `go install github.com/cweill/gotests/gotests@latest`
|
||||
|
||||
# Go Testing Best Practices
|
||||
|
||||
This skill guides the creation of production-ready tests for Go applications. Follow these principles to write maintainable, fast, and reliable tests.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. Table-driven tests MUST use named subtests -- every test case needs a `name` field passed to `t.Run`
|
||||
2. Integration tests MUST use build tags (`//go:build integration`) to separate from unit tests
|
||||
3. Tests MUST NOT depend on execution order -- each test MUST be independently runnable
|
||||
4. Independent tests SHOULD use `t.Parallel()` when possible
|
||||
5. NEVER test implementation details -- test observable behavior and public API contracts
|
||||
6. Packages with goroutines SHOULD use `goleak.VerifyTestMain` in `TestMain` to detect goroutine leaks
|
||||
7. Use testify as helpers, not a replacement for standard library
|
||||
8. Mock interfaces, not concrete types
|
||||
9. Keep unit tests fast (< 1ms), use build tags for integration tests
|
||||
10. Run tests with race detection in CI
|
||||
11. Include examples as executable documentation
|
||||
12. Test files MUST be named after the source file under test, not after the function or method being tested
|
||||
13. Test functions SHOULD appear in the same order as the functions/methods they test in the source file
|
||||
|
||||
## Test Structure and Organization
|
||||
|
||||
### File Conventions
|
||||
|
||||
```go
|
||||
// package_test.go - tests in same package (white-box, access unexported)
|
||||
package mypackage
|
||||
|
||||
// mypackage_test.go - tests in test package (black-box, public API only)
|
||||
package mypackage_test
|
||||
```
|
||||
|
||||
Name the test file after the source file it tests, not after the function or method under test. Go's convention is one test file per source file (`foo.go` -> `foo_test.go`), because tools (`go test`, coverage reports, IDE "jump to test" navigation, `gotests`) and reviewers all resolve tests by source file, not by symbol. A source file usually declares several functions/methods; splitting its tests by symbol name scatters them across many files and breaks that file-to-file mapping.
|
||||
|
||||
```
|
||||
// ✓ Good — one test file per source file
|
||||
helloworld.go -> helloworld_test.go // contains TestHelloWorld, TestAbcd, TestXyz, ...
|
||||
|
||||
// ✗ Bad — test file named after the function/method instead of the source file
|
||||
helloworld.go -> abcd_test.go // wrong: should be helloworld_test.go
|
||||
```
|
||||
|
||||
Exception: very large source files MAY be split into multiple `_test.go` files by concern (e.g. `foo_test.go` + `foo_edgecases_test.go`), but each split file's name MUST still be derived from the source file name, never from an individual function name. Prefer keeping a single `_test.go` file per source file even when it grows large — splitting adds navigation overhead and is rarely worth it; reach for the exception only when a single file becomes genuinely unwieldy to browse or review.
|
||||
|
||||
Within a test file, order test functions to match the order their tested functions/methods appear in the source file. A reader (human or agent) scrolling `foo.go` alongside `foo_test.go` can then find the matching test by position instead of searching; drift between the two orderings compounds every time either file grows.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
```go
|
||||
func TestAdd(t *testing.T) { ... } // function test
|
||||
func TestMyStruct_MyMethod(t *testing.T) { ... } // method test
|
||||
func BenchmarkAdd(b *testing.B) { ... } // benchmark
|
||||
func ExampleAdd() { ... } // example
|
||||
func FuzzAdd(f *testing.F) { ... } // fuzz test
|
||||
```
|
||||
|
||||
## Table-Driven Tests
|
||||
|
||||
Table-driven tests are the idiomatic Go way to test multiple scenarios. Always name each test case.
|
||||
|
||||
```go
|
||||
func TestCalculatePrice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quantity int
|
||||
unitPrice float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "single item",
|
||||
quantity: 1,
|
||||
unitPrice: 10.0,
|
||||
expected: 10.0,
|
||||
},
|
||||
{
|
||||
name: "bulk discount - 100 items",
|
||||
quantity: 100,
|
||||
unitPrice: 10.0,
|
||||
expected: 900.0, // 10% discount
|
||||
},
|
||||
{
|
||||
name: "zero quantity",
|
||||
quantity: 0,
|
||||
unitPrice: 10.0,
|
||||
expected: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CalculatePrice(tt.quantity, tt.unitPrice)
|
||||
if got != tt.expected {
|
||||
t.Errorf("CalculatePrice(%d, %.2f) = %.2f, want %.2f",
|
||||
tt.quantity, tt.unitPrice, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfall: Assert Scope Leaking into Subtests
|
||||
|
||||
Never create a testify `assert`/`require` instance in the parent test function and reuse it inside `t.Run` closures. `assert.New(t)` captures the exact `*testing.T` it was built with, so if that `t` belongs to the parent, every failure raised inside the subtest gets attributed to the *parent* test in `go test` output — the failing subtest itself still reports `--- PASS`, silently hiding which case broke. This happens whether or not the subtest calls `t.Parallel()`.
|
||||
|
||||
```go
|
||||
// WRONG -- `is` is bound to the parent's t
|
||||
func TestCalculatePrice(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is.Equal(tt.expected, CalculatePrice(tt.quantity, tt.unitPrice)) // misattributed on failure
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RIGHT -- each subtest builds its own instance from its own t
|
||||
func TestCalculatePrice(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
is.Equal(tt.expected, CalculatePrice(tt.quantity, tt.unitPrice))
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify with a deliberately-broken case: if `go test -v -run TestName` shows `--- FAIL: TestName` but every `--- PASS: TestName/subtest_name` line still says PASS, the assert scope is leaking.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
Unit tests should be fast (< 1ms), isolated (no external dependencies), and deterministic.
|
||||
|
||||
## Testing HTTP Handlers
|
||||
|
||||
Use `httptest` for handler tests with table-driven patterns. See [HTTP Testing](./references/http-testing.md) for examples with request/response bodies, query parameters, headers, and status code assertions.
|
||||
|
||||
## Goroutine Leak Detection with goleak
|
||||
|
||||
Use `go.uber.org/goleak` to detect leaking goroutines, especially for concurrent code:
|
||||
|
||||
```go
|
||||
import (
|
||||
"testing"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
```
|
||||
|
||||
To exclude specific goroutine stacks (for known leaks or library goroutines):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreCurrent(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Or per-test:
|
||||
|
||||
```go
|
||||
func TestWorkerPool(t *testing.T) {
|
||||
defer goleak.VerifyNone(t)
|
||||
// ... test code ...
|
||||
}
|
||||
```
|
||||
|
||||
## testing/synctest for Deterministic Goroutine Testing
|
||||
|
||||
`testing/synctest` (Go 1.25+) provides deterministic tests for goroutines, timers, deadlines, and context cancellation. Time advances only when all goroutines are blocked, making ordering predictable.
|
||||
|
||||
When to use `synctest` instead of real time:
|
||||
|
||||
- Testing concurrent code with time-based operations (time.Sleep, time.After, time.Ticker)
|
||||
- When race conditions need to be reproducible
|
||||
- When tests are flaky due to timing issues
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextTimeout(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
const timeout = 5 * time.Second
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
time.Sleep(timeout - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Fatalf("before timeout: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
if err := ctx.Err(); err != context.DeadlineExceeded {
|
||||
t.Fatalf("after timeout: got %v, want DeadlineExceeded", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Use `synctest.Test` in Go 1.25+ and Go 1.26+. Do not use the old Go 1.24 experimental `synctest.Run` API in Go 1.25+ or Go 1.26+ code. If a module explicitly targets Go 1.24 and opts into `GOEXPERIMENT=synctest`, use the old API only as a compatibility fallback.
|
||||
|
||||
Key differences in `synctest`:
|
||||
|
||||
- `time.Sleep` advances synthetic time instantly when the goroutine blocks
|
||||
- `time.After` fires when synthetic time reaches the duration
|
||||
- All goroutines run to blocking points before time advances
|
||||
- Test execution is deterministic and repeatable
|
||||
|
||||
## Test Timeouts
|
||||
|
||||
For tests that may hang, use a timeout helper that panics with caller location. See [Helpers](./references/helpers.md).
|
||||
|
||||
## Benchmarks
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-benchmark` skill for advanced benchmarking: `b.Loop()` (Go 1.24+), `benchstat`, profiling from benchmarks, and CI regression detection.
|
||||
|
||||
Write benchmarks to measure performance and detect regressions:
|
||||
|
||||
```go
|
||||
func BenchmarkStringConcatenation(b *testing.B) {
|
||||
b.Run("plus-operator", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
result := "a" + "b" + "c"
|
||||
_ = result
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("strings.Builder", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("a")
|
||||
builder.WriteString("b")
|
||||
builder.WriteString("c")
|
||||
_ = builder.String()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Benchmarks with different input sizes:
|
||||
|
||||
```go
|
||||
func BenchmarkFibonacci(b *testing.B) {
|
||||
sizes := []int{10, 20, 30}
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
Fibonacci(size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Go 1.24+, new benchmarks should use `b.Loop()`. Use legacy `b.N` loops only when the module targets Go <1.24 or when preserving old benchmark code intentionally.
|
||||
|
||||
### Go 1.26+: test artifacts
|
||||
|
||||
When a test, benchmark, or fuzz target needs to persist files for inspection, use `ArtifactDir()` instead of ad-hoc paths or repo-local output.
|
||||
|
||||
```go
|
||||
func TestRenderGoldenArtifact(t *testing.T) {
|
||||
dir := t.ArtifactDir()
|
||||
|
||||
out := filepath.Join(dir, "rendered.json")
|
||||
if err := os.WriteFile(out, renderedBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("artifact written: %s", out)
|
||||
}
|
||||
```
|
||||
|
||||
Available on `*testing.T`, `*testing.B`, and `*testing.F` in Go 1.26+.
|
||||
|
||||
## Parallel Tests
|
||||
|
||||
Use `t.Parallel()` to run tests concurrently:
|
||||
|
||||
```go
|
||||
func TestParallelOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"small data", make([]byte, 1024)},
|
||||
{"medium data", make([]byte, 1024*1024)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
result := Process(tt.data)
|
||||
is.NotNil(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fuzzing
|
||||
|
||||
Use fuzzing to find edge cases and bugs:
|
||||
|
||||
```go
|
||||
func FuzzReverse(f *testing.F) {
|
||||
f.Add("hello")
|
||||
f.Add("")
|
||||
f.Add("a")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
reversed := Reverse(input)
|
||||
doubleReversed := Reverse(reversed)
|
||||
if input != doubleReversed {
|
||||
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", input, doubleReversed, input)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Examples as Documentation
|
||||
|
||||
Examples are executable documentation verified by `go test`:
|
||||
|
||||
```go
|
||||
func ExampleCalculatePrice() {
|
||||
price := CalculatePrice(100, 10.0)
|
||||
fmt.Printf("Price: %.2f\n", price)
|
||||
// Output: Price: 900.00
|
||||
}
|
||||
|
||||
func ExampleCalculatePrice_singleItem() {
|
||||
price := CalculatePrice(1, 25.50)
|
||||
fmt.Printf("Price: %.2f\n", price)
|
||||
// Output: Price: 25.50
|
||||
}
|
||||
```
|
||||
|
||||
## Code Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage file
|
||||
go test -coverprofile=coverage.out ./...
|
||||
|
||||
# View coverage in HTML
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Coverage by function
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
# Total coverage percentage
|
||||
go tool cover -func=coverage.out | grep total
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Use build tags to separate integration tests from unit tests:
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package mypackage
|
||||
|
||||
func TestDatabaseIntegration(t *testing.T) {
|
||||
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test real database operations
|
||||
}
|
||||
```
|
||||
|
||||
Run integration tests separately:
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./...
|
||||
```
|
||||
|
||||
For Docker Compose fixtures, SQL schemas, and integration test suites, see [Integration Testing](./references/integration-testing.md).
|
||||
|
||||
## Mocking
|
||||
|
||||
Mock interfaces, not concrete types. Define interfaces where consumed, then create mock implementations.
|
||||
|
||||
For mock patterns, test fixtures, and time mocking, see [Mocking](./references/mocking.md).
|
||||
|
||||
## Enforce with Linters
|
||||
|
||||
Many test best practices are enforced automatically by linters: `thelper`, `paralleltest`, `testifylint`. See the `samber/cc-skills-golang@golang-lint` skill for configuration and usage.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- -> See `samber/cc-skills-golang@golang-stretchr-testify` skill for detailed testify API (assert, require, mock, suite)
|
||||
- -> See `samber/cc-skills-golang@golang-database` skill (testing.md) for database integration test patterns
|
||||
- -> See `samber/cc-skills-golang@golang-concurrency` skill for goroutine leak detection with goleak
|
||||
- -> See `samber/cc-skills-golang@golang-continuous-integration` skill for CI test configuration and GitHub Actions workflows
|
||||
- -> See `samber/cc-skills-golang@golang-lint` skill for testifylint and paralleltest configuration
|
||||
- -> See `samber/cc-skills-golang@golang-continuous-integration` skill for automated AI-driven code review in CI using these guidelines
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
go test ./... # all tests
|
||||
go test -run TestName ./... # specific test by exact name
|
||||
go test -run TestName/subtest ./... # subtests within a test
|
||||
go test -run 'Test(Add|Sub)' ./... # multiple tests (regexp OR)
|
||||
go test -run 'Test[A-Z]' ./... # tests starting with capital letter
|
||||
go test -run 'TestUser.*' ./... # tests matching prefix
|
||||
go test -run '.*Validation.*' ./... # tests containing substring
|
||||
go test -run TestName/. ./... # all subtests of TestName
|
||||
go test -run '/(unit|integration)' ./... # filter by subtest name
|
||||
go test -race ./... # race detection
|
||||
go test -cover ./... # coverage summary
|
||||
go test -bench=. -benchmem ./... # benchmarks
|
||||
go test -fuzz=FuzzName ./... # fuzzing
|
||||
go test -tags=integration ./... # integration tests
|
||||
```
|
||||
@@ -0,0 +1,388 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "goleak-goroutine-leak-detection",
|
||||
"description": "Tests use goleak for goroutine leak detection, not just task completion",
|
||||
"prompt": "Write tests for a `workerpool` package. The package has a `Pool` struct with `Start(numWorkers int)`, `Submit(task func())`, and `Stop()` methods. Start spawns goroutines, Submit enqueues work, Stop shuts down gracefully. Write comprehensive unit tests covering start, submit tasks, and stop.",
|
||||
"trap": "Model writes normal unit tests verifying task completion but omits goroutine leak detection — Stop() may appear to work while leaking goroutines",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "1.1",
|
||||
"text": "Uses goleak (go.uber.org/goleak) — either goleak.VerifyTestMain in TestMain or goleak.VerifyNone per-test — to detect goroutine leaks from the worker pool"
|
||||
},
|
||||
{
|
||||
"id": "1.2",
|
||||
"text": "Has a TestMain function if using goleak.VerifyTestMain (the package-level approach)"
|
||||
},
|
||||
{
|
||||
"id": "1.3",
|
||||
"text": "Tests verify that Stop() properly cleans up goroutines (not just that tasks complete)"
|
||||
},
|
||||
{
|
||||
"id": "1.4",
|
||||
"text": "Imports go.uber.org/goleak"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "integration-build-tag-not-testing-short",
|
||||
"description": "Integration tests use //go:build integration tag; testing.Short() is not an acceptable alternative",
|
||||
"prompt": "Our team disagrees on how to separate integration tests from unit tests in our Go project. A teammate proposes:\n\n```go\nfunc TestUserRepository_Create(t *testing.T) {\n if testing.Short() {\n t.Skip(\"skipping integration test\")\n }\n db := connectToPostgres(t)\n // ... test ...\n}\n```\n\nThey argue: 'testing.Short() is the Go standard way — it's in the stdlib, you can configure it with -short, and every Go developer knows it. Build tags are extra complexity for no benefit.'\n\nHow should integration tests be separated? Is the teammate's approach correct? Write the correct implementation for a TestUserRepository_Create integration test.",
|
||||
"trap": "Model accepts testing.Short() as a valid approach because it's a stdlib feature and the teammate's argument sounds reasonable. The skill teaches: build tags are required because testing.Short() still compiles tests into the binary, requires a flag to skip, and leaks DB connection attempts into normal test runs.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "2.1",
|
||||
"text": "Rejects testing.Short() as the primary separation mechanism — does not accept the teammate's approach as correct"
|
||||
},
|
||||
{
|
||||
"id": "2.2",
|
||||
"text": "Uses `//go:build integration` build tag (at the file level, before the package declaration)"
|
||||
},
|
||||
{
|
||||
"id": "2.3",
|
||||
"text": "Explains why build tags are preferred: tests using testing.Short() still compile and attempt connections when running without -short, whereas build-tagged files are completely excluded from compilation"
|
||||
},
|
||||
{
|
||||
"id": "2.4",
|
||||
"text": "Includes the command to run integration tests: go test -tags=integration ./..."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "parallel-subtests-pure-function",
|
||||
"description": "Pure function subtests call t.Parallel(); top-level test also parallel",
|
||||
"prompt": "Write table-driven tests for a pure function `Slugify(input string) string` that converts titles to URL-friendly slugs (lowercase, hyphens for spaces, strips special chars). Test at least 6 cases: normal title, unicode, multiple spaces, empty string, already-slugified input, and special characters only.",
|
||||
"trap": "Model omits t.Parallel() since the function is pure and 'already fast enough', missing parallelism opportunities for stateless tests",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "3.1",
|
||||
"text": "Subtests call t.Parallel() — these are independent pure function tests with no shared mutable state"
|
||||
},
|
||||
{
|
||||
"id": "3.2",
|
||||
"text": "Top-level test function also calls t.Parallel()"
|
||||
},
|
||||
{
|
||||
"id": "3.3",
|
||||
"text": "Each test case has a descriptive `name` field used in t.Run"
|
||||
},
|
||||
{
|
||||
"id": "3.4",
|
||||
"text": "At least 6 test cases as requested"
|
||||
},
|
||||
{
|
||||
"id": "3.5",
|
||||
"text": "No shared mutable state between subtests (each subtest captures its own test case variable)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "fake-clock-injection-for-time-dependent-tests",
|
||||
"description": "Time-dependent code must accept a clock interface so tests can use clockwork.FakeClock; real time.Sleep is unacceptable",
|
||||
"prompt": "Here is an existing RateLimiter implementation:\n\n```go\ntype RateLimiter struct {\n limit int\n window time.Duration\n count int\n resetAt time.Time\n}\n\nfunc NewRateLimiter(limit int, window time.Duration) *RateLimiter {\n return &RateLimiter{\n limit: limit,\n window: window,\n resetAt: time.Now().Add(window),\n }\n}\n\nfunc (r *RateLimiter) Allow() bool {\n now := time.Now()\n if now.After(r.resetAt) {\n r.count = 0\n r.resetAt = now.Add(r.window)\n }\n if r.count >= r.limit {\n return false\n }\n r.count++\n return true\n}\n```\n\nWrite tests that verify:\n1. Allow() returns true while under the limit\n2. Allow() returns false when the limit is exceeded\n3. The counter resets after the time window expires\n\nThe tests must run in milliseconds, not seconds. You may modify the implementation if needed.",
|
||||
"trap": "Model uses time.Sleep(window + small margin) to test window expiration — the code uses time.Now() directly, making tests slow and flaky. The skill teaches to refactor the code to accept a clock interface (clockwork.Clock) and inject a FakeClock in tests.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "4.1",
|
||||
"text": "Modifies the RateLimiter to accept a clock interface (e.g., clockwork.Clock or a custom Now() func) rather than calling time.Now() directly"
|
||||
},
|
||||
{
|
||||
"id": "4.2",
|
||||
"text": "Uses clockwork.FakeClock (or equivalent) in tests to advance time without real sleeping — tests run in microseconds"
|
||||
},
|
||||
{
|
||||
"id": "4.3",
|
||||
"text": "Tests the window reset scenario by advancing the fake clock past the window duration (e.g., fakeClock.Advance(window + time.Millisecond))"
|
||||
},
|
||||
{
|
||||
"id": "4.4",
|
||||
"text": "No real-time time.Sleep in test code; use synctest.Test/synctest.Wait or a fake clock for deterministic synthetic time"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "consumer-site-interface-mocking",
|
||||
"description": "Tests define interfaces at the consumer site and mock those, not concrete structs",
|
||||
"prompt": "Test a `NotificationService` struct that has a `NotifyUser(userID string) error` method. It depends on two concrete structs: `SMTPClient` (with `Send(to, subject, body string) error`) and `AuditLogger` (with `Log(event string) error`). NotifyUser looks up the user's email, sends an email via SMTPClient, and logs the event via AuditLogger. Write comprehensive tests for NotifyUser.",
|
||||
"trap": "Model embeds or wraps concrete SMTPClient/AuditLogger in mock structs, or creates test doubles that shadow the concrete types, instead of extracting consumer-site interfaces",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "5.1",
|
||||
"text": "Defines interfaces for the dependencies (e.g., EmailSender, Logger) rather than using the concrete SMTPClient/AuditLogger structs directly in tests"
|
||||
},
|
||||
{
|
||||
"id": "5.2",
|
||||
"text": "Creates mock implementations of these interfaces (using testify/mock or manual mocks)"
|
||||
},
|
||||
{
|
||||
"id": "5.3",
|
||||
"text": "Does NOT embed or wrap the concrete SMTPClient/AuditLogger structs in mock objects"
|
||||
},
|
||||
{
|
||||
"id": "5.4",
|
||||
"text": "Uses dependency injection — NotificationService accepts interfaces, not concrete types"
|
||||
},
|
||||
{
|
||||
"id": "5.5",
|
||||
"text": "Tests verify both happy path (send succeeds) and error scenarios (send fails, log fails)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "test-observable-behavior-not-internals",
|
||||
"description": "Tests verify behavior via public API only, not by inspecting the internal map field",
|
||||
"prompt": "Test a `UserCache` struct with `Get(id string) (*User, bool)`, `Set(id string, user *User)`, and `Len() int` methods. The cache has an internal `data map[string]*User` field. Write tests that verify the cache stores and retrieves users correctly. Make sure to verify the internal map state is consistent after each operation.",
|
||||
"trap": "Model directly accesses the internal data map field to verify state, coupling tests to implementation details",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "6.1",
|
||||
"text": "Tests observable behavior through Get/Set/Len public API only"
|
||||
},
|
||||
{
|
||||
"id": "6.2",
|
||||
"text": "Does NOT directly access or inspect the internal `data` map field"
|
||||
},
|
||||
{
|
||||
"id": "6.3",
|
||||
"text": "Does NOT use same-package (white-box) testing to examine cache internals"
|
||||
},
|
||||
{
|
||||
"id": "6.4",
|
||||
"text": "Tests cover cache hit, cache miss, overwrite, and Len() correctness"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "external-test-package-resists-internal-access",
|
||||
"description": "Tests use package_test to black-box test; does not access unexported state even when a teammate suggests it",
|
||||
"prompt": "Write tests for a `tokenizer` package that exports `Tokenize(input string) []Token` and a `Token` struct with `Type` and `Value` fields. The package has an unexported `tokenCache map[string][]Token` field on the package level for memoization.\n\nA teammate says: 'We should verify the cache is being populated correctly — access tokenCache directly in the tests. It's in the same package so it's fine, and it makes the tests more reliable since we can confirm memoization is working.'\n\nWrite the tests. Should you follow the teammate's suggestion?",
|
||||
"trap": "Model follows the teammate's suggestion and uses package tokenizer (white-box) to access tokenCache directly, reasoning that it makes tests more reliable. The skill teaches to use package tokenizer_test and test only observable behavior — cache behavior is an implementation detail, not a contract.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "7.1",
|
||||
"text": "Rejects the teammate's suggestion — does not access tokenCache directly in tests"
|
||||
},
|
||||
{
|
||||
"id": "7.2",
|
||||
"text": "Uses `package tokenizer_test` (external test package) instead of `package tokenizer`"
|
||||
},
|
||||
{
|
||||
"id": "7.3",
|
||||
"text": "Explains why: cache behavior is an implementation detail — tests should verify the observable contract (same input → same output), not whether a specific optimization is in place"
|
||||
},
|
||||
{
|
||||
"id": "7.4",
|
||||
"text": "Tests focus on functional correctness: same input tokenizes identically on repeated calls, edge cases, different token types"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "example-functions-as-documentation",
|
||||
"description": "Packages need Example functions with // Output: comments that serve as executable documentation in go doc",
|
||||
"prompt": "I've written a `currency` package with these functions:\n\n```go\nfunc Format(cents int64, code string) string // Format(1234, \"USD\") → \"$12.34\"\nfunc Parse(s string) (int64, string, error) // Parse(\"$12.34\") → 1234, \"USD\", nil\nfunc Convert(cents int64, from, to string, rate float64) int64\n```\n\nA colleague says: 'The functions are self-explanatory — names and signatures are clear enough. We don't need extra documentation. Just write unit tests with good coverage.'\n\nWrite comprehensive test coverage for this package. Should you follow the colleague's advice about documentation?",
|
||||
"trap": "Model follows the colleague's advice and writes only table-driven unit tests without Example functions — missing the executable documentation that shows real usage in `go doc` and on pkg.go.dev. The skill teaches that Example functions serve as both tests and documentation.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "8.1",
|
||||
"text": "Disagrees with the colleague — includes Example functions despite the advice to skip them"
|
||||
},
|
||||
{
|
||||
"id": "8.2",
|
||||
"text": "Includes at least one Example function (ExampleFormat, ExampleParse, or ExampleConvert)"
|
||||
},
|
||||
{
|
||||
"id": "8.3",
|
||||
"text": "Example functions have `// Output:` comments so they are verified by go test"
|
||||
},
|
||||
{
|
||||
"id": "8.4",
|
||||
"text": "Explains that Example functions serve as executable documentation visible in go doc and pkg.go.dev — not just tests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "fuzz-test-for-critical-functions",
|
||||
"description": "Security-critical functions get fuzz tests with seed corpus and property assertions",
|
||||
"prompt": "Write tests for a `SanitizeHTML(input string) string` function that strips all HTML tags from input while preserving text content. Make sure to test edge cases thoroughly — this function is critical for security.",
|
||||
"trap": "Model writes only table-driven tests for known edge cases, missing the fuzz test that would discover unexpected inputs causing XSS vulnerabilities",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "9.1",
|
||||
"text": "Includes a fuzz test function (FuzzSanitizeHTML or similar)"
|
||||
},
|
||||
{
|
||||
"id": "9.2",
|
||||
"text": "Fuzz test uses f.Add() to provide seed corpus entries"
|
||||
},
|
||||
{
|
||||
"id": "9.3",
|
||||
"text": "Fuzz test includes property-based assertions (e.g., output contains no < or > characters, or double-sanitize is idempotent)"
|
||||
},
|
||||
{
|
||||
"id": "9.4",
|
||||
"text": "Also includes regular table-driven tests for known edge cases"
|
||||
},
|
||||
{
|
||||
"id": "9.5",
|
||||
"text": "Table tests cover tricky cases like nested tags, unclosed tags, or script tags"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "test-helper-t-helper-attribution",
|
||||
"description": "Test helpers must call t.Helper() so failures point to the caller's line, not the helper's internal line",
|
||||
"prompt": "I have this test helper and some tests using it:\n\n```go\nfunc requireNoError(t *testing.T, err error, msg string) {\n if err != nil {\n t.Fatalf(\"%s: unexpected error: %v\", msg, err)\n }\n}\n\nfunc TestProcessOrder(t *testing.T) {\n order := NewOrder(\"prod-1\", 2)\n err := order.Validate()\n requireNoError(t, err, \"validate\")\n\n err = order.Submit()\n requireNoError(t, err, \"submit\")\n}\n```\n\nWhen Validate() fails, the test output reports a failure at the `t.Fatalf` line inside `requireNoError`, not at the `requireNoError(t, err, \"validate\")` call site in `TestProcessOrder`. Is this a problem? How do you fix it?",
|
||||
"trap": "Model says this is expected behavior or suggests switching to t.Error() instead of the real fix. The skill teaches that t.Helper() must be called as the first statement in the helper so Go's test framework reports failures at the caller's line.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "10.1",
|
||||
"text": "Identifies this as a real problem — the line number pointing to the helper's internal Fatalf is unhelpful for debugging which call caused the failure"
|
||||
},
|
||||
{
|
||||
"id": "10.2",
|
||||
"text": "Fixes it by adding t.Helper() as the first statement in requireNoError — not by restructuring the helper or using a different assertion method"
|
||||
},
|
||||
{
|
||||
"id": "10.3",
|
||||
"text": "Explains that t.Helper() marks the function as a test helper so that the testing framework reports the caller's file:line instead of the helper's file:line"
|
||||
},
|
||||
{
|
||||
"id": "10.4",
|
||||
"text": "Does NOT suggest switching to t.Error() as the fix — t.Helper() is the correct solution regardless of t.Fatal vs t.Error"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "httptest-recorder-not-real-server",
|
||||
"description": "HTTP handler tests use httptest.NewRecorder, not a real HTTP server",
|
||||
"prompt": "Write end-to-end tests for a REST API handler `HandleCreateOrder(w http.ResponseWriter, r *http.Request)` that accepts POST with JSON body `{\"product\": \"...\", \"quantity\": N}`. It returns 201 with the order JSON on success, 400 for invalid JSON, and 422 for validation errors (empty product, quantity <= 0). Test it like a real client would call it.",
|
||||
"trap": "Model starts a real HTTP server with httptest.NewServer or net/http ListenAndServe, adding unnecessary network overhead and port allocation to tests",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "11.1",
|
||||
"text": "Uses httptest.NewRecorder (not httptest.NewServer or a real HTTP server)"
|
||||
},
|
||||
{
|
||||
"id": "11.2",
|
||||
"text": "Table-driven with named test cases covering multiple scenarios"
|
||||
},
|
||||
{
|
||||
"id": "11.3",
|
||||
"text": "Tests at least 3 status codes (201, 400, 422)"
|
||||
},
|
||||
{
|
||||
"id": "11.4",
|
||||
"text": "Verifies response body content (not just status code)"
|
||||
},
|
||||
{
|
||||
"id": "11.5",
|
||||
"text": "Sets proper Content-Type header on requests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "testify-suite-for-integration",
|
||||
"description": "Integration tests use testify/suite with SetupSuite/TearDownTest for organized setup/teardown",
|
||||
"prompt": "Write integration tests for an `OrderRepository` that interacts with PostgreSQL. It has `Create(order *Order) error`, `GetByID(id string) (*Order, error)`, and `ListByUserID(userID string) ([]*Order, error)`. Tests need database setup (create tables), per-test data cleanup, and graceful teardown. Organize them cleanly so setup/teardown happens automatically. These must not run during normal unit tests.",
|
||||
"trap": "Model uses TestMain or plain setup functions with defer for teardown, mixing setup concerns into each test instead of a suite",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "12.1",
|
||||
"text": "Uses testify/suite.Suite struct embedding for test organization"
|
||||
},
|
||||
{
|
||||
"id": "12.2",
|
||||
"text": "Has SetupSuite (or similar) for one-time database connection and schema setup"
|
||||
},
|
||||
{
|
||||
"id": "12.3",
|
||||
"text": "Has SetupTest or TearDownTest for per-test data cleanup (e.g., TRUNCATE)"
|
||||
},
|
||||
{
|
||||
"id": "12.4",
|
||||
"text": "Has TearDownSuite for graceful shutdown (close DB, docker-compose down)"
|
||||
},
|
||||
{
|
||||
"id": "12.5",
|
||||
"text": "Uses `//go:build integration` build tag"
|
||||
},
|
||||
{
|
||||
"id": "12.6",
|
||||
"text": "Has a runner function `func TestXxx(t *testing.T) { suite.Run(t, ...) }`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "benchmark-report-allocs-and-input-sizes",
|
||||
"description": "Benchmarks use b.ReportAllocs(), test multiple input sizes, and follow naming conventions",
|
||||
"prompt": "Write benchmarks for a `Compress(data []byte) ([]byte, error)` function that compresses byte slices. We need to measure performance to decide if this is fast enough for our hot path. Just write the benchmark tests.",
|
||||
"trap": "Model writes a single benchmark with one input size and omits b.ReportAllocs(), missing allocation tracking and size-scaling analysis",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "13.1",
|
||||
"text": "Calls b.ReportAllocs() to track memory allocations per operation"
|
||||
},
|
||||
{
|
||||
"id": "13.2",
|
||||
"text": "Tests multiple input sizes using b.Run with descriptive sub-benchmark names (e.g., size=1KB, size=1MB)"
|
||||
},
|
||||
{
|
||||
"id": "13.3",
|
||||
"text": "Uses b.Loop() for Go 1.24+ benchmark loops; uses legacy b.N only for older module targets"
|
||||
},
|
||||
{
|
||||
"id": "13.4",
|
||||
"text": "Follows benchmark naming convention: BenchmarkCompress or BenchmarkCompress_<variant>"
|
||||
},
|
||||
{
|
||||
"id": "13.5",
|
||||
"text": "Prevents compiler optimization of the result (assigns to a package-level variable or uses _ =)"
|
||||
},
|
||||
{
|
||||
"id": "13.6",
|
||||
"text": "Does NOT include setup/allocation costs inside the timed loop (or uses b.ResetTimer if setup is needed)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "race-detection-and-test-independence",
|
||||
"description": "Tests for concurrent code include -race flag guidance and ensure test independence (no order dependence)",
|
||||
"prompt": "Write tests for a `SafeMap[K comparable, V any]` struct that provides a goroutine-safe map with `Get(key K) (V, bool)`, `Set(key K, value V)`, `Delete(key K)`, and `Len() int` methods. Multiple goroutines will call these concurrently. Write thorough tests including concurrent access scenarios. Also include a note on how to run these tests in CI.",
|
||||
"trap": "Model writes concurrent tests but omits -race flag guidance for CI and doesn't ensure tests are independently runnable (e.g., shares map state between test functions)",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "14.1",
|
||||
"text": "Includes concurrent test scenarios where multiple goroutines call Get/Set/Delete simultaneously"
|
||||
},
|
||||
{
|
||||
"id": "14.2",
|
||||
"text": "Recommends running with -race flag (go test -race) for CI or includes it in a run command comment"
|
||||
},
|
||||
{
|
||||
"id": "14.3",
|
||||
"text": "Each test function creates its own SafeMap instance — no shared state between test functions"
|
||||
},
|
||||
{
|
||||
"id": "14.4",
|
||||
"text": "Uses sync.WaitGroup or similar synchronization to coordinate concurrent test goroutines"
|
||||
},
|
||||
{
|
||||
"id": "14.5",
|
||||
"text": "Tests are independently runnable (any single test can pass when run in isolation with -run)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Test Helpers
|
||||
|
||||
## Test Timeout
|
||||
|
||||
For tests that may hang, use a timeout helper that panics with caller location:
|
||||
|
||||
```go
|
||||
// https://github.com/stretchr/testify/issues/1101
|
||||
func testWithTimeout(t *testing.T, timeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
testFinished := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(testFinished)
|
||||
})
|
||||
|
||||
var pc [1]uintptr
|
||||
n := runtime.Callers(2, pc[:])
|
||||
line, funcName := "", ""
|
||||
if n > 0 {
|
||||
frames := runtime.CallersFrames(pc[:])
|
||||
frame, _ := frames.Next()
|
||||
line = frame.File + ":" + strconv.Itoa(frame.Line)
|
||||
funcName = frame.Function
|
||||
}
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-testFinished:
|
||||
case <-time.After(timeout):
|
||||
panic(fmt.Sprintf("%s: Test timed out after: %v\n%s", funcName, timeout, line))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Usage
|
||||
func TestLongRunningOperation(t *testing.T) {
|
||||
testWithTimeout(t, 2*time.Second)
|
||||
result := LongRunningOperation()
|
||||
// If this takes longer than 2 seconds, the test panics with location info
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# HTTP Handler Testing
|
||||
|
||||
Use `httptest` package for testing HTTP handlers without starting a server.
|
||||
|
||||
## Basic Handler Test
|
||||
|
||||
```go
|
||||
func TestCreateUserHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: `{"name": "Alice", "email": "alice@example.com"}`,
|
||||
expectedStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
body: `invalid json`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(CreateUserHandler)
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
is.Equal(tt.expectedStatus, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters and Headers
|
||||
|
||||
```go
|
||||
func TestListUsersHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
authHeader string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "paginated results",
|
||||
query: "?page=1&limit=10",
|
||||
authHeader: "Bearer token123",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "missing auth",
|
||||
query: "?page=1",
|
||||
authHeader: "",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users"+tt.query, nil)
|
||||
if tt.authHeader != "" {
|
||||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler := AuthMiddleware(ListUsersHandler)
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
is.Equal(tt.expectedStatus, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,187 @@
|
||||
# Integration Testing
|
||||
|
||||
## Docker Compose Fixture
|
||||
|
||||
Create `pkg/myfeature/testdata/docker-compose.yml` for test services:
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: testdb
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U test"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
```
|
||||
|
||||
## SQL Schema Fixture
|
||||
|
||||
Create `pkg/myfeature/testdata/schema.sql` for database initialization:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Test Data Fixture
|
||||
|
||||
Create `pkg/myfeature/testdata/testdata.sql`:
|
||||
|
||||
```sql
|
||||
INSERT INTO users (name, email) VALUES
|
||||
('Alice Johnson', 'alice@example.com'),
|
||||
('Bob Smith', 'bob@example.com'),
|
||||
('Charlie Brown', 'charlie@example.com');
|
||||
|
||||
INSERT INTO orders (user_id, amount, status) VALUES
|
||||
(1, 100.00, 'completed'),
|
||||
(1, 50.00, 'pending'),
|
||||
(2, 200.00, 'completed');
|
||||
```
|
||||
|
||||
## Using Fixtures in Tests
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type DatabaseTestSuite struct {
|
||||
suite.Suite
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *DatabaseTestSuite) SetupSuite() {
|
||||
cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "up", "-d")
|
||||
if err := cmd.Run(); err != nil {
|
||||
s.T().Fatalf("failed to start docker-compose: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://test:test@localhost:5433/testdb?sslmode=disable")
|
||||
if err != nil {
|
||||
s.T().Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
s.db = db
|
||||
|
||||
schema, _ := os.ReadFile("testdata/schema.sql")
|
||||
_, err = db.Exec(string(schema))
|
||||
if err != nil {
|
||||
s.T().Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DatabaseTestSuite) TearDownSuite() {
|
||||
cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "down", "-v")
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
func (s *DatabaseTestSuite) SetupTest() {
|
||||
_, err := s.db.Exec("TRUNCATE TABLE orders, users CASCADE")
|
||||
if err != nil {
|
||||
s.T().Fatalf("failed to clear database: %v", err)
|
||||
}
|
||||
|
||||
testdata, _ := os.ReadFile("testdata/testdata.sql")
|
||||
_, err = s.db.Exec(string(testdata))
|
||||
if err != nil {
|
||||
s.T().Fatalf("failed to load test data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DatabaseTestSuite) TestUserCount() {
|
||||
is := assert.New(s.T())
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
is.NoError(err)
|
||||
is.Equal(3, count)
|
||||
}
|
||||
|
||||
func (s *DatabaseTestSuite) TestOrderSum() {
|
||||
is := assert.New(s.T())
|
||||
|
||||
var sum float64
|
||||
err := s.db.QueryRow("SELECT SUM(amount) FROM orders").Scan(&sum)
|
||||
is.NoError(err)
|
||||
is.InDelta(350.0, sum, 0.01)
|
||||
}
|
||||
|
||||
func TestDatabaseTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(DatabaseTestSuite))
|
||||
}
|
||||
```
|
||||
|
||||
## Test Helper with Embedded Fixtures
|
||||
|
||||
```go
|
||||
package myfeature
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed testdata/schema.sql testdata/testdata.sql
|
||||
var fixtures embed.FS
|
||||
|
||||
func SetupDB(db *sql.DB) error {
|
||||
schema, err := fixtures.ReadFile("testdata/schema.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(schema)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := fixtures.ReadFile("testdata/testdata.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,206 @@
|
||||
# Mocking and Test Fixtures
|
||||
|
||||
## Mocks with testify/mock
|
||||
|
||||
Create interfaces for your dependencies, then mock them.
|
||||
|
||||
> For the full testify/mock API (argument matchers, call modifiers, verification), see the `samber/cc-skills-golang@golang-stretchr-testify` skill.
|
||||
|
||||
```go
|
||||
// Define the interface
|
||||
type Database interface {
|
||||
GetUser(id string) (*User, error)
|
||||
CreateUser(user *User) error
|
||||
}
|
||||
|
||||
// Mock implementation
|
||||
type MockDatabase struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDatabase) GetUser(id string) (*User, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockDatabase) CreateUser(user *User) error {
|
||||
args := m.Called(user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Usage in tests
|
||||
func TestService_GetUser(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
mockDB := new(MockDatabase)
|
||||
service := NewService(mockDB)
|
||||
|
||||
expectedUser := &User{ID: "1", Name: "John"}
|
||||
mockDB.On("GetUser", "1").Return(expectedUser, nil)
|
||||
|
||||
user, err := service.GetUser("1")
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal(expectedUser, user)
|
||||
mockDB.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestService_GetUser_NotFound(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
mockDB := new(MockDatabase)
|
||||
service := NewService(mockDB)
|
||||
|
||||
mockDB.On("GetUser", "999").Return(nil, ErrNotFound)
|
||||
|
||||
user, err := service.GetUser("999")
|
||||
|
||||
is.Error(err)
|
||||
is.ErrorIs(err, ErrNotFound)
|
||||
is.Nil(user)
|
||||
mockDB.AssertExpectations(t)
|
||||
}
|
||||
```
|
||||
|
||||
## Mock Organization
|
||||
|
||||
For larger codebases, organize mocks alongside the code they mock:
|
||||
|
||||
```go
|
||||
// user_service.go
|
||||
type UserService struct {
|
||||
db Database
|
||||
email EmailService
|
||||
}
|
||||
type Database interface {
|
||||
GetUser(id string) (*User, error)
|
||||
CreateUser(user *User) error
|
||||
}
|
||||
type EmailService interface {
|
||||
SendWelcomeEmail(to string) error
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// user_service_test.go
|
||||
package mypackage_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"path/to/mypackage"
|
||||
)
|
||||
|
||||
// MockDatabase implements mypackage.Database
|
||||
type MockDatabase struct {
|
||||
mock.Mock
|
||||
}
|
||||
func (m *MockDatabase) GetUser(id string) (*mypackage.User, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil { return nil, args.Error(1) }
|
||||
return args.Get(0).(*mypackage.User), args.Error(1)
|
||||
}
|
||||
func (m *MockDatabase) CreateUser(user *mypackage.User) error {
|
||||
return m.Called(user).Error(0)
|
||||
}
|
||||
|
||||
// MockEmailService implements mypackage.EmailService
|
||||
type MockEmailService struct {
|
||||
mock.Mock
|
||||
}
|
||||
func (m *MockEmailService) SendWelcomeEmail(to string) error {
|
||||
return m.Called(to).Error(0)
|
||||
}
|
||||
|
||||
func TestUserService_CreateUser(t *testing.T) {
|
||||
mockDB := new(MockDatabase)
|
||||
mockEmail := new(MockEmailService)
|
||||
service := mypackage.NewUserService(mockDB, mockEmail)
|
||||
|
||||
user := &mypackage.User{Name: "Test", Email: "test@example.com"}
|
||||
mockDB.On("CreateUser", user).Return(nil)
|
||||
mockEmail.On("SendWelcomeEmail", "test@example.com").Return(nil)
|
||||
|
||||
err := service.CreateUser(user)
|
||||
|
||||
assert.NoError(t, err)
|
||||
mockDB.AssertExpectations(t)
|
||||
mockEmail.AssertExpectations(t)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Create reusable test data in a separate package or file:
|
||||
|
||||
```go
|
||||
package fixtures
|
||||
|
||||
import "time"
|
||||
|
||||
var (
|
||||
DefaultUser = &User{
|
||||
ID: "user-123",
|
||||
Name: "Jane Doe",
|
||||
Email: "jane@example.com",
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
AdminUser = &User{
|
||||
ID: "admin-1",
|
||||
Name: "Admin User",
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
)
|
||||
|
||||
func NewUser(name, email string) *User {
|
||||
return &User{
|
||||
ID: "user-" + uuid.New().String(),
|
||||
Name: name,
|
||||
Email: email,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Time Mocking
|
||||
|
||||
Use `clockwork` to test time-dependent code without `time.Sleep()`:
|
||||
|
||||
```go
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScheduler_AddJob(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
fakeClock := clockwork.NewFakeClock()
|
||||
scheduler := NewScheduler(fakeClock)
|
||||
|
||||
job := &Job{ID: "1", RunAt: time.Now().Add(1 * time.Hour)}
|
||||
scheduler.AddJob(job)
|
||||
|
||||
is.Equal(1, scheduler.PendingCount())
|
||||
|
||||
// Advance fake time
|
||||
fakeClock.Advance(2 * time.Hour)
|
||||
|
||||
is.Equal(0, scheduler.PendingCount())
|
||||
}
|
||||
```
|
||||
|
||||
Install clockwork:
|
||||
|
||||
```bash
|
||||
go get github.com/jonboulle/clockwork
|
||||
```
|
||||
Reference in New Issue
Block a user