Skip to content

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.

LayerAnswersSpeedCount
Rust unit“Is this logic right?”µshundreds
#[sqlx::test]“Is this SQL right? Does RLS hold?”10–30 mshundreds
oneshot contract“Is the HTTP surface right?”~1 msdozens
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.

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.

Terminal window
cargo test --workspace --lib

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(())
}

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 unitcontainerdatabase
Per-test cost2–10 s10–30 ms
Parallelheavyfree
PrerequisiteDocker daemona 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.

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

api/migrations/0008_rls_audit.sql
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.

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:

PropertyVerified behaviour
All migrations apply00010008 apply cleanly from an empty database
Cross-tenant readsA session scoped to tenant A sees only tenant A’s rows
Cross-tenant writesWITH CHECK rejects an INSERT/UPDATE stamping another tenant’s tenant_id
System-row forgeryA tenant session cannot insert a NULL-tenant “system” app.decoration_methods row
Fail-closedWith platyn.tenant_id unset, every tenant-scoped query returns zero rows
  • Every RLS policy — read, write, and the WITH CHECK half 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 NUMERIC round trip.
  • Job queue semantics: two workers running FOR UPDATE SKIP LOCKED claim 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 rolls line_item_sizes.quantity up onto line_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, and XX-LARGE must all return the same size_id, and an unknown alias must return NULL rather than a guess.
  • app.next_deal_number() under concurrency: two simultaneous deals in one tenant must not collide on UNIQUE (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.

  • 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 viewer gets 403 on a write, an owner gets 200.
  • 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.

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.

web/e2e/quote-a-job.spec.ts
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.

Terminal window
podman-compose up -d
cd web && bun run test:e2e

Five assertions run against a deployed environment right after release. They test that the deploy worked, not that the code is correct.

  1. GET /health/live on api returns 200.
  2. GET /health/ready returns 200 with both postgres and valkey at ok.
  3. GET /healthz on web returns 200.
  4. Sign in as the canary user and fetch /v1/auth/session.
  5. GET /v1/catalog/styles?q=PC61 returns 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.

Terminal window
just test # unit + integration + contract + lint, the pre-push gate
just test-e2e # Playwright against a running compose stack
flowchart 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.

  • Mocking the database. A mock cannot enforce an RLS policy, a partial unique index, or a NUMERIC round 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 oneshot test 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.