The project has two tiers of tests:
- Unit tests (171, no external dependencies): pure-function tests for the analyzer, generator, LLM providers, LLM retries, PDF report, exporters, cloud mapping, real-provider fetchers, and central config validation. Run with
go test ./internal/.... - Integration tests (12, require Docker): exercise the real Postgres path via testcontainers-go — insert/upsert behavior, transaction rollback, snapshot aggregation, and a full end-to-end seed → analyze flow against a containerized Postgres 16. Run with
go test -tags=integration ./internal/db/ ./internal/e2e/.
Integration tests share a single Postgres container per process and TRUNCATE … RESTART IDENTITY CASCADE between cases — fast (sub-millisecond reset on small tables) and hermetic enough for our schema. The helper lives at internal/db/dbtest/postgres.go and is gated by the integration build tag, so the testcontainers dependency stays out of the unit-test compile path. If Docker isn't running, the helper calls t.Skip with a clear message rather than failing — running the binary without Docker just skips the integration cases.
The CI workflow at .github/workflows/test.yml runs both tiers on every push and PR. GitHub-hosted Ubuntu runners have Docker preinstalled, so the integration job needs no extra service container.
- Per-rule tests: each detection rule (
ec2-idle,rds-oversized,ebs-orphan,lambda-over-provisioned) has happy-path, negative, and boundary tests. - Boundary testing: CPU thresholds, age cutoffs, memory limits, and invocation counts are explicitly tested at their exact values to catch off-by-one errors.
- Aggregator tests:
Analyzeis tested for empty input, mixed input, false-positive prevention, and correct savings-descending ordering. - LLM provider tests: all three providers (Gemini, Claude, OpenAI) are tested against mock HTTP servers using
httptest, covering success responses, API errors, empty payloads, error fields, and context cancellation. - Provider factory tests: auto-detection order (Gemini > Claude > OpenAI), explicit selection, missing keys, and unknown providers.
- Prompt builder tests: total calculations, severity breakdowns, service rollups, top-5 limiting, and empty input handling.
- PDF generation tests: file creation, AI summary inclusion/exclusion, empty findings, 100-finding page-break stress test, invalid paths, and all severity color codes.
- Export tests: JSON round-trip, CSV header + row layout, numeric formatting, RFC 4180 escaping of commas/quotes/newlines, and empty-findings handling for both formats.
- Generator tests: correct count, valid services/regions/types, non-negative costs, timestamp ordering, and service distribution.
- Config tests: default values, custom values, timeout parsing (valid and invalid durations), empty-env fallback, and DSN assembly.
- Cloud mapping tests: AWS SDK type →
shared.Resourceconversion with struct literals (no AWS calls, no credentials needed). - Real-provider fetcher tests: every cloud provider (AWS, GCP, Azure) is exercised end-to-end against fake SDK clients — pagination exhaustion, per-service API errors, graceful degradation when one service fails, and edge cases (nil hardware profile on Azure VMs, nil settings on Cloud SQL, web apps mixed with function apps in the Azure
/sitescollection). - LLM retry tests: the shared retry transport is verified against
httptestservers — retries until success, respectsMaxRetriescap, honorsRetry-Afterheaders, replays the request body on every attempt, retries transport-level errors (not just non-2xx), bails out on context cancellation, and returns immediately on non-retryable statuses (401, 4xx other than 408/429). - Config validation tests: every invalid input shape (non-numeric port, out-of-range port, unknown enum value, negative integer, malformed Go duration, zero/negative duration), every cross-field rule (provider=gcp without project, provider=azure without subscription, LLM_PROVIDER set without matching API key), and the multi-error accumulator that lists all problems at once instead of failing on the first.
- Insert + upsert: round-trip through a real Postgres, asserting that
ON CONFLICT DO UPDATEupdates the right columns (monthly_cost,usage_metric,updated_at) without overwritingcreated_at. - Transaction rollback: a failing batch (one row that overflows
NUMERIC(10,2)) rolls back the whole batch, leaving pre-existing rows untouched. - Snapshot aggregation: a mixed set of resources across multiple
(account, service)tuples produces exactly the expected snapshot rows, with correct counts and per-tuple cost totals. - Snapshot windowing: the
--daysfilter on thetrendcommand actually filters via SQL — old snapshots are excluded from short windows and included in long ones. - End-to-end seed → analyze: a deterministic resource set engineered to fire each rule once, inserted via
InsertResources, read back viaListResources, and analyzed — asserts every rule fires exactly once and findings are sorted by potential savings descending. - End-to-end with synthetic data: 50 random resources generated by
SyntheticProvider, full round-trip through the DB, analyzer must produce some findings (the generator skews toward waste patterns). - Re-seed idempotency: running insert three times on the same fixed-ID set ends with the same row count — proves the seed flow is safe to re-run on a schedule.
# Unit tests (no Docker required)
go test ./internal/...
# Integration tests (Docker must be running)
go test -tags=integration ./internal/db/ ./internal/e2e/
# Both, verbose
go test -tags=integration -v ./internal/...All rules are pure functions (Resource -> *Finding), which makes them trivially testable without mocks, fixtures, or test databases. The code was designed to be testable from the start — not tested after the fact.