Docs

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

TS
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.

TS
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 drifted

Whether 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.

TS
// 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.

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.

TS
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.

TS
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.

TS
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.

TS
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,
})

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.