Contract-derived adversarial testing
A route contract should be more than documentation. @nifrajs/testing turns it into a laboratory: valid requests, hostile inputs, real boundary rejection, response conformance, shrinking, replay seeds, and adapter parity from one small test interface.
One assertion, every contracted boundary
import { assertAdversarialContract } from "@nifrajs/testing"
import { app } from "../src/app"
const { test } = await import("bun:test")
test("the API contract withstands hostile inputs", async () => {
await assertAdversarialContract(app, { seed: 73 })
})For every selected route, the laboratory synthesizes a valid contract witness. It then changes types, removes required fields, crosses numeric and length bounds, inserts unknown properties, and descends into nested objects and arrays. A mutation is sent only after the route's own Standard Schema validator proves it invalid. Query values are proved after URL serialization, exactly as the server receives them.
Invalid inputs must produce 422 by default. A valid witness is also executed for every declared response, and the real JSON body is validated off the request hot path. Use expectedValidationStatuses or isRejected when your app deliberately has a different validation response.
Assert the server keeps its own contract
A route’s response schema types the handler and the client, but nothing re-checks the bytes that actually leave the app. validateResponses closes that gap in tests: every JSON response is validated against the schema declared for its status, and a mismatch throws ResponseContractViolation straight through the otherwise-never-throwing client - because a drifted payload passing quietly is the failure this exists to prevent.
import { testClient } from "@nifrajs/client"
import { app } from "./app"
// Every JSON response is checked against the route's declared schema for its status:
// `response` for 2xx, `errors[status]` for declared failures. A mismatch THROWS.
const api = testClient<typeof app>(app, { validateResponses: true })
const res = await api.me.get() // ResponseContractViolation if the payload driftedWhether it catches undeclared extra fields depends on your validator, not on nifra: a strict schema (@nifrajs/schema’s t.object) reports them and the test fails, while a stripping one (Zod, Valibot) accepts them silently. To catch extras regardless, or to stop them reaching the wire in production, use responseContract.
Opaque schemas stay validator-neutral
Nifra's t schemas carry inspectable JSON Schema, so witness generation is automatic. Other Standard Schema libraries do not have to expose structure. Supply a known-good witness; their own validator still proves every hostile mutation.
// Standard Schema guarantees validation, not introspection.
// Give opaque Zod/Valibot/ArkType routes only their known-good request values.
await assertAdversarialContract(app, {
witnesses: {
"POST /users/:id": {
params: { id: "user-1" },
body: { name: "Ada" },
query: { notify: "true" },
},
},
})Missing, invalid, or unsynthesizable witnesses become explicit coverage gaps. The default is fail-closed; requireCoverage: false makes gaps advisory.
Declared params and headers get witnesses too, validated before any case runs, so a body or query case is rejected by the input it mutates. The harness never invents credentials: authorization, cookie, x-api-key and names that look like a key, token, secret or signature come from the witness and prepareRequest only, and are never mutated. A route with a cookies schema needs headers.cookie in its witness, and a missing credential is a gap that names it.
Auth and tenant context
prepareRequest runs for every case and runtime. Use it to attach a test session, tenant identity, signed headers, or platform bindings without putting secrets into reports.
await assertAdversarialContract(app, {
prepareRequest(request, context) {
const headers = new Headers(request.headers)
headers.set("authorization", "Bearer test-session")
headers.set("x-tenant-id", context.runtime === "worker" ? "edge-test" : "local-test")
return new Request(request, { headers })
},
})One contract, many runtimes
Supply fetch targets to exercise the identical cases through Bun, Node, Deno, or Workers adapters. Reflection still comes from the original app, so there is one authoritative contract.
const report = await assertAdversarialContract(app, {
runtimes: [
{ name: "bun", fetch: (request) => bunApp.fetch(request) },
{ name: "node", fetch: (request) => nodeAdapter.fetch(request) },
{ name: "worker", fetch: (request) => worker.fetch(request, env) },
],
})
// Each target receives the same case IDs and deterministic witnesses.
console.log(report.seed, report.counts)A mock server from the same contract
@nifrajs/mock builds a fake backend out of the routes you already have. It reads each route's response schema and generates data of that shape, so the mock cannot drift from the contract the way a hand-written fixture file does - a changed schema changes the mock, and a route with no response schema returns rather than something invented.
import { createMockServer } from "@nifrajs/mock"
import { app } from "./app.ts"
// Reads the routes' `response` schemas and generates data matching their shape.
// The seed is fixed, so a snapshot taken today still matches tomorrow.
const mock = createMockServer(app, { seed: 42 })
const res = await mock.fetch(new Request("http://local/notes"))Useful for building a frontend against a backend that is not finished, and for demos that must not touch a real database. It generates SHAPE, not meaning: the values are plausible-looking filler, so it answers "does this render" rather than "is this right".
Shrink and replay failures
Unexpectedly accepted hostile inputs are greedily reduced to a smaller validator-invalid request. Results do not print request bodies or headers; the stable case ID, runtime, and seed are enough to replay deterministically.
const report = await runAdversarialContract(app, { seed: 73 })
const failure = report.failures[0]
// CI can print this small, payload-free replay tuple.
console.error(failure.replay) // { seed: 73, caseId: "...", runtime: "worker" }
await assertAdversarialContract(app, {
seed: failure.replay.seed,
only: failure.replay.caseId,
})A web app's routes in a test
Give the test the same route manifest the app serves. discoverRoutes(dir) from @nifrajs/web/fs scans a routes directory the way the build does, so every page arrives with its .backend.ts half: its loader, action, output schemas and middleware. A built app can import the generated server-manifest instead, which bakes the same pairs into static imports.
// test/routes.test.ts - render the real routes in-process, each page with its backend half.
import { expect, test } from "bun:test"
import { createWebApp } from "@nifrajs/web"
import { discoverRoutes } from "@nifrajs/web/fs"
import { reactAdapter } from "@nifrajs/web-react"
const app = createWebApp({
adapter: reactAdapter,
// Scans routes/ the way the build does: a page and its .backend.ts half always arrive together.
manifest: discoverRoutes(`${import.meta.dir}/../routes`),
clientEntry: "/client.js",
})
test("the users page runs its loader", async () => {
const res = await app.fetch(new Request("http://localhost/users/7"))
expect(res.status).toBe(200)
})buildManifest(files, load) pairs a page with its backend half only when both are in files. It is pure, so it cannot know a half is missing, and a hand-written list that names the page alone serves it without its loader and guards: the test passes against a route production never runs. nifra doctor flags a buildManifest(...) call whose array literal or import.meta.glob patterns reach a page but not the backend half beside it.
Browser end-to-end
API flows stay in-process through testSession - typed, cookie-aware, no socket. What in-process cannot do (real navigation, real rendering) goes to a real browser: serveTestApp binds the app to an ephemeral port, e2eUrl typechecks the visited path against the route registry, and the session jar crosses into the browser as a Cookie header. Bring Playwright (or Vitest Browser) for the browser itself - nifra supplies the glue, not another runner.
import { chromium } from "@playwright/test"
// doc-check: skip - Playwright is an optional peer dependency; run with @playwright/test installed.
import { e2eUrl, serveTestApp } from "@nifrajs/testing/e2e"
import { testSession } from "@nifrajs/testing"
import { app } from "../src/app"
// API flows stay in-process (fast, typed, no socket); only rendering goes to a browser.
const { client, cookies } = testSession<typeof app>(app)
await client.auth.login.post({ email: "ada@x.com", password: "secret" })
const www = await serveTestApp(app)
const page = await (await chromium.launch()).newPage({
extraHTTPHeaders: { Cookie: cookies.header() }, // the session crosses into the browser
})
await page.goto(e2eUrl<typeof app>(www.baseUrl, "/dashboard")) // typo'd path won't compile
await page.getByRole("heading").isVisible()
await www.stop()The response-conformance pass executes handlers, including POST/DELETE handlers. Run it with isolated fixtures and a test database. Never point a contract laboratory at production.