# Testing Aveloxis follows TDD as a discipline: failing test first, then implementation, then verify. This chapter covers what kinds of tests exist, when to use which, and the patterns that have proved durable. ## The three tiers ### Tier 1 — Unit tests Run against pure code, no database, no network. Live in `*_test.go` next to the code. ```bash go test ./... ``` This is what CI runs and what every contributor runs constantly. Should always be green. Should always be fast (the full suite finishes in well under a minute, except for `internal/platform` which has ~40 s of network mock tests). ### Tier 2 — Integration tests Run against a real PostgreSQL via the `AVELOXIS_TEST_DB` env var. Live in `*_integration_test.go` files. ```bash AVELOXIS_TEST_DB="postgres://aveloxis:pw@localhost:5432/aveloxis_scratch?sslmode=prefer" \ go test ./internal/db/ -v ``` Every integration test starts with: ```go func TestSomething(t *testing.T) { dsn := os.Getenv("AVELOXIS_TEST_DB") if dsn == "" { t.Skip("AVELOXIS_TEST_DB not set — skipping integration test") } // ... rest of test } ``` This means `go test ./...` (no env var) skips them cleanly. CI runs both modes — see `.github/workflows/integration.yml` for the Postgres-service-container recipe. **Safety:** integration tests assume a scratch database. Some helpers (`store.RealignDueDates`, the `dm_` aggregate refresh, the `cntrb_id` migration) update every matching row. Pointing them at a production database is destructive. ### Tier 3 — `data-test` harness (cross-version regression detection) The `aveloxis data-test` subcommand collects the same repo into two scratch databases (one with a released-tag binary, one with the local working-tree binary) and row-count-diffs them. Catches schema-regression data loss before it ships. ```bash aveloxis data-test \ --released-tag 0.23.3 \ --repo https://github.com/augurlabs/augur ``` This is documented in [`docs/guide/data-test.md`](../guide/data-test.md). You don't run it during normal feature work — it's the operator-side gate before a release. ## TDD discipline The contract: 1. **Write a failing test.** Run it. See it fail. The failure message should make it obvious what behavior is missing. 2. **Implement the minimum needed to pass.** Don't add features beyond what the test requires. 3. **Verify everything passes.** Run `go test ./...` AND the integration tier if your change touches the DB. 4. **Then refactor if useful.** With tests passing, you can clean up confidently. This isn't optional. It's the rule that prevents bugs like the v0.21.0 backfill that referenced a column name that didn't exist — the source-contract test PASSED because the SQL string contained the wrong column name and the test scanned for that same wrong name. v0.21.1's lesson: source-contract tests verify the code SAYS what you wrote, not that what you wrote is CORRECT against the actual schema. Hence the integration tier. ## The source-contract pattern A test that reads the implementation file and asserts certain text/structure exists. Looks like this: ```go func TestRunMigrationsAddsOnUpdateCascadeToAllCntrbIDFKs(t *testing.T) { src, err := os.ReadFile("migrate.go") if err != nil { t.Fatal(err) } code := string(src) if !strings.Contains(code, "ON UPDATE CASCADE") { t.Error("RunMigrations must add ON UPDATE CASCADE to every " + "cntrb_id child FK. Without this, the v0.22.2 cntrb_id " + "data migration can't propagate UPDATEs to child rows.") } } ``` ### When to use a source-contract test - Pinning a load-bearing design choice that a "helpful" refactor might silently revert. Example: the v0.20.18 negative tripwire that fails CI if `batch_size` ever reappears as a JSON tag (the field was removed; tests prevent it sneaking back). - Verifying the FROM of a query, the WHERE clause, the column list, when behavioral testing would require a complex DB fixture. - Pinning that a function is wired into the right caller (`TestRunMigrationsInvokesEnsureCntrbIDFKIndexes` proves the helper is actually called from RunMigrations). ### When NOT to use a source-contract test - When you can write a behavioral test that actually exercises the code. Behavioral > source-contract every time. - When the "contract" is just gofmt formatting (the v0.22.0 phase-5 test had to be rewritten to be whitespace-tolerant after gofmt re-aligned a struct). ### The v0.21.1 lesson Source-contract tests can give false confidence: > v0.21.0 backfill SQL referenced `aveloxis_scan.scancode_scans.created_at` but the table uses `data_collection_date`. The source-contract test pinned `MAX(created_at)` as a needle in `migrate.go` and passed because both sides of the contract agreed on the wrong answer. Production migrate failed with `ERROR: column "created_at" does not exist (SQLSTATE 42703)`. If you write a source-contract test for SQL that references a column from a different table, **also** write an integration test that runs the migration against a fresh database. The combination catches both refactor-rename drift (source-contract) and column-name typos (integration). ## The behavioral test pattern Tests that exercise actual code through its public API: ```go func TestSalvageHelperBehavioral(t *testing.T) { tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "scan.json") jsonContent := map[string]any{ "headers": []map[string]any{{ "tool_version": "32.5.0", "errors": []string{"Path: foo/bar.pdf"}, "extra_data": map[string]any{"files_count": 38}, }}, } b, _ := json.Marshal(jsonContent) os.WriteFile(outputPath, b, 0o644) filesCount, headerErrors, ok := salvageScancodeOutput(outputPath) if !ok { t.Fatal("expected salvage to succeed for valid JSON") } if filesCount != 38 { t.Errorf("expected filesCount=38, got %d", filesCount) } if len(headerErrors) != 1 { t.Errorf("expected 1 header error, got %d", len(headerErrors)) } } ``` Behavioral tests are preferred when they're cheap. Use source-contract tests only when behavioral testing is genuinely hard. ## The integration test pattern ```go //go:build integration // optional, if you want a build tag func TestUpsertCommitProtectedFromInvalidUTF8(t *testing.T) { dsn := os.Getenv("AVELOXIS_TEST_DB") if dsn == "" { t.Skip("AVELOXIS_TEST_DB not set — skipping integration test") } ctx := context.Background() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) store, err := NewPostgresStore(ctx, dsn, logger) if err != nil { t.Fatalf("connect: %v", err) } defer store.Close() store.SetMatviewSkip(true) if err := RunMigrations(ctx, store, logger); err != nil { t.Fatalf("migrate: %v", err) } // Seed any prerequisite rows (e.g. a parent repo for FK satisfaction). repoID := int64(-191919) if _, err := store.pool.Exec(ctx, ` INSERT INTO aveloxis_data.repos (repo_id, platform_id, repo_git, repo_owner, repo_name, repo_archived) VALUES ($1, 1, 'https://example.invalid/x', 'x', 'x', FALSE) ON CONFLICT (repo_id) DO NOTHING`, repoID); err != nil { t.Fatalf("seed: %v", err) } t.Cleanup(func() { _, _ = store.pool.Exec(context.Background(), `DELETE FROM aveloxis_data.commits WHERE repo_id = $1`, repoID) _, _ = store.pool.Exec(context.Background(), `DELETE FROM aveloxis_data.repos WHERE repo_id = $1`, repoID) }) // The actual test... } ``` Patterns to follow: - **Negative repo IDs** for fixture rows so they can't collide with operator-imported data. - **`t.Cleanup`** to delete fixtures even if the test fails mid-run. - **Pre-cleanup** at the top of tests that share fixture row IDs across runs (the test DB persists between invocations; without pre-cleanup a previous failure leaves rows that break the next run). - **Use `store.SetMatviewSkip(true)`** unless you specifically test matview behavior. Building 22+ matviews on every test run is several seconds wasted. ## What to test ### Always - The success case (the happy path). - At least one edge case — empty input, zero values, NULL, the boundary condition. - Any branch in the code. If `if err != nil { ... } else { ... }`, both branches need coverage. ### Often valuable - Idempotency: re-running the operation shouldn't change state. Especially for migrations + backfills. - Concurrency edge cases: what happens if two collectors hit the same row? - Error-class behavior: does the right error type bubble? - Source-contract pins for invariants that "helpful" refactors might violate. The v0.20.18 dead-config tripwire and v0.22.13's R2-cntrb-login-preservation pin are good models. ### Skip - Trivial getters/setters. - Pure wiring (e.g. struct initialization in main.go) — unless the wiring is load-bearing (e.g. `TestRunMigrationsInvokesUTF8Tracer`). - Standard library behavior (don't test `time.Now()`). ## Naming Test names should describe what's pinned, not just what's tested: ``` TestRunOneAttemptsSalvageOnSubprocessFailure // good — describes the contract TestRunOne // bad — too vague TestSalvageWorks // bad — what does "works" mean ``` The maintainers use long test names. They show up in `go test -v` output and serve as documentation. ## Run patterns ```bash # Everything (unit only, since AVELOXIS_TEST_DB not set) go test ./... # One package, verbose go test ./internal/db/ -v # Single test go test ./internal/collector/ -run TestSalvageHelperBehavioral -v # Pattern match go test ./internal/db/ -run "TestUTF8Tracer.*" -v # Race detector (run periodically; slows tests ~2x) go test ./... -race # Force re-run (skip caching) go test ./... -count=1 # Integration tier AVELOXIS_TEST_DB="postgres://..." go test ./internal/db/ -v -timeout 120s ``` ## What good test failures look like When a test fails, the failure message should: 1. Say what was expected vs what was observed. 2. Explain WHY this matters (the load-bearing invariant being violated). 3. Point at the relevant CLAUDE.md section or production incident if applicable. ```go if !strings.Contains(code, "ON UPDATE CASCADE") { t.Error("RunMigrations must add ON UPDATE CASCADE to every " + "cntrb_id child FK. Without this, the v0.22.2 cntrb_id " + "data migration can't propagate UPDATEs to child rows. " + "See CLAUDE.md `Changes in v0.22.1`.") } ``` A future contributor seeing this fail will understand both what to fix and why. Compare to a bare `t.Error("missing CASCADE")` which leaves them spelunking. ## Writing tests for code that depends on time Use `time.Now()` directly in production code; in tests, either: - Use real durations and accept ~10 ms tolerance: `if elapsed > 100*time.Millisecond { ... }`. - Inject a clock function: `clock func() time.Time` field on the struct. Aveloxis tends to use the first pattern — most time-sensitive code is at second / minute granularity, so millisecond drift in tests doesn't matter. ## Writing tests for goroutines If your test spawns goroutines, use `sync.WaitGroup` or buffered channels to coordinate. Never use `time.Sleep` to wait for a goroutine — it's flaky. For watchdog / ticker-style code, accept an injectable ticker duration: ```go type LongJobsWatchdog struct { interval time.Duration // ... } // In tests: use 10ms; in production: 30s. ``` ## Writing tests for HTTP clients `httptest.NewServer` for full mock servers. For finer control, implement `http.RoundTripper` and inject it. ```go type mockTransport struct { handler http.HandlerFunc } func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { rec := httptest.NewRecorder() m.handler(rec, req) return rec.Result(), nil } ``` See `internal/platform/httpclient_test.go` for patterns. Tests like `TestHTTPClientTreats500AsTransient` use httptest to verify retry behavior without hitting GitHub. ## CI GitHub Actions runs: - `test.yml`: `go test ./...` on every push. - `integration.yml`: PostgreSQL service container + `AVELOXIS_TEST_DB` integration tests. Both must pass for a PR to merge. The maintainers can override but rarely do.