Testing (Canopy)
On this page
- Running the suite
- The validate report (
test-results/validate-report.json, #1253) - Canopy test-writing idioms
- The boundary-auth conformance matrix (OIDC F4, #1422)
- Deterministic seed-based test data — the canopy-seed harness (#450)
- Scanner-path tests (ADR-042, #1006)
- Contracts crates + proptest round-trips
- Coverage gate
- Multi-Replica Fixture + Ephemeral Schema (Phase D of #436)
- Fault injection (
crates/canopy-test-lib/src/evil.rs) - Finalize-saga acceptance suite (ADR-038, epic &71 MR8)
- Observability assertions (
crates/canopy-test-lib/src/observability.rs) - Time mocking (
crates/canopy-test-lib/src/time.rs) - Frozen KAT corpus pattern (
crates/canopy-chain/tests/vectors/, #1246) - Goldenfile pattern (
crates/canopy-test-lib/src/goldenfile.rs) - Snapshot testing
- Typed service clients (
crates/canopy-test-lib/src/clients/) - Invariant tests with drift contract
- See also
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 |
|---|---|---|
|
|
none needed; |
|
ENTRYPOINT |
devstack, |
validate pure-unit arm |
|
genuinely infra-free |
validate infra arm |
|
the one complete infra lane per battery |
CI |
|
infra-less runner + |
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).
-
state∈running\|pass\|fail\|interrupted. Arunningleft on disk means the process was killed by a signal (no cleanup ran); a graceful unwind / early return marksinterruptedvia aDropguard; only a clean finish finalizespass/fail.failnames the failing blocking stage infailed_stage. -
Each stage records
execution(pass/fail/skip/not_run/running), apolicy(blocking\|advisory),duration_ms, and — on failure — a redactederror_chainplus, for the subprocess gates (capture: full-tee), adiagnosticsblock 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: apassrun may legitimately carry an advisoryfail. -
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 /40P01body 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_decimalserializes via the workspace-wideserde-strfeature, so a JSON number will fail to deserialize. -
Test date construction:
NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid test date")— never unwrap anOption<NaiveDate>silently. -
Test naming:
snake_casedescribing 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
Activationset 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_FLIPScarries canopy-rules, whose exchange rows are honest permanent pendings; canopy-exchange has no routes, pinned vacuous by its ownroutes_is_an_empty_routertest. 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 carrybody/querytemplates 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:
-
Single source of truth. Both
cargo xtask seedandcargo xtask e2eroute 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 thetests/e2e/lib/seed.tsmanifest to point at UUIDs that did not exist in the DB. Nowxtask e2ereadstest-results/seed/last.txtand only re-seeds when env-suppliedCANOPY_SEED/CANOPY_HOUSEHOLDSdiffer from the captured values. -
Random by default + captured for replay. No
--seedflag ⇒rand::random::<u64>(). The resolved seed lands at the top ofseed.tsasexport const SEED_VALUE: number = N;AND intest-results/seed/last.txt(gitignored). The completion notice echoesSeed: N (replay with --seed N).for cut-and-paste. -
Predicate-fixture API decoupled from the auto-generated manifest. Specs import from
tests/e2e/lib/fixtures.ts(hand-written, checked-in), nottests/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'
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+Deserializeon every Request and Response (symmetric — Request types are notDeserialize-only). -
Adds
PartialEqwhen proptest round-trip tests rely on it. -
Re-exports a
pathsmodule with the FULL post-mount path constants (e.g.pub const DETERMINE: &str = "/v1/eligibility/determine"); service routers strip/v1at boot so the const is the single source of truth. -
Ships a
tests/roundtrip.rswith proptestproptest! { … }blocks that serialize → deserialize →prop_assert_eq!on every DTO. Arbitrary generators bound floats and dates to ranges that round-trip exactly;serde_json::Valuegenerators 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.
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_ready → required → 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:
-
the detached
Droptask (best-effort; bounded by a 4-permit semaphore so simultaneous drops can’t storm the cluster with handshakes); -
explicit
cleanup().await(preferred in tests; on failure it prints, returns the error, AND keeps the Drop backstop armed); -
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).awaitis the preferred end-of-test call — issues a synchronousDROP SCHEMA <name> CASCADEandmem::forget`s the value so the fallback `Dropdoes not double-issue. Use this; theDrop-based path is the fallback for panics / early-returns. -
EphemeralSchema::sweep_orphans(base_url).awaitdrops everytest_*schema in the target database. Idempotent; safe to call against a live devstack since it only touches thetest_*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_schemaandsweep_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 sharedcanopy.dlqexchange) -
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_writes— ADR-014pg_advisory_xact_lock(1)chain serialisation across 3 replicas -
outbox_skip_locked_partitions_drainers— ADR-018FOR UPDATE SKIP LOCKEDcooperative draining across 2 replicas -
sse_broadcast_reaches_every_replica— #458 per-replica fan-out queues for canopy-web SSE
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_jitter → drop_after → failure_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 localfinalize_stepsDELETE (response-then-crash-before-record) — the four distinct boundary classes of the failure matrix. -
Deterministic two-connection barriers:
hold_on_steparms a two-tokio::sync::Notifybarrier — the mock signalsreachedwhen the step’s write arrives and parks INSIDE the persons call untilrelease, while the test acts on a second DB connection (FOR UPDATE NOWAITlock 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
SpanCaptureon 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-applicationsservice 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.
}
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 usingcanopy_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.
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 theVECTOR_CORPUS_SHA256const 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/AnchorKindvariant 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 runregenerates 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 checksweeps all 12 JDM rulesets — compiles each under zen-engine 0.55 and evaluates the paired fixture undercrates/canopy-test-lib/fixtures/rulesets/. Failure means a ruleset was added / edited without the matching fixture (or vice versa). -
cargo xtask policy auditvalidatescitations.tomlcompleteness againstjurisdiction.tomlper ADR-011. Failure means a jurisdiction parameter has no PAMMS citation backing it. -
cargo xtask check-docsvalidates 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-libpublic API surface (MultiReplicaFixture,EphemeralSchema,EvilLayer,SpanCapture, thechaoshelpers, 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.