Search docs

Jump between documentation pages.

Browse docs

Testing & contract tests

Three layers, one app
  1. 01In-process testsapp.request() through the real pipeline
  2. 02Mock modenew App({ mockMode: true }), returns examples
  3. 03Contract runnerrunContractTests(app) validates every route
  4. 04backstopCI / pre-push gatedaloy inspect --check, exits non-zero
In-process tests exercise real handlers through app.request(), mock mode returns declared examples for pure contract assertions, and the contract runner validates the whole spec, the same check daloy inspect runs in CI and the pre-push hook.

In-process test client

Every App exposes a request() method that round-trips a fetch Request through the same pipeline real traffic uses, no socket, no port:

ts
import test from "node:test";
import assert from "node:assert/strict";
import { app } from "../src/server.js";

test("GET /books/1 returns 200", async () => {
  const res = await app.request("/books/1");
  assert.equal(res.status, 200);
  assert.equal((await res.json()).title, "Foundation");
});

test("POST /books rejects unauthorized", async () => {
  const res = await app.request("/books", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title: "Dune" }),
  });
  assert.equal(res.status, 401);
});

Mock mode

For pure-contract testing (no DB, no side effects), enable mockMode. DaloyJS returns the first declared response examples entry for the first 2xx status without ever invoking your handler. If no example is declared, the mocked body is null.

ts
import { App } from "@daloyjs/core";
import { z } from "zod";

const app = new App({ mockMode: true });

app.route({
  method: "GET",
  path: "/users/:id",
  operationId: "getUser",
  responses: {
    200: {
      description: "ok",
      body: z.object({ id: z.string(), name: z.string() }),
      examples: { default: { id: "u_1", name: "Alice" } },
    },
  },
  handler: async () => {
    throw new Error("not called in mock mode");
  },
});

Contract test runner

runContractTests walks your registered routes and verifies that every declared response and meta.examples payload validates against its schema, every required operationId is present and unique, and there are no obvious anti-patterns:

ts
import { runContractTests } from "@daloyjs/core/contract";

const report = await runContractTests(app, {
  requireOperationId: true,
  allowBodyOnSafeMethods: false,
});

if (!report.ok) {
  console.error(report.issues);
  process.exit(1);
}
console.log(`${report.checked} routes - all clean`);
What the runner checks
one passrunContractTests(app)walks every registered route
operationId presentrequireOperationId
operationIds uniqueno duplicates across routes
examples match schemaresponse examples + meta.examples
no body on safe methodsGET / HEAD / DELETE warning
responses declaredevery route has responses
report.okfalse only for error-level issues
A single walk over your routes produces one report. Error-level issues make report.ok false; warnings, such as safe-method body schemas, stay in report.issues without failing the gate.

The report flags:

  • Routes missing operationId.
  • Duplicate operationIds.
  • Response examples and meta.examples payloads that don't match their schemas.
  • Body schemas declared on safe methods (GET, HEAD, DELETE) as warnings.
  • Routes with no declared responses.

Wire into CI

json
{
  "scripts": {
    "test": "node --import tsx --test tests/**/*.test.ts",
    "contract": "daloy inspect --check src/build-app.ts"
  }
}

Or skip the script and let the CLI do it directly. daloy inspect --check <entry> loads your app, runs the same checks, and exits non-zero on any error-level issue, so it drops straight into a CI step. The entry must export your App as the default export or a named app export.

bash
pnpm exec daloy inspect --check src/build-app.ts

Every create-daloy template already ships this gate: a tests/contract.test.ts (tests/contract_test.ts on Deno) that asserts report.ok for the real app and proves the gate rejects a broken contract. It runs as part of the project's test task, so a missing operationId or a mismatched example fails CI from the first commit.

Gate it locally with a pre-push hook

A contract check is an authoring-time concern, so it belongs on your machine, never on the production request path. A pre-push git hook is the cleanest home for it: it is localhost-only by construction (it cannot run in production), adds no server boot cost, and fires right before code leaves your machine, with CI as the backstop.

Every create-daloy template ships this hook under .githooks/pre-push, wired to a hooks:install script. Enabling it is one command per clone: it points core.hooksPath at the committed hook, so the whole team shares the same gate. The hook skips gracefully when tooling is missing (it never blocks a push over an uninstalled dependency), and you can always bypass it once with git push --no-verify.

bash
# Enable the contract gate for this clone (points core.hooksPath at .githooks)
pnpm hooks:install

# From then on, every `git push` runs the contract check first:
#   .githooks/pre-push  ->  daloy inspect --check src/build-app.ts
# Need to push past it once:
git push --no-verify