Testing
Five layers. Each answers a different question, and putting a test in the wrong layer is how a suite becomes slow and untrustworthy at the same time.
| Layer | Answers | Speed | Count |
|---|---|---|---|
| Rust unit | “Is this logic right?” | µs | hundreds |
#[sqlx::test] | “Is this SQL right? Does RLS hold?” | 10–30 ms | hundreds |
oneshot contract | “Is the HTTP surface right?” | ~1 ms | dozens |
| Playwright E2E | “Can a person do the job?” | seconds | ~20 |
| Smoke | “Is the deployed thing alive?” | seconds | ~5 |
The shape is a pyramid with an unusually fat middle. That is deliberate: in a system whose
correctness is enforced by the database — RLS policies, partial unique indexes, FORCE ROW LEVEL SECURITY — a test that mocks the database tests nothing that matters.
Rust unit tests
Section titled “Rust unit tests”Pure functions, no IO. Pricing math, size normalisation, cursor encoding, vendor payload parsing, state machine transitions.
#[cfg(test)]mod tests { use super::*;
#[test] fn quantity_break_uses_the_highest_qualifying_tier() { let breaks = PriceBreaks::new(vec![(1, dec!(12.00)), (24, dec!(9.50)), (144, dec!(7.25))]); assert_eq!(breaks.unit_price_at(143), dec!(9.50)); assert_eq!(breaks.unit_price_at(144), dec!(7.25)); }
#[test] fn totals_never_use_floating_point() { // rust_decimal end to end. 0.1 + 0.2 == 0.3 must hold exactly. let line = LineItem::new(dec!(0.10), 3); assert_eq!(line.extended(), dec!(0.30)); }}Vendor client tests use wiremock to serve recorded vendor responses,
including the malformed ones. Real vendor APIs are never called from a test.
cargo test --workspace --libIntegration tests with #[sqlx::test]
Section titled “Integration tests with #[sqlx::test]”This is the layer that carries the most weight, because most of Platyn’s invariants are database invariants.
#[sqlx::test] gives each test function its own freshly migrated database, created before the
test and dropped after. Tests run in parallel and never see each other’s rows.
#[sqlx::test(migrations = "./migrations")]async fn rls_blocks_cross_tenant_reads(pool: PgPool) -> sqlx::Result<()> { let a = seed_tenant(&pool, "shop-a").await?; let b = seed_tenant(&pool, "shop-b").await?; seed_membership(&pool, a, "dana@shop-a.test").await?;
let mut tx = pool.begin().await?; // Connect as the runtime role, not the owner. See the warning below. sqlx::query!("SET LOCAL ROLE platyn_app").execute(&mut *tx).await?; sqlx::query!("SELECT set_config('platyn.tenant_id', $1, true)", b.to_string()) .execute(&mut *tx) .await?;
let rows = sqlx::query!("SELECT id FROM app.memberships").fetch_all(&mut *tx).await?; assert!(rows.is_empty(), "tenant B must not see tenant A's memberships"); Ok(())}
#[sqlx::test(migrations = "./migrations")]async fn unset_tenant_returns_nothing_rather_than_everything(pool: PgPool) -> sqlx::Result<()> { let t = seed_tenant(&pool, "shop-a").await?; seed_membership(&pool, t, "dana@shop-a.test").await?;
let mut tx = pool.begin().await?; sqlx::query!("SET LOCAL ROLE platyn_app").execute(&mut *tx).await?; // Deliberately no set_config. The failure mode must be "nothing", not "everything". let rows = sqlx::query!("SELECT id FROM app.memberships").fetch_all(&mut *tx).await?; assert!(rows.is_empty()); Ok(())}Why not testcontainers-per-test
Section titled “Why not testcontainers-per-test”Testcontainers spins up a Postgres container per test: image pull, initdb, startup, migration — seconds each, and you are quickly choosing between coverage and a coffee break.
#[sqlx::test] reuses one long-lived Postgres and creates a database per test from a
migrated template. That is a CREATE DATABASE ... TEMPLATE — tens of milliseconds. Hundreds of
integration tests finish in the time testcontainers needs for a handful.
| testcontainers-per-test | #[sqlx::test] | |
|---|---|---|
| Isolation unit | container | database |
| Per-test cost | 2–10 s | 10–30 ms |
| Parallel | heavy | free |
| Prerequisite | Docker daemon | a DATABASE_URL |
A container is still the right tool for one thing — standing up the Postgres those tests connect to, once, in CI. It is the wrong granularity for a test case.
export DATABASE_URL='postgres://platyn:platyn@localhost:5432/platyn_test'cargo test --workspace --test '*'The highest-value test in the repo: app.rls_audit()
Section titled “The highest-value test in the repo: app.rls_audit()”Every test above proves that one policy works. This one proves that no table was missed — and it is the only test here that gets more valuable as the schema grows.
Migration 0008_rls_audit.sql ships a function that returns every table in app.* which has a
tenant_id column but is not fully protected and is not explicitly exempted:
CREATE OR REPLACE FUNCTION app.rls_audit()RETURNS TABLE (table_name TEXT, has_rls BOOLEAN, has_force BOOLEAN, has_policy BOOLEAN)LANGUAGE sql STABLE AS $$ SELECT c.relname::TEXT, c.relrowsecurity, c.relforcerowsecurity, EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'app' AND c.relkind IN ('r', 'p') -- ordinary tables AND partitioned parents AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = 'tenant_id' AND a.attnum > 0 AND NOT a.attisdropped) AND c.relname NOT IN (SELECT table_name FROM app.rls_exemptions) AND NOT (c.relrowsecurity AND c.relforcerowsecurity AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid));$$;Zero rows is the invariant. CI asserts it:
#[sqlx::test(migrations = "./migrations")]async fn every_tenant_table_is_rls_protected(pool: PgPool) -> sqlx::Result<()> { let gaps = sqlx::query!("SELECT table_name, has_rls, has_force, has_policy FROM app.rls_audit()") .fetch_all(&pool) .await?;
assert!( gaps.is_empty(), "tables with tenant_id but no enforced isolation: {:#?}", gaps.iter().map(|r| &r.table_name).collect::<Vec<_>>() ); Ok(())}Add a table with a tenant_id and forget SELECT app.apply_tenant_rls(...), and this test fails
before the feature ever ships. It checks all three conditions independently — ENABLE, FORCE, and
a policy actually existing — because any one of them missing means no isolation, and the first two
are easy to get half-right.
The audit deliberately includes relkind = 'p' — partitioned parents. app.audit_log is
PARTITION BY RANGE (at), and RLS on a partitioned parent only covers queries routed through the
parent: selecting a partition directly bypasses it. So 0006 applies the policy to the parent and
every existing partition, and app.ensure_audit_partition() applies it to each new month’s
partition as it is created. A partition created without a policy would be a silent hole; this test
finds it.
Verified isolation behaviour
Section titled “Verified isolation behaviour”The full eight-migration schema has been applied to a real Postgres 17 and the following were confirmed, not assumed. Each is worth a regression test of its own:
| Property | Verified behaviour |
|---|---|
| All migrations apply | 0001–0008 apply cleanly from an empty database |
| Cross-tenant reads | A session scoped to tenant A sees only tenant A’s rows |
| Cross-tenant writes | WITH CHECK rejects an INSERT/UPDATE stamping another tenant’s tenant_id |
| System-row forgery | A tenant session cannot insert a NULL-tenant “system” app.decoration_methods row |
| Fail-closed | With platyn.tenant_id unset, every tenant-scoped query returns zero rows |
What else belongs here
Section titled “What else belongs here”- Every RLS policy — read, write, and the
WITH CHECKhalf that stops writing a row stamped with another tenant’s id. - Constraints that encode a rule:
sync_runs_one_active,jobs_idempotent,design_assets_one_primary,UNIQUE (tenant_id, user_id)on memberships,UNIQUE (vendor_id, vendor_sku)on variants. - Repository functions — the real SQL, the real types, the real
NUMERICround trip. - Job queue semantics: two workers running
FOR UPDATE SKIP LOCKEDclaim disjoint sets; an expired lease is reclaimable; enqueue rolls back with its transaction. - Database-side derivations.
app.sync_line_item_quantity()is a trigger that rollsline_item_sizes.quantityup ontoline_items.quantity. Insert, update, and delete each need a test, because a trigger is logic that no amount of reading the Rust will reveal. catalog.resolve_size()—XXL,2XL,2X, andXX-LARGEmust all return the samesize_id, and an unknown alias must returnNULLrather than a guess.app.next_deal_number()under concurrency: two simultaneous deals in one tenant must not collide onUNIQUE (tenant_id, deal_number).- Migration reversibility and idempotence.
HTTP contract tests with tower::ServiceExt::oneshot
Section titled “HTTP contract tests with tower::ServiceExt::oneshot”Axum routers are tower::Services, so a request can be driven through the entire middleware
stack — auth, tenant resolution, error mapping, tracing — without binding a socket.
use tower::ServiceExt;
#[sqlx::test(migrations = "./migrations")]async fn session_endpoint_rejects_a_revoked_token(pool: PgPool) -> anyhow::Result<()> { let app = platyn_api::router(test_state(pool.clone()).await); let token = login(&pool, "dana@shop-a.test").await?; revoke_session(&pool, &token).await?;
let res = app .oneshot( Request::builder() .uri("/v1/auth/session") .header(AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty())?, ) .await?;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED); let problem: Problem = serde_json::from_slice(&to_bytes(res.into_body(), 8192).await?)?; assert_eq!(problem.status, 401); assert!(problem.detail.is_none(), "401 must not describe why"); Ok(())}No socket, no port allocation, no waiting for a server to come up — a request is roughly a millisecond, and the test still exercises the real router.
What belongs here
Section titled “What belongs here”- Status codes, especially the error ones.
- The RFC 9457 problem shape on every failure path.
- Authentication middleware: absent, malformed, expired, revoked, wrong-tenant tokens.
- Authorization: a
viewergets403on a write, anownergets200. - Request validation rejections.
- Response headers — cache directives,
x-request-id. - That the API never sets a cookie. Invariant 3 of ADR-01 is worth one cheap assertion across every route.
End-to-end with Playwright
Section titled “End-to-end with Playwright”E2E tests drive a real browser against the full compose stack. They are the slowest and most fragile layer, so they cover journeys, not features — roughly twenty tests, each one a thing a shop actually does.
test('a sales rep quotes a 144-piece two-color job', async ({ page }) => { await signIn(page, 'dana@shop-a.test');
await page.goto('/deals/new'); await page.getByLabel('Company').fill('Riverside Brewing'); await page.getByRole('option', { name: 'Riverside Brewing Co.' }).click();
await page.getByPlaceholder('Search styles').fill('PC61'); await page.getByRole('option', { name: /Port & Company Essential Tee/ }).click(); await page.getByLabel('Athletic Heather').fill('144');
await page.getByRole('button', { name: 'Add imprint' }).click(); await page.getByLabel('Location').selectOption('Full Front'); await page.getByLabel('Colors').fill('2');
await expect(page.getByTestId('deal-total')).toHaveText(/\$1,[0-9]{3}\.[0-9]{2}/); await page.getByRole('button', { name: 'Save quote' }).click(); await expect(page.getByRole('status')).toContainText('Quote saved');});Two rules keep this layer from rotting:
Set up through the API, assert through the UI. Clicking through six screens to reach the state
you want to test makes the test slow and makes an unrelated regression fail it. Seed via
platyn-cli or a direct API call, then drive only the interaction under test.
One cross-tenant test lives here too. Sign in as tenant A, request a tenant B deal URL directly, expect a 404. RLS is tested properly at the integration layer, but a test proving the whole stack composes correctly is worth its runtime.
podman-compose up -dcd web && bun run test:e2eSmoke tests
Section titled “Smoke tests”Five assertions run against a deployed environment right after release. They test that the deploy worked, not that the code is correct.
GET /health/liveonapireturns200.GET /health/readyreturns200with bothpostgresandvalkeyatok.GET /healthzonwebreturns200.- Sign in as the canary user and fetch
/v1/auth/session. GET /v1/catalog/styles?q=PC61returns at least one row — proves the database is reachable, migrated, and populated.
If a smoke test fails, the deploy rolls back. It never becomes a debugging session.
Running everything
Section titled “Running everything”just test # unit + integration + contract + lint, the pre-push gatejust test-e2e # Playwright against a running compose stackcargo test --workspacecargo clippy --workspace --all-targets -- -D warningscargo fmt --checkcargo sqlx prepare --check --workspace # offline query data is currentcd web && bun test && bun run check && bun run lintcd docs && bun run build # a broken link or bad MDX fails hereflowchart LR
L["lint<br/>fmt · clippy · svelte-check"] --> U["unit<br/>cargo test --lib"]
U --> I["integration<br/>#[sqlx::test] against<br/>a service-container Postgres<br/>incl. app.rls_audit() == 0 rows"]
I --> C["contract<br/>oneshot"]
C --> B["build images"]
B --> E["e2e<br/>Playwright on compose"]
E --> D["deploy"]
D --> S["smoke"]
style L fill:#00A3D9,stroke:#007AA6,color:#14131A
style I fill:#E4006C,stroke:#B00054,color:#ffffff
style E fill:#FF5B23,stroke:#D9410F,color:#14131A
Postgres is a service container for the whole integration job — started once, with every
#[sqlx::test] creating its own database inside it. cargo sqlx prepare --check runs in lint so a
query changed without regenerating offline data fails in seconds rather than at build time.
Anti-patterns
Section titled “Anti-patterns”- Mocking the database. A mock cannot enforce an RLS policy, a partial unique index, or a
NUMERICround trip — which is to say it cannot test any of the things that break. - A container per test. See above. Use
#[sqlx::test]. - Testing through the UI what a
oneshottest can prove. If the assertion is a status code, it does not need a browser. - Shared mutable fixtures. Each
#[sqlx::test]gets a clean database. Seed inside the test. - Asserting on log output. Assert on state or on the response.
- A cross-tenant test that forgets
SET LOCAL ROLE platyn_app. It passes, and it proves nothing.