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.

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.

AVELOXIS_TEST_DB="postgres://aveloxis:pw@localhost:5432/aveloxis_scratch?sslmode=prefer" \
    go test ./internal/db/ -v

Every integration test starts with:

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.

aveloxis data-test \
  --released-tag 0.23.3 \
  --repo https://github.com/augurlabs/augur

This is documented in docs/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:

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:

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: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

# 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.

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:

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.

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.