Testing (Canopy)

On this page
NOTE

The universal testing strategy — testing philosophy, the cargo-nextest runner basics, the Playwright E2E mandate, the evil-input corpus, mutation testing, property-based-testing basics, and the test-results/ layout — lives in the synced standard at Testing (Standard). Do not duplicate it here.

The canopy-test-lib API surface referenced throughout this page is also summarized in Shared Crates Reference.

This page is Canopy’s project-specific testing guide: the patterns, primitives, and gates that are particular to this codebase and not part of the universal standard.

Running the suite

cargo xtask test is the entry point; the --unit / --integration split scopes what runs:

cargo xtask test                # All — fmt + clippy + nextest (unit + integration)
cargo xtask test --unit         # Pure-unit lane, no devstack required
cargo xtask test --integration  # The infra lane, needs a running devstack

The lane partition (#1377)

Every lane selects on ONE classification: a lib/bin test that needs live infrastructure (EphemeralSchema, infrastructure_available(), PG/AMQP/devstack HTTP) lives in a module whose path contains infra_tests; everything else is pure. The filterset constants live in xtask/src/lanes.rs; the blocking cargo xtask test-lanes-lint enforces the classification statically (a syn walk over lib/bin sources) and --verify-partition set-verifies the split against cargo nextest list in every validate battery (unit ⊎ integration == the full workspace list, disjoint — plus every serialized-group filterset still matching, so a test-fn rename can never silently unserialize a group).

Lane Selection Infra posture

cargo xtask test --unit

--lib --bins -E '!INFRA'

none needed; CANOPY_TEST_INFRA=required as a classification tripwire — an unclassified infra test panics loudly instead of vacuously passing

cargo xtask test --integration (container, default)

ENTRYPOINT -E 'kind(test) | INFRA' — all tests/ targets + the classified infra lib/bin set; the container no longer reruns the workspace

devstack, required

validate pure-unit arm

-E '!kind(test) & !INFRA', 16 threads

genuinely infra-free

validate infra arm

-E 'kind(test) | INFRA', 4 threads

the one complete infra lane per battery

CI cargo-test

--lib --bins -E '!INFRA'

infra-less runner + required = the standing dynamic backstop for classification misses

The MR-CI gap is explicit and deliberate: no merge-request CI job runs the infra set against live services (repo policy — the pre-push validate battery is the sole functional-correctness gate; CI carries security/supply-chain/ release plus the static lane checks). Before #1377 the same tests silently SKIPPED in MR CI while the --workspace container duplicated every unit test on main; now the boundary is visible instead of vacuous.

The E2E battery and the journey-lane gate (#1386)

A bare, unfiltered cargo xtask e2e — exactly what the pre-push hook runs — IS the blocking E2E battery, and it carries two standing devstack requirements: the full compose profile (every program service; the default profile since #1386) and the test-clock build (CANOPY_CARGO_FEATURES=canopy-api/test-clock cargo xtask dev reload, sticky). The multi-life-event journey project is a blocking battery lane; it runs strictly LAST via Playwright project dependencies, because its specs advance/reset the fleet’s logical clocks and would contaminate concurrently-running date-sensitive specs.

The lane is fail-loud: after a green Playwright exit the battery verifies that test-results/e2e/results.xml was written by this run, that the journey project executed at least one test with zero skips (every journey skip-guard marks an unhealthy battery state — non-clock build, missing profile, missing seed), and that at least one walkthrough artifact was written during the run (the #1409 phantom-pass corroboration). The test-clock probe runs before the suite so a production-shaped stack fails in seconds. Targeted runs are exempt from the battery gate — any trailing Playwright filter or --devstack-profile snap-only marks the run as targeted — but still carry the #1409 reality watchdog: whenever a run’s JUnit report claims executed journey tests, at least one walkthrough artifact must have been written during the run (every journey spec writes them, 14/14), or the run fails loudly. This is the answer to the documented runner-level false-green, where Playwright emitted per-test ✓ lines and a green summary for journey tests that never ran their bodies:

# The blocking battery (what pre-push runs) — full profile + test-clock required:
cargo xtask e2e

# One journey spec, skipping the dependency lanes:
cargo xtask e2e --no-refresh -- specs/journey-snap-lifecycle.spec.ts --project journey --no-deps

# Screenshot/capture sweeps are OPT-IN and never run in the battery:
cargo xtask e2e --no-refresh --capture -- --project demo-review
cargo xtask docs screenshots     # passes --capture itself

The demo-review capture sweep’s 20 case-detail section-readiness assertions stay in the default battery as the ordinary functional suite tests/e2e/specs/case-detail-sections.spec.ts (light scheme, caseworker lane, exactly once); dark-scheme correctness coverage lives in the accessibility project’s per-target dark entries.

Runaway-test kill bound (#1338)

Every nextest profile carries slow-timeout = { period, terminate-after } — a genuinely hung test is killed and reported failed instead of blocking a battery forever (a starved-pool hang once ran 15 hours inside a pre-push battery with only SLOW warnings). Bounds, audited against the slowest legitimate tests in the 2026-08-26 battery (integration max 13.3s, unit max 11.2s):

  • infra lanes (integration, validate, ci-integration, coverage-integration): warn at 120s, kill at 240s (18× headroom).

  • unit lanes (default, validate-unit, ci, coverage): warn at 60s, kill at 180s (16× headroom).

Manual proof (2026-08-26): a deliberately-hung loop {{ sleep }} test under the default profile was terminated at 180.0s, reported TIMEOUT + run failed. A test that legitimately needs more than the kill bound is a design smell — split it or give it an explicit per-test override with a written rationale, never a global loosening.

Resource-pressure signal for flake triage (#653)

cargo xtask {validate,test,e2e} wrap their test phases (nextest, doctest, Playwright) in a best-effort 1 Hz SysMonitor (xtask::sysmon, Linux /proc). When a phase ends it prints a one-shot summary line next to any FAIL lines:

[sysmon:validate:nextest] cores=24 window=…T…Z→…T…Z (137.2s) samples=137
  cpu-busy mean=43.1% peak=98.7%@…  | loadavg-1m 4.2→18.9 | psi-some-avg10 peak cpu=12.3 io=4.5 mem=0.0 | mem-avail 30144→18002MiB (min 15233) | swap-peak 0MiB

Every sample also streams (timestamped, RFC3339-ms) to test-results/resource-samples.jsonl for post-hoc correlation against the nextest JUnit/timings. The signal is purely diagnostic — low load during a failure is evidence of a real bug; pegged CPU / IO-PSI spikes during a cluster of timeouts point at a perf/timeout flake. It never changes test behaviour, and is an inert no-op off Linux.

The validate report (test-results/validate-report.json, #1253)

cargo xtask validate (the pre-push battery core) is fail-fast: its gate stages run before the tests, so an earlier gate failure exits nonzero while test-results/integration/results.xml still holds the previous run’s green JUnit — "push failed, test-results green", forcing a terminal log-grep. Validate therefore emits one always-present, atomically-written, self-describing report at test-results/validate-report.json. After git push, read that one file to know exactly which stage failed and why — no log scraping.

Scope is honest — validate only. This is not a whole-git push report: auth (.env.local), commit signature, the hook-level gates (cargo deny / check-docs / cargo doc / perf / LFS), and xtask-compile run outside cargo xtask validate (a hook-owned whole-pre-push manifest is tracked in #1254). It does not prove remote delivery — git ls-remote remains the only truth that a push landed.

Shape. schema_version, a UUID-v7 run_id, runner (host \| in-network), best-effort git provenance (commit_sha / branch / worktree_dirty), started_at / ended_at, a state, and the full predeclared stages inventory (seeded not_run before the first gate, so the report is a complete manifest even if the run dies early), plus an embedded test_report (the nextest JUnit).

  • staterunning \| pass \| fail \| interrupted. A running left on disk means the process was killed by a signal (no cleanup ran); a graceful unwind / early return marks interrupted via a Drop guard; only a clean finish finalizes pass / fail. fail names the failing blocking stage in failed_stage.

  • Each stage records execution (pass / fail / skip / not_run / running), a policy (blocking \| advisory), duration_ms, and — on failure — a redacted error_chain plus, for the subprocess gates (capture: full-tee), a diagnostics block with bounded (≤64 KiB), redacted stdout/stderr tails. An advisory failure (e.g. cargo deny) is recorded with full diagnostics but does not fail the battery: a pass run may legitimately carry an advisory fail.

  • In-process gates (capture: error-chain-only) carry only the anyhow error chain — a follow-up (#1255) routes their subprocess diagnostics too.

  • Test failures are embedded per-test: test_report.failed_tests[] carries each failed test’s redacted, bounded stdout/stderr, so the report replaces the log for a test failure (the panic / 40P01 body lives there).

Single-run JUnit ownership. Validate’s nextest stage is split (#1366) into a nextest-build stage (cargo nextest run --no-run, #1371 — on a code-change push the compile dominates and was previously misattributed to test time; the run arms now report ~pure execution), then a pure-unit arm — [profile.validate-unit], -E '!kind(test) & !INFRA' (#1377) at high parallelism, writing test-results/validate/unit-results.xml — and an infra arm — [profile.validate], -E 'kind(test) | INFRA', at the devstack-shared thread cap, writing test-results/validate/results.xml. The two filters partition the default target set exactly (set-verified by the lane-partition gate every battery), so no test falls between the arms; the serialized test-groups replicate into EVERY profile (repo invariant — the groups, not thread caps, protect those tests wherever they land). Both files live inside test-results/validate/, which validate owns exclusively — it never collides with cargo xtask test --integration’s `test-results/integration/results.xml, both are deleted at bootstrap (an interrupted battery can never leave a stale-green artifact), and a workspace-relative fs2 lock (test-results/.validate.lock) serializes concurrent runs. A blocking test-report stage owns the reporting verdict over BOTH embeds: any nextest arm exit-0 but a missing/malformed/unreadable JUnit ⟹ state: fail. All free-form fields redact injected secret values (the decrypted dev secrets) before truncation; the report is written 0644 (redaction, not the inode, keeps it non-secret) so it is host-readable across the validate-in-network bind mount.

Canopy test-writing idioms

These conventions keep tests deterministic and reviewable; they have no universal equivalent.

  • Decimal in test JSON: use string values ("1200.00"), never floats — rust_decimal serializes via the workspace-wide serde-str feature, so a JSON number will fail to deserialize.

  • Test date construction: NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid test date") — never unwrap an Option<NaiveDate> silently.

  • Test naming: snake_case describing the scenario / property asserted, not the endpoint called — e.g. three_non_qualifying_months_exhausted. Keeps a failing test’s intent visible at a glance.

The boundary-auth conformance matrix (OIDC F4, #1422)

canopy_test_lib::conformance is the route-manifest-driven harness from the OIDC program plan §G (program plan), landed BEFORE the first receiver flip. manifest() is the machine-readable projection of the F1a authorization inventory — every entry cites its guard (file:line) — and rows() crosses it with the §G matrix dimensions (RowKind: token integrity, worker/service arms, raw-broad-audience, the exchange shapes, portal lateral/cross-owner, cache separation, IdP failure — the mixed-version kind was retired by S6 #1445 once the fleet went post-C1 uniform). The driver (tests/conformance_matrix_test.rs) runs every RUNNABLE row against the live devstack and asserts AUTH CLASSIFICATION ONLY (401 / 403 / passed-auth — 400s and 404s count as passed-auth, so rows never break on payload churn); rows whose prerequisites don’t exist yet are generated as Pending with a named activation phase and counted, never silently absent.

Two operational rules for slice authors:

  • Flipping a service: add its slug to the Activation set in your slice MR — the expectation table switches that service’s rows to the target receiver contract (§C), and the exchange-shaped rows become a LOUD gap until you implement them. (Post-S6 #1445 every routed service is either flipped or a documented never-flips leaf — NEVER_FLIPS carries canopy-rules, whose exchange rows are honest permanent pendings; canopy-exchange has no routes, pinned vacuous by its own routes_is_an_empty_router test. The remaining permanent pendings are broker-side properties (excessive scope/lifetime, cache separation — asserted in canopy-auth units), the IdP-failure lane, and the deliberately indistinguishable portal post-load compares.)

  • Extractor-valid probes: axum runs Json<T>/Query<T> extractors BEFORE in-handler authz, so a probe with an invalid body/query gets 422/400 without the guard ever running. Manifest entries carry body/query templates for exactly this; keep them minimal but parseable.

Deterministic seed-based test data — the canopy-seed harness (#450)

Canopy’s test-seed pipeline implements the universal deterministic-seed principle (same seed ⇒ same entities/UUIDs/relationships; random-by-default with the resolved seed captured for replay) with three architectural commitments:

  1. Single source of truth. Both cargo xtask seed and cargo xtask e2e route through the same code path (xtask::cmd::seed::run). Pre-#450 they regenerated the manifest independently with different default household counts (9 vs 50), causing the tests/e2e/lib/seed.ts manifest to point at UUIDs that did not exist in the DB. Now xtask e2e reads test-results/seed/last.txt and only re-seeds when env-supplied CANOPY_SEED / CANOPY_HOUSEHOLDS differ from the captured values.

  2. Random by default + captured for replay. No --seed flag ⇒ rand::random::<u64>(). The resolved seed lands at the top of seed.ts as export const SEED_VALUE: number = N; AND in test-results/seed/last.txt (gitignored). The completion notice echoes Seed: N (replay with --seed N). for cut-and-paste.

  3. Predicate-fixture API decoupled from the auto-generated manifest. Specs import from tests/e2e/lib/fixtures.ts (hand-written, checked-in), not tests/e2e/lib/seed.ts (auto-generated, gitignored). The fixtures file is the choke point — adding a new predicate or migrating to live-DB queries means editing one file; no spec touches the underlying manifest shape.

Replay flow

# Default — random seed, captured to last.txt:
cargo xtask seed
# stderr: canopy-seed: using random seed 17234982347
# stderr: canopy-seed: done. Seed: 17234982347 (replay with --seed 17234982347 ...)

# E2E run picks up the captured seed automatically:
cargo xtask e2e --no-refresh
# stdout: Using captured seed (seed=17234982347, households=50). Skipping re-seed.

# A spec fails — replay the exact failure:
cargo xtask seed --seed 17234982347 --households 50
cargo xtask e2e --no-refresh -- --grep 'CAPS'

Adding a new fixture predicate

  1. Edit tests/e2e/lib/fixtures.ts — add a new exported function (e.g. findApprovedMedicaidAdult).

  2. Back it with the existing seed data OR (future work) a live API query via Page.request.

  3. Specs import from ../lib/fixtures; never from ../lib/seed.

Scanner-path tests (ADR-042, #1006)

CANOPY_TEST__CLAMD_ADDR (host lane: 127.0.0.1:<port> from .ports.env; in-network: clamav:3310) reaches the devstack clamd sidecar. Real-clamd rows (EICAR — committed split so the checkout never carries the contiguous string — plus the baked devstack/clamav/test.ndb marker Canopy.Test.Upload, detectable through the REAL upload endpoint since raw EICAR cannot pass magic-byte validation) probe-and-skip locally and are REQUIRED in the in-network lane (CANOPY_TEST_INFRA=required). Deterministic protocol/verdict coverage lives against an in-process fake clamd that VALIDATES INSTREAM wire framing (crates/canopy-scanner-clamd/tests/); worker-lifecycle coverage drives scan_worker::drain_once with scripted scanners over an ephemeral schema + local object store (services/canopy-applications/tests/scan_quarantine_test.rs — the module doc maps which leg proves what). Live-service flips only ever land SETTLED states, so the real worker (which claims only pending) can never race an assertion.

Contracts crates + proptest round-trips

Per the canopy-test-lib port plan (canopy-test-lib Port Plan), every JSON-over-HTTP operation family lives in a dedicated crates/canopy-contracts-{service}/ crate. Each crate:

  • Holds pure DTOs (no axum, no sqlx) so test clients, downstream services, and external consumers share one definition.

  • Carries Serialize + Deserialize on every Request and Response (symmetric — Request types are not Deserialize-only).

  • Adds PartialEq when proptest round-trip tests rely on it.

  • Re-exports a paths module with the FULL post-mount path constants (e.g. pub const DETERMINE: &str = "/v1/eligibility/determine"); service routers strip /v1 at boot so the const is the single source of truth.

  • Ships a tests/roundtrip.rs with proptest proptest! { …​ } blocks that serialize → deserialize → prop_assert_eq! on every DTO. Arbitrary generators bound floats and dates to ranges that round-trip exactly; serde_json::Value generators bound depth so proptest can shrink failures in finite time.

Phase A1 (canopy-eligibility pilot) shipped 2026-05-14; phases A2–A4 extend the pattern across the remaining 15 services.

NOTE
The property-based-testing rationale (why proptest over example-based tests, the mandatory-for categories) is universal — see the standard. This section documents only Canopy’s concrete contracts-crate layout.

Coverage gate

cargo xtask coverage gates unit-lane line coverage (#1382): cargo llvm-cov nextest --workspace --lib --bins filtered to the !infra_tests lane (xtask/src/lanes.rs), run with CANOPY_TEST_INFRA=required so a stray infra-backed test fails loudly instead of skip-passing on an infra-less runner. The pre-#1382 gate ran plain cargo llvm-cov --workspace with no infrastructure and called the result workspace coverage — every DB-backed test skipped vacuously, so the number defended less than it claimed. The floor now names what it measures.

The threshold lives in xtask/src/cmd/coverage.rs::DEFAULT_THRESHOLD; the measurement it derives from is committed as coverage-baseline.toml at the workspace root (scope, line totals, tool + toolchain versions, date). Both refresh together: run cargo xtask coverage --baseline in the CI image (rust:1.96-alpine, tool versions pinned in the coverage: CI job) — it writes the raw llvm JSON to .coverage-baseline.json (gitignored) and the normalized TOML (committed). Host measurements drift; never set the floor from one.

cargo xtask coverage --integration measures the integration lane (kind(test) | infra_tests, coverage-integration nextest profile) with the full battery bootstrap: battery locks → ensure_readyrequired → run id → #1379 schema sweep afterwards. It is an informational developer command — no floor, no CI invocation. An integration-coverage gate would rot silently (nobody reruns it on infra changes); if you need the number, run it against a live devstack and read it in context.

Locally, install the tooling once:

rustup component add llvm-tools-preview
cargo install cargo-llvm-cov cargo-nextest

Multi-Replica Fixture + Ephemeral Schema (Phase D of #436)

Two complementary primitives in canopy-test-lib for chaos / invariant tests that need to exercise production assumptions across N replicas without contaminating the host devstack. These are the keystone anti-flake patterns.

MultiReplicaFixture (crates/canopy-test-lib/src/multi_replica.rs)

MultiReplicaFixture::spawn(service, n).await brings up N independent processes of a service binary:

let fx = MultiReplicaFixture::spawn("canopy-eligibility", 3).await?;
let url0 = fx.base_url_for(0);
let client = EligibilityClient::new(&url0);
// ...drive workload, optionally fx.kill_replica(1).await; fx.restart_replica(1).await?;
// fx is Drop-aware: every replica process is killed on scope exit.

Each replica gets a free 127.0.0.1 port + a unique CANOPY_MQ_REPLICA_ID + the shared CANOPY_MQ_QUEUE_PREFIX injected as env. Caller must pre-build the binary via cargo build --bin <service> (or just cargo build) before the fixture runs — spawn does NOT build. Honors CARGO_TARGET_DIR if set (CI runners typically set it).

EphemeralSchema (crates/canopy-test-lib/src/db.rs)

EphemeralSchema::new_for_<service>(base_url).await creates a randomly-named Postgres schema (test_<16-hex-of-UUIDv4>), opens a pool scoped via ?options=-c search_path=<schema>, runtime-loads and runs the service’s migrations, and returns:

let cfg = TestConfig::from_env();
let schema = EphemeralSchema::new_for_eligibility(&cfg.eligibility_db_url).await?;
let pool = schema.pool();
// ...run test against pool...
schema.cleanup().await.expect("schema cleanup");  // synchronous DROP — Phase E preferred

Migrations are runtime-loaded (#1380): run_service_migrations resolves services/canopy-<service>/migrations from the workspace tree and feeds sqlx::migrate::Migrator::new, so a migration edit is picked up by the next test run with no rebuild — the former compile-time sqlx::migrate! embed went stale until someone re-touched db.rs (the retired #1242 ritual: a false-green window running old SQL). A missing dir errors loudly with the resolved path. The define_ephemeral_schema_for!(service) macro generates the 17 one-line constructors in db_constructors.rs (canopy-verification included since #1380 — the old "no migrations directory" claim had been false since 2026-05-16).

Infra gating (#1376): infrastructure_available() AND-probes postgres and the rules canary (a partial stack is not available), and CANOPY_TEST_INFRA=required — set by validate, cargo xtask test’s infra lanes, and the in-network container — turns an unavailable stack into an immediate loud panic instead of a skip: battery lanes can never false-green DB-backed tests. Bare local runs stay skip-friendly. DEPENDENCY-level skips (#1396) — a setup helper returning `None after infrastructure_available() already passed (Keycloak token mint failed, a peer service unhealthy) — must go through canopy_test_lib::skip_or_panic(detail), which applies the same required-mode contract; a bare eprintln!("skipping …"); return; vacuously green-passes battery lanes (the six notices worker tests did exactly that for as long as they existed).

Ephemeral-schema lifecycle & the post-battery sweep (#1379)

Every test schema is run-scoped: test_<run16>_<hex12> inside an xtask battery (CANOPY_TEST_RUN_ID, minted per battery, carried into the in-network container by compose interpolation) and COMMENT-stamped canopy-test run=<id> created=<epoch> at creation. Connections carry application_name = canopy-test:<service>:<schema> (pools) and canopy-test-admin:<purpose> (create/cleanup/sweep) — visible in pg_stat_activity and, with the devstack’s connection logging (#1379), in the rotated container logs.

Cleanup is layered, weakest to strongest:

  1. the detached Drop task (best-effort; bounded by a 4-permit semaphore so simultaneous drops can’t storm the cluster with handshakes);

  2. explicit cleanup().await (preferred in tests; on failure it prints, returns the error, AND keeps the Drop backstop armed);

  3. the xtask post-battery sweep — the guarantee: after the nextest arms (failed arms included; the lane failure stays the reported error), the battery synchronously drops every schema carrying ITS run component across every service database on every instance, with one ~2s retry for drops racing a late Drop task. A current-run schema surviving the sweep FAILS the battery — a nonzero swept count is the reliable cleanup-failure signal (nextest captures the per-test prints).

Constructor failures never leak: from the instant CREATE SCHEMA commits, a scoped-pool connect failure or migration-replay failure triggers an awaited compensating DROP (the scoped pool closes first — sqlx can return before releasing its per-database migration advisory lock).

Other runs' schemas are NEVER swept automatically without positive inactivity evidence: stamp age > 6h AND no backend referencing the schema in application_name. The battery lease cannot prove another run dead (bare cargo nextest, other worktrees, in-network validate, other developers), and a sweep landing in a foreign run’s migration-wait window would redirect its unqualified DDL into public. Unmarked schemas are reported, never auto-dropped; cargo xtask dev sweep-schemas [--older-than N] [--include-unmarked] is the explicit, lock-held maintenance path.

Phase E hardening (refs #436)

  • EphemeralSchema::cleanup(self).await is the preferred end-of-test call — issues a synchronous DROP SCHEMA <name> CASCADE and mem::forget`s the value so the fallback `Drop does not double-issue. Use this; the Drop-based path is the fallback for panics / early-returns.

  • EphemeralSchema::sweep_orphans(base_url).await drops every test_* schema in the target database. Idempotent; safe to call against a live devstack since it only touches the test_* namespace. Useful for janitor jobs and CI cleanup between test runs.

  • Schema-name suffixes use uuid::Uuid::new_v4().simple()[..16] (122 bits of entropy → 64 bits of suffix) — NOT UUID v7, whose timestamp-derived first hex chars cause collisions when two tests construct schemas in the same millisecond.

  • Contract pinned by crates/canopy-test-lib/tests/db_cleanup_test.rs: cleanup_synchronously_drops_the_schema and sweep_orphans_drops_all_test_schemas_and_is_idempotent.

CANOPY_MQ_QUEUE_PREFIX env

Read by canopy_mq::subscriber::queue_prefix() (returns empty string when unset — identity behavior). Applied uniformly inside:

  • replica_queue_name(base){prefix}{base}.{replica_id}

  • dlq_queue_name(source){prefix}{source}.dlq

  • derive_dlx(queue_name).routing_key{prefix}{queue_name} (so two prefixed environments do not cross-fan-out via the shared canopy.dlq exchange)

  • subscribe_inner (durable subscribe path) prefixes the queue at declare time

MultiReplicaFixture::spawn generates a fresh prefix per fixture so concurrent fixtures see fully-isolated queue namespaces against a shared RabbitMQ broker. Topic-exchange routing keys are unaffected — publishers continue to publish by routing key regardless of which prefixed queues happen to be bound.

Chaos test pattern

4 invariant tests at crates/canopy-test-lib/tests/multi_replica_test.rs, all `#[ignore]’d, opt-in via:

cargo build --workspace
cargo xtask dev start
cargo nextest run --run-ignored only -p canopy-test-lib --test multi_replica_test

Tests:

  • inbox_dedup_under_concurrent_delivery — #433 inbox UNIQUE-key dedup across 2 replicas

  • audit_hash_chain_holds_under_concurrent_writesADR-014 pg_advisory_xact_lock(1) chain serialisation across 3 replicas

  • outbox_skip_locked_partitions_drainersADR-018 FOR UPDATE SKIP LOCKED cooperative draining across 2 replicas

  • sse_broadcast_reaches_every_replica — #458 per-replica fan-out queues for canopy-web SSE

NOTE
These tests assert the structural readiness contract (replicas spawned + queues prefix-isolated). For deeper behavioural assertions see #469 / #470 — those are tracked separately rather than gated on chaos infra.

Fault injection (crates/canopy-test-lib/src/evil.rs)

EvilLayer + evil_proxy() ship an Axum-based reverse proxy on 127.0.0.1:0 for fault-injection chaos tests. The builder composes per-request behaviours:

let proxy = evil_proxy("http://canopy-rules:46699")
    .with_latency_jitter(Duration::from_millis(100)..Duration::from_millis(800))
    .with_failure_rate(0.1)              // 10% 503 synthesis
    .drop_connection_after(50)           // serve 50 then 502
    .tamper_payload(|json| { /* mutate */ })
    .start().await?;
let client = EligibilityClient::new(&proxy.base_url());

Per-request order: latency_jitterdrop_afterfailure_rate → forward → tamper_payload. Use to exercise circuit-breakers, retry budgets, deserialisation hardening, and outbox catch-up after broker outage.

Live example: crates/canopy-test-lib/tests/evil_proxy_test.rs (inbox_dedup_at_100_percent_failure, eligibility_circuit_breaker, jwks_rotation, outbox_catches_up — all `#[ignore]’d, opt-in via the chaos-test command above).

Devstack fault injection (/test/fault, #1325)

The e2e-reachable sibling of EvilLayer: every service built with the canopy-api/test-fault cargo feature serves an unauthenticated /test/fault control surface (GET reads, POST sets, DELETE clears) whose spec — { "latency_ms": 30000 } and/or { "status": 500 } — is applied by a middleware on the service’s /v1 router only: /livez, /readyz, and /test/* stay healthy, so an injected 30-second hold degrades pages without tripping container health checks. The same compile-stripped doctrine as /test/clock (ADR-033 §5): the route, the state, and the middleware do not exist in a release build — off by default, nothing to guard in production, and a devstack without the feature makes the fault specs test.skip cleanly (no impact on normal batteries).

Playwright drives it through tests/e2e/lib/fault.ts (withFaults(ctx, { applications: { latency_ms: 30_000 } }, body) — always clears every faultable service in a finally, the withAdvancedClocks contract). The fault project runs strictly LAST (after the journey lane) because the spec is process-global on the devstack. Build the stack with CANOPY_CARGO_FEATURES=canopy-api/test-clock,canopy-api/test-fault cargo xtask dev refresh.

The specs/fault-injection.spec.ts lane is the controlled-failure proof the ssr-aggregate-deadline plan deferred here: one upstream held at +30s lands the worker dashboard under the 15s nav budget with the degraded arms rendered, and the all-sources specs pin my-queue’s two static error copies (time-class vs plain) — assertions the healthy-stack dashboard spec deliberately refuses to carry.

Finalize-saga acceptance suite (ADR-038, epic &71 MR8)

services/canopy-applications/tests/finalize_acceptance_test.rs proves the ADR-038 criteria (a–h) end-to-end against an ephemeral applications schema and the receipt-modeling mock canopy-persons shared with the MR5 behavioral suite via tests/common/mod.rs (one home for the mock + harness). The mock implements the MR1 receipt contract and generation gate for real — a tagged replay returns the stored id, a cancelled generation refuses tagged writes with 409 — so resume semantics are exercised, not replay-the-first-response. Patterns worth reusing:

  • Commit/response-separated fault knobs: fail_on_step (pre-commit 5xx — nothing committed), commit_then_fail_on_step (entity + receipt commit, the response dies), hold-then-fail (timeout-before-commit under a short-timeout client), and a local finalize_steps DELETE (response-then-crash-before-record) — the four distinct boundary classes of the failure matrix.

  • Deterministic two-connection barriers: hold_on_step arms a two-tokio::sync::Notify barrier — the mock signals reached when the step’s write arrives and parks INSIDE the persons call until release, while the test acts on a second DB connection (FOR UPDATE NOWAIT lock probes for criterion g, the reaper sweep, or the full compensation loop against the parked stale writer).

  • Restart = rebuild every service-side dependency (client/publisher/deps) against the same databases — never "drop the future"; lease/grace aging = DB-time backdating (paused Tokio cannot move clock_timestamp()).

  • PII-free-log assertions via SpanCapture on a current-thread runtime: run the failure paths, drain() the events, assert no applicant sentinel (name/DOB/amount/contact) appears in any target/message/field.

  • Real-layer receipt dedup: one guarded test acquires a genuine canopy-applications service identity (acquire_service_token) and drives the saga against the REAL devstack canopy-persons, then replays a tagged create — proving the transactional receipt at the layer that owns it.

Observability assertions (crates/canopy-test-lib/src/observability.rs)

SpanCapture installs a scoped tracing subscriber so tests can assert on emitted spans + fields:

#[tokio::test(flavor = "current_thread")]  // REQUIRED — set_default is thread-local
async fn token_refresh_emits_correlated_span() {
    let (capture, _guard) = SpanCapture::install_scoped();
    do_the_thing().await;
    capture.assert_span_emitted("token_refresh");
    capture.assert_span_field("token_refresh", "worker_id", "00000000-0000-0000-0000-000000000001");
    let events = capture.drain();
    // event timeline, field assertions, etc.
}
IMPORTANT

Tests using SpanCapture MUST use #[tokio::test(flavor = "current_thread")]. tracing::subscriber::set_default is thread-local; on a work-stealing multi-threaded runtime, spawned tasks will not see the subscriber. CI failure mode: the test passes locally and `flake`s under nextest.

Cross-process chaos via in-process fixtures (canopy_test_lib::chaos, #480 + ADR-020)

SpanCapture’s thread-local constraint means events fired inside devstack containers (canopy-auth’s `JwksProvider refresh task, canopy-mq’s OutboxDrainer running inside services) are unobservable from the test process — the chaos tests were "fixture landed" not "invariant proven" (closed #469 + #470). The chaos module sidesteps this by spawning production components IN the test process pointed at controlled endpoints, so spans fire on the same current_thread runtime as the test.

use canopy_test_lib::chaos::spawn_jwks_provider_for_chaos;
use canopy_test_lib::evil::EvilLayer;
use canopy_test_lib::observability::SpanCapture;

#[tokio::test(flavor = "current_thread")]
async fn my_chaos_test() {
    let (capture, _guard) = SpanCapture::install_scoped();
    let handle = spawn_jwks_provider_for_chaos(EvilLayer::new()).await;
    handle.provider.refresh().await.expect("happy-path");
    capture.assert_span_emitted("JWKS refreshed");
}

Helpers: spawn_jwks_provider_for_chaos(EvilLayer) (in-process, no devstack) and spawn_outbox_drainer_for_chaos(pool, broker_url) (devstack-required, mark tests #[ignore] per the existing chaos pattern). See Shared Crates Reference for the per-function contract. ADR-020 documents the strategy decision.

Adding a new contract: follow the step-by-step runbook at the chaos-observability-contract runbook (canonical references for the target: annotation, helper authoring, the #[tokio::test(flavor = "current_thread")] requirement, and the deterministic-20x verification pattern). Landed contracts as worked examples: jwks_rotation (#481) in-process, outbox_catches_up (#482) devstack-gated.

Metric assertions (MetricCapture) are stubbed — wire via opentelemetry_sdk::metrics::InMemoryMetricExporter if a chaos test demands counter assertions.

Time mocking (crates/canopy-test-lib/src/time.rs)

For tests that exercise clock-driven logic (cert-period expiry, idle timeouts, throttle windows), use the time helpers rather than tokio::time::sleep against wall-clock:

  • with_frozen_time(now, async { …​ }) runs the closure with a frozen reference instant; production code using canopy_common::time::now() returns the frozen value.

  • advance(Duration) moves the frozen clock forward without sleeping the test thread.

  • Pairs with tokio::test(start_paused = true) for fully-deterministic async time.

Use this for: certification-period boundary tests, refresh-token expiry, advisory-lock timeout, idempotency-cache TTL expiry. Never use sleep to "wait for expiry" — the test becomes slow AND flaky.

NOTE
For journey tests that need a process-global gated clock end-to-end (the canopy_test_lib::journey step primitives + the test-clock devstack opt-in), see ADR-033. The gated clock is process-global, so clock-driven journeys run in isolation. Enable the devstack test-clock build with CANOPY_CARGO_FEATURES=canopy-api/test-clock cargo xtask dev refresh. Since #1218 the renewals caseload-trend serves only rollup data anchored on the SAME gated clock (window parse, series spine, refresh anchor, and freshness all move together), so a journey that advances the clock and then renders the supervisor dashboard must run POST /v1/renewals/caseload-rollup/refresh after the advance — otherwise the trend honestly 503s as stale under the advanced clock.

Frozen KAT corpus pattern (crates/canopy-chain/tests/vectors/, #1246)

For BYTE-level protocol surfaces (canonicalization, hash preimages, manifest encodings, signatures) a goldenfile is not enough — the vectors must be independently seeded so the implementation cannot certify itself:

  • Seed vectors come from OUTSIDE the implementation: the official cyberphone/RFC 8785 test data (bundled by the canonicalizer crate, provenance documented per file) plus a committed second-implementation derivation (tests/vectors/provenance/seed_verify.py) whose outputs the Rust stack must match.

  • The generator (cargo run --example generate_vectors) REFUSES overwrite — regeneration is a deliberate act, and the VECTOR_CORPUS_SHA256 const forces a visible source diff (review discipline, not a mechanical gate).

  • Freeze discipline: changing any vector means bumping THAT surface’s protocol version (event_hash_formula_version, routing_version, genesis_version, anchor_manifest_version, anchor_signing_version — each bumps independently) plus an ADR-014 amendment.

  • Enum-coverage assertions keep the corpus exhaustive: adding a ChainFamily/AnchorKind variant fails the suite until a vector exists.

  • Adversarial cases are pinned alongside the happy path (non-BMP keys, ±(2^53−1) edges, rejection classes, strict-JWS negatives, test-key exclusion) — the corpus is the refusal contract too.

Goldenfile pattern (crates/canopy-test-lib/src/goldenfile.rs)

For tests whose output is structured text (rendered notices, generated SQL, federal-reporting CSVs, OpenAPI snapshots), capture once and pin via goldenfile:

let actual = render_notice_acceptance(&household).await?;
golden::assert_eq("tests/golden/notice_acceptance.txt", &actual);
  • CANOPY_GOLDEN_UPDATE=1 cargo nextest run regenerates files in-place (review diffs before commit).

  • Reviewer-friendly: structured-output diffs surface in the MR directly.

  • Use sparingly — over-pinning makes refactors require mass-regeneration. Pin where the output IS the contract (notice text, report CSV layout, schema migrations); skip where the output is incidental.

Snapshot testing

For non-textual structured values (deep serde_json::Value outputs, error chains, decision-tree dumps), insta is the default tool. The pattern lives alongside contracts-crate proptest blocks.

let result = engine.evaluate(&ruleset, &input).await?;
insta::assert_yaml_snapshot!(result);  // YAML for human-readable diffs

cargo insta review walks pending updates; cargo insta accept commits them. Snapshots live next to the test in <test>.snap files.

Typed service clients (crates/canopy-test-lib/src/clients/)

For integration + chaos tests, prefer the per-service typed clients over hand-rolled reqwest against URL strings:

let client = EligibilityClient::new(&cfg.eligibility_url);
let result: CombinedResult = client.evaluate(determination_input).await?;

Each client wraps the contracts-crate DTOs (so changes to the wire schema break compilation, not runtime). They share TestClient’s auth flow: `TestClient::authenticated() auto-acquires a Keycloak JWT for jane.doe and threads it through every typed call. Endpoint paths are encoded in the client, not the call site — refactoring an endpoint URL touches one file.

When a test needs the raw HTTP for negative-case assertions (4xx behaviours, header inspection), drop down to TestClient::new(…​) and call .get/.post(…​) directly.

TestClient::with_retry (bounded retry for flake suppression)

Chaos tests and integration tests that exercise transient-blip behavior can opt into canopy_api::retry::RetryPolicy via TestClient::with_retry (or, for typed clients, the per-client pass-through such as EligibilityClient::with_retry):

let client = EligibilityClient::new(&cfg.eligibility_url)
    .with_retry(canopy_api::retry::RetryPolicy::default_http());

When a policy is set, get / post_json / delete dispatch through canopy_api::retry::retry_request (3 attempts default; 100ms→30s backoff; ±25% jitter). POST auto-generates a single Idempotency-Key: {uuid v7} header outside the retry loop so the server-side cache replays the first response on retry. TestClient’s return types are unchanged — `get/post_json/delete still return TestResponse directly; transport errors still panic (same posture as the one-shot path); retry-exhaustion with an HTTP status synthesizes a TestResponse carrying the last status seen so callers reading .status get a real number. PATCH/PUT are intentionally NOT retry-wrapped (server-side idempotency middleware caches POST only). See crates/canopy-api/src/retry.rs for the contract and the retry-middleware design plan for the rationale (#462).

Invariant tests with drift contract

Canopy’s invariant gates. These run in the pre-push validate suite and in CI.

  • cargo xtask rules check sweeps all 12 JDM rulesets — compiles each under zen-engine 0.55 and evaluates the paired fixture under crates/canopy-test-lib/fixtures/rulesets/. Failure means a ruleset was added / edited without the matching fixture (or vice versa).

  • cargo xtask policy audit validates citations.toml completeness against jurisdiction.toml per ADR-011. Failure means a jurisdiction parameter has no PAMMS citation backing it.

  • cargo xtask check-docs validates SHA-256 hashes of Tier 1 docs against the claude-quickstart template. Failure means a universal doc was edited locally without a template-side bump.

See also

  • Testing (Standard) — the universal testing strategy (philosophy, nextest, Playwright, evil-input corpus, mutation testing, property-based basics, test-results/ layout).

  • Shared Crates Reference — the canopy-test-lib public API surface (MultiReplicaFixture, EphemeralSchema, EvilLayer, SpanCapture, the chaos helpers, typed clients).

  • Chaos-Observability Contract runbook — how to author a new in-process chaos contract.

  • ADR-020, ADR-033 — the chaos-observability and generative-seed-harness decisions.

Edit this page · default