| name | test-go |
| description | Write, review, and improve Go test code for this project. Use whenever generating, reviewing, or modifying Go tests - including when invoked by the Tester agent, the /test prompt, or any test-related request. Covers table-driven tests, subtests, t.Parallel(), test helpers with t.Helper(), error assertions via errors.As/errors.Is, fixture loading from testdata/, httptest servers, env-gated integration tests, mock/fake/spy patterns, and adapter conformance. Do NOT use for benchmarks or performance profiling. |
| metadata | {"author":"Serghei Iakovlev","version":"1.0","category":"testing"} |
Go Testing
Placeholders
Examples below use two placeholders. They are not literal - substitute the actual names from the project being worked on, matching the case of the surrounding context:
{PROJECT} - the project's identifier (e.g. env-var prefix, repo name). In env vars: MYAPP_…. In prose: MyApp or myapp.
{integration} - an external system or adapter name (e.g. jira, stripe, claude). In paths: internal/tracker/jira/. In env vars: JIRA_…. In type names: JiraAdapter.
.go files in assets/ use concrete-looking sample names (Adapter, MYAPP_INTEGRATION_TEST, etc.) so the templates stay syntactically valid Go; comments inside each template tell you what to rename.
Decision Framework
Before writing any test, determine which category it belongs to:
| Category | Characteristics | Run condition |
|---|
| Unit | Deterministic, no I/O, no network | Always (make test) |
| Unit with fixtures | Reads testdata/ files, uses t.TempDir() | Always |
| Unit with httptest | Spins up httptest.NewServer, tests HTTP adapters | Always |
| Integration | Talks to real external service | Env-gated by per-adapter variable: {PROJECT}_{INTEGRATION}_TEST=1 |
Pick the lightest category that validates the behavior.
Canonical Test Structure
Every test file in this project follows this skeleton. Internalize it - do not deviate.
package pkg
import (
"testing"
)
func helperName(t *testing.T, args ...) ReturnType {
t.Helper()
}
func TestFunctionName(t *testing.T) {
t.Parallel()
}
Key rules this project enforces:
t.Helper() is the first statement in every helper - no exceptions.
t.Cleanup() for teardown in helpers; defer only in test functions themselves.
t.Parallel() at both test and subtest level for independent cases.
t.TempDir() for filesystem isolation - never write to fixed paths.
t.Setenv() for environment variable isolation in tests.
- Errors use
errors.As() / errors.Is() - never string comparison.
Table-Driven Tests
Use when multiple cases share identical execution logic. This is the dominant pattern in this project.
func TestSanitizeKey(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr bool
}{
{"simple key", "ABC-123", "ABC-123", false},
{"empty input", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := SanitizeKey(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("SanitizeKey(%q) = %q, want error", tt.input, got)
}
return
}
if err != nil {
t.Fatalf("SanitizeKey(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("SanitizeKey(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
When NOT to use tables: cases needing different setup, conditional mocking, or complex branching. Write separate t.Run blocks or separate test functions instead.
Table struct conventions:
- Always include
name string as the first field
- Use
wantErr bool for error presence; add wantKind field for typed error checking
- Use field names (not positional) when structs have more than 3 fields
- Include inputs in failure messages:
FuncName(%v) = %v, want %v
Error Testing
This project uses custom typed errors extensively. Test error semantics, never strings.
func assertTrackerErrorKind(t *testing.T, err error, want domain.TrackerErrorKind) {
t.Helper()
if err == nil {
t.Fatalf("expected error with kind %q, got nil", want)
}
var te *domain.TrackerError
if !errors.As(err, &te) {
t.Fatalf("error type = %T, want *domain.TrackerError", err)
}
if te.Kind != want {
t.Errorf("TrackerError.Kind = %q, want %q", te.Kind, want)
}
}
Error testing rules:
errors.As() for type assertion - validates the error chain, not just the top
errors.Is() for sentinel comparison
- Test the
Kind/Field/Op of typed errors, not .Error() strings
t.Fatal when nil-error means subsequent assertions will panic; t.Error otherwise
Test Helpers
Helpers belong at the top of the test file, before test functions. Each adapter package defines its own helpers - do not create a shared testutil package.
Common helper patterns in this project:
func mustAdapter(t *testing.T, config map[string]any) *Adapter {
t.Helper()
a, err := NewAdapter(config)
if err != nil {
t.Fatalf("NewAdapter: %v", err)
}
return a.(*Adapter)
}
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("reading fixture %s: %v", name, err)
}
return data
}
func validConfig(endpoint string) map[string]any {
return map[string]any{
"endpoint": endpoint,
"api_key": "user@test.com:api_token_123",
"project": "PROJ",
}
}
func closeStore(t *testing.T, s *Store) {
t.Helper()
if err := s.Close(); err != nil {
t.Errorf("Close: %v", err)
}
}
Naming conventions:
mustX - creates X or fatals (setup that cannot fail gracefully)
validX / defaultX - returns baseline config/params for test customization
loadFixture - reads from testdata/
assertX / requireX - assertion helpers (require fatals, assert errors)
HTTP Adapter Testing
Adapter tests use httptest.NewServer with handler functions that return fixture data. Never mock the http.Client itself.
func TestFetchIssues(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got == "" {
t.Error("missing Authorization header")
}
w.Header().Set("Content-Type", "application/json")
w.Write(loadFixture(t, "search_single_page.json"))
}))
defer srv.Close()
adapter := mustAdapter(t, validConfig(srv.URL))
issues, err := adapter.FetchIssuesByStates(context.Background(), []string{"To Do"})
if err != nil {
t.Fatalf("FetchIssuesByStates: %v", err)
}
}
Rules for httptest usage:
- Verify request headers, query params, and method inside the handler
- Return fixture JSON from
testdata/ - do not inline large JSON strings
- Assert on domain-level objects after adapter normalization, not raw payloads
- Use
atomic counters when verifying call counts across concurrent requests
Integration Tests (Env-Gated)
Integration tests talk to real external services. They MUST be gated by environment variables and skip cleanly when disabled.
Read references/integration-tests.md for the full integration testing protocol including skip helpers, required env vars, and CI configuration.
Quick reference:
func skipUnlessIntegration(t *testing.T) {
t.Helper()
if os.Getenv("MYAPP_INTEGRATION_TEST") != "1" {
t.Skip("skipping integration test: set MYAPP_INTEGRATION_TEST=1 to enable")
}
}
- File naming:
integration_test.go (separate from unit tests)
- Skip with
t.Skip, not silent pass - skipped tests are visible in output
- Use isolated test data; clean up artifacts when practical
- Never fail CI when env var is absent
Adapter Conformance Testing
Every adapter (tracker or agent) must prove it satisfies the domain interface. Use compile-time interface checks and conformance test suites.
var _ domain.TrackerAdapter = (*Adapter)(nil)
var _ domain.AgentAdapter = (*mockAgentAdapter)(nil)
What conformance tests must cover (per architecture Section 17):
- Normalized field mapping (issue state, priority, labels → domain types)
- Pagination handling (order preserved across pages)
- Error category mapping (transport, auth, API, payload → typed errors)
- Config validation (required fields, defaults, invalid combinations)
Mock and Test Double Patterns
This project uses three kinds of test doubles - pick the lightest one that works.
| Double | Purpose | Example |
|---|
| Stub | Returns fixed data | validConfig() returning a map |
| Fake | Simplified working implementation | internal/agent/mock package, internal/tracker/file adapter |
| Spy | Records interactions for later assertion | httptest handler with atomic counters |
Registered fakes (when this project provides them, e.g. internal/agent/mock/, internal/tracker/file/) are first-class adapters in the registry. Use them for orchestrator-level tests that need controllable adapter behavior - check the project layout for the actual package paths.
Mock struct pattern:
type mockTrackerAdapter struct{}
var _ domain.TrackerAdapter = (*mockTrackerAdapter)(nil)
func (m *mockTrackerAdapter) FetchIssuesByStates(ctx context.Context, states []string) ([]domain.Issue, error) {
return nil, nil
}
Fixture Management
Store test data in testdata/ within the package directory. Go tooling ignores this directory during builds.
internal/tracker/{integration}/testdata/
search_single_page.json
search_multi_page_1.json
search_multi_page_2.json
issue_detail.json
comments.json
Rules:
- One
testdata/ directory per package that needs fixtures
- Name fixtures descriptively:
search_empty.json, malformed.json, comments_multi_page_1.json
- Load via
loadFixture(t, name) helper - never hardcode paths in test functions
- JSON fixtures should be real (or realistic) API responses, not minimal stubs
Failure Message Format
Every assertion must produce a message diagnosable without reading the test source.
Format: FuncName(inputs) = got, want expected
t.Errorf("SanitizeKey(%q) = %q, want %q", tt.input, got, tt.want)
t.Errorf("TrackerError.Kind = %q, want %q", te.Kind, want)
t.Errorf("got %q, want %q", got, tt.want)
t.Error("wrong result")
- Always
got before want in message ordering
- Use
%q for strings (shows quotes and escapes), %v for general values
- Use
%d for integers, %f for floats - match the type
Validation Checklist
After writing or modifying tests, verify: