Skip to content

Guide: testing capabilities

Status: Implemented in v0.1. Package reference: adapters/testing.md.

Governance is code; test it like code. Everything below runs deterministically in CI — no model, no network. The examples use Vitest; any runner works.

Setup pattern

One helper per app builds the test runtime with production registry + policies and per-test knobs:

ts
// test/helpers.ts
import { createAgentTestRuntime, fakeActor, testClock } from "@orpc-agent/testing";
import { capabilities } from "../src/capabilities";
import { governancePolicies } from "../src/policies";

export function makeRuntime(overrides: Parameters<typeof createAgentTestRuntime>[0] extends infer O ? Partial<O> : never = {}) {
  return createAgentTestRuntime({
    registry: capabilities,
    policies: governancePolicies,
    actor: fakeActor({ attributes: { orgId: "org_1", permissions: ["orders:refund"] } }),
    context: { organizationId: "org_1" },
    overrides: {
      // stub handlers so governance tests need no database
      "orders.refund": async ({ input }) => ({ refundId: "ref_t", amount: input.amount, status: "issued" }),
      "orders.search": async () => ({ orders: [] }),
    },
    clock: testClock("2026-07-27T10:00:00Z"),
    ...overrides,
  });
}

Note: the test runtime accepts per-call conveniences (actor, context, surface, timeoutMs) that don't exist on the production invoke signature — test ergonomics, clearly test-only.

The five suites every app should have

1. Exposure (SI-1, SI-8)

ts
test("refund is not reachable over mcp", async () => {
  const t = makeRuntime();
  expect((await t.describe("mcp")).map(d => d.id)).not.toContain("orders.refund");

  const r = await t.invoke("orders.refund", REFUND_INPUT, { surface: "mcp" });
  expect(r.status).toBe("failed");
  expect(r.error.code).toBe("CAPABILITY_NOT_FOUND");          // concealed, not FORBIDDEN
  expect(t.audit.ofType("capability.denied")[0].data.reason).toBe("not-exposed"); // truth in audit
});

Add the inverse (aiSdk lists it) and a snapshot of describe("mcp") ids — surface drift then shows up as a failing snapshot, i.e. a reviewed diff.

2. Policy decisions

ts
test.each([
  [400,  "completed"],
  [649,  "approval-required"],
  [9000, "failed"],                 // POLICY_DENIED
])("refund of $%i → %s", async (amount, status) => {
  const t = makeRuntime();
  const r = await t.invoke("orders.refund", { orderId: "o1", amount, reason: "dmg" });
  expect(r.status).toBe(status);
});

test("policy exceptions fail closed", async () => {
  const boom = definePolicy("boom", () => { throw new Error("db down"); });
  const t = makeRuntime({ policies: [boom] });
  const r = await t.invoke("orders.search", { query: "x" });
  expect(r.error.code).toBe("POLICY_FAILED");                  // SI-7
});

Test policies as pure functions too (cheap, exhaustive) — the wired tests then only cover ordering/precedence/audit.

3. Approval lifecycle (SI-4, SI-5)

ts
test("approve → resume → consumed", async () => {
  const t = makeRuntime();
  const p = await t.invoke("orders.refund", { orderId: "o1", amount: 649, reason: "dmg" });
  expect(p.status).toBe("approval-required");

  await t.approvals.approve(p.approval.id, fakeActor({ id: "u_manager" }));
  expect((await t.resume(p.approval.id)).status).toBe("completed");
  expect((await t.resume(p.approval.id)).error.code).toBe("APPROVAL_CONSUMED");
});

test("self-approval is rejected", async () => {
  const t = makeRuntime();
  const p = await t.invoke("orders.refund", { orderId: "o1", amount: 649, reason: "dmg" });
  await t.approvals.approve(p.approval.id, t.defaultActor);    // same identity as requester
  expect((await t.resume(p.approval.id)).error.code).toBe("APPROVAL_SELF_APPROVAL");
});

test("expiry via clock", async () => {
  const t = makeRuntime();
  const p = await t.invoke("orders.refund", { orderId: "o1", amount: 649, reason: "dmg" });
  t.clock.advance("16m");
  await t.approvals.approve(p.approval.id, fakeActor({ id: "u_manager" }));   // decide throws or...
  expect((await t.resume(p.approval.id)).error.code).toBe("APPROVAL_EXPIRED");
});

4. Redaction and error faces (SI-9, SI-10)

ts
test("model-visible output is redacted", async () => {
  const t = makeRuntime({ overrides: { "customers.get": async () => FULL_CUSTOMER } });
  const r = await t.invoke("customers.get", { id: "c1" });
  expect(r.output.paymentMethods).toBeUndefined();
  expect(r.output.email).toMatch(/\*\*\*/);
});

test("undeclared handler errors are concealed", async () => {
  const t = makeRuntime({ overrides: { "orders.search": async () => { throw new Error("pg: secret_table"); } } });
  const r = await t.invoke("orders.search", { query: "x" });
  expect(r.error.exposeToModel).toBe(false);
  expect(r.error.publicMessage).toBe("The operation failed.");
  expect(r.error.publicMessage).not.toMatch(/secret_table/);
});

5. Bounded execution (SI-11, SI-12)

ts
test("timeout cancels and reports", async () => {
  const t = makeRuntime({
    overrides: { "orders.search": ({ signal }) =>
      new Promise((_, rej) => signal!.addEventListener("abort", () => rej(signal!.reason))) },
  });
  const r = await t.invoke("orders.search", { query: "x" }, { timeoutMs: 10 });
  expect(r.status).toBe("cancelled");
  expect(r.error.code).toBe("TIMEOUT");
});

test("writes are not auto-retried", async () => {
  let calls = 0;
  const t = makeRuntime({ overrides: { "orders.refund": async () => { calls++; throw new CapabilityError({ code: "EXECUTION_FAILED", retryable: true, /* ... */ }); } } });
  await t.invoke("orders.refund", { orderId: "o1", amount: 100, reason: "dmg" });
  expect(calls).toBe(1);                                        // SI-11: no retry without idempotent+config
});

Integration mode

Drop overrides to execute real handlers against a test database — same suites, real effects. Recommended split: governance suites with stubs on every PR; a smaller integration suite (one read, one approval-gated write — mirroring the reference app) on merge.

What not to test here

Model behavior ("does GPT pick the right tool") is prompt engineering, not governance — evaluate it in your eval harness. This layer's promise is narrower and testable: whatever the model asks for, these are the outcomes.

Independent community project — not affiliated with or endorsed by the oRPC maintainers.