Fundamentals

Testing

Test your capabilities with fast unit tests and optional E2E runs.

Quick start

A project from create-routecraft runs its tests with bun run test (its test script runs bun test), and each capability keeps its test beside it: capabilities/hello-world/route.bun.test.ts next to route.ts. The scaffolded test has this shape, which is the one to copy:

import { afterEach, describe, expect, mock, test } from "bun:test";
import { http } from "@routecraft/routecraft";
import { mcp } from "@routecraft/ai";
import { mockAdapter, testContext, type TestContext } from "@routecraft/testing";
import capabilities from "./route";

describe("hello-world", () => {
  let t: TestContext;

  afterEach(async () => {
    if (t) await t.stop();
  });

  test("greets the user by name", async () => {
    const httpMock = mockAdapter(http, {
      send: async () => ({
        status: 200,
        headers: {},
        body: { id: 1, name: "Leanne Graham" },
        url: "https://jsonplaceholder.typicode.com/users/1",
      }),
    });

    t = await testContext({ fn: mock })
      .override(mockAdapter(mcp, { source: [] }))
      .override(httpMock)
      .routes(capabilities)
      .build();
    await t.test();

    expect(httpMock.calls.send).toHaveLength(1);
    const logged = (t.logger.info as ReturnType<typeof mock>).mock.calls.find(
      (call) => call[1] === "LogAdapter output",
    );
    expect(logged?.[0].body).toBe("Hello, Leanne Graham!");
  });
});

testContext() builds a context around the capabilities under test, .override() stands a mock in for every http() and mcp() call site, and t.test() runs the full lifecycle: start, wait for the routes to be ready, drain, stop. Assert after it returns. Mocking external adapters covers mockAdapter() in full.

Tip: t.logger is a spy logger. By default it uses a built-in runner-agnostic spy that records calls in t.logger.info.mock.calls, so it works under bun test, Vitest, and node:test with no extra wiring. Pass your runner's mock factory ({ fn: mock } with bun:test, { fn: vi.fn } with Vitest) when you want native matcher support like expect(t.logger.info).toHaveBeenCalledWith(...).

Using Vitest instead

@routecraft/testing works under any runner. To use Vitest, add it as a dev dependency, import from vitest instead of bun:test, and point the test script at it:

{
  "scripts": {
    "test": "vitest run"
  }
}

Vitest needs no configuration file for this. Add a vitest.config.mjs at the project root when you want settings such as coverage:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    coverage: { provider: "v8", reporter: ["text", "lcov"] },
  },
});

Some examples below import from vitest. Under bun test, import the same names from bun:test.

Route lifecycle in tests

Use testContext() and t.test() for the recommended flow. t.test() runs start, waits for all routes to be ready, drains and stops, so you don't need manual timeouts for direct and simple routes:

import { testContext } from "@routecraft/testing";
import capabilities from "./route";

const t = await testContext().routes(capabilities).build();
await t.test();
// Assert here: mocks, t.errors, t.ctx.getStore(), etc.

Checklist:

  • Prefer await t.test() for full lifecycle; assert after it returns.
  • Use t.ctx when you need the raw context (e.g. t.ctx.start(), t.ctx.getStore()).
  • Use t.logger to assert on log calls (e.g. t.logger.info.mock.calls, or expect(t.logger.info).toHaveBeenCalled() when built with { fn: vi.fn }).
  • For custom timing (e.g. timer routes), use t.ctx.start() and t.ctx.stop() manually.
  • Restore mocks in beforeEach/afterEach.

Mocking external adapters

When your route uses an adapter that talks to an external system, such as mail(), http() or mcp(), you want to test your logic, not re-test the adapter. Two things you should not do:

  • Mock the adapter's underlying library (imapflow, nodemailer, globalThis.fetch). This couples your tests to our implementation choices; the day we swap a library, your tests break even though nothing in the public contract changed.
  • Restructure the route to inject test adapters. The route you run in production should be the route you test.

Use mockAdapter() and testContext().override() instead. You import the factory (mail, http, ...), describe how it should behave in the test, and register the mock on the test context. The route stays unchanged.

import { afterEach, expect, it } from "bun:test";
import { mail } from "@routecraft/routecraft";
import {
  mockAdapter,
  sourceMessage,
  testContext,
  type TestContext,
} from "@routecraft/testing";
import route from "./route";

const mailMock = mockAdapter(mail, {
  // Source role: feeds the .from(mail(...)) call site. The mail source puts the
  // payload on `body` and the envelope on `routecraft.mail.*` headers, so wrap
  // each fixture with `sourceMessage(body, headers)` to reproduce that split.
  source: [
    sourceMessage(
      { text: "lunch?" },
      { "routecraft.mail.uid": 1, "routecraft.mail.from": "friend@co.com", "routecraft.mail.subject": "lunch?" },
    ),
    sourceMessage(
      { text: "buy now" },
      { "routecraft.mail.uid": 2, "routecraft.mail.from": "spam@x.com", "routecraft.mail.subject": "URGENT BUY NOW" },
    ),
  ],
  // Destination role: stands in for every .to(mail(...)) and .enrich(mail(...))
  // call in the route. Use `args` (what was passed to mail()) to discriminate.
  send: async (exchange, { args }) => {
    if (args[0]?.action === "move") return { moved: true };
    return { messageId: "<fake>" };
  },
});

let t: TestContext;
afterEach(async () => { if (t) await t.stop(); });

it("moves spam and replies to friends", async () => {
  t = await testContext().override(mailMock).routes(route).build();
  await t.test();

  expect(mailMock.calls.source).toHaveLength(1);
  expect(mailMock.calls.send).toHaveLength(2);
  // Fixtures are dispatched concurrently, so assert by content, not by the
  // order the sends happened to land in.
  const moved = mailMock.calls.send.find((c) => c.args[0]?.action === "move");
  const replied = mailMock.calls.send.find((c) => c.args[0]?.action !== "move");
  expect(moved).toBeDefined(); // spam was archived
  expect(replied?.exchange.body.to).toBe("friend@co.com"); // friend got a reply
});

When to use what

You want to...Use
Test a route that calls an external system (IMAP/SMTP, HTTP, MCP)mockAdapter(factory, { source, send }) + .override()
Test that an in-process destination was invokedspy() (see below)
Drive a route's input manually from the testsimple(value) or a callableSource
Assert on logger callst.logger.info / t.logger.warn (spies, see the Quick start tip)
Check a validation boundary honours an async schemathenableSchema({ issues }) / thenableSchema({ value })

Anatomy of a mock

  • source: an array of fixtures, a (sync or async) iterable, or (args) => iterable. Each item is delivered to .from(factory(...)) as one exchange. For polling sources this models one poll cycle.
  • send: (exchange, { args }) => result. Called for every .to(factory(...)), .enrich(factory(...)), and .tap(factory(...)) in the route. What happens to result follows the step's slot resolution: a fetch-resolved step (.enrich(), or a fetch-only enricher such as http() in .to()) uses it as the fetched value (replacing the body by default), while a send-resolved .to() discards it: a mocked send stays a void send, and the body continues unchanged. Accepts a runner mock (mock() from bun:test, vi.fn() from Vitest) too, so mockResolvedValueOnce / mockRejectedValueOnce chains work as expected.
  • args: whatever the route passed to the factory at that call site. Use it to discriminate when the same factory is used in multiple positions (e.g. mail("INBOX") as source vs mail({ action: "move" }) as destination).

Inspecting recorded calls

mailMock.calls.source   // [{ args, yielded }]   one per subscribe call
mailMock.calls.send     // [{ args, exchange, result }]
                         //   exchange = { id, body, headers } snapshot
                         //   result   = whatever the send handler returned

Failed sends (where the handler throws) are still recorded; result stays undefined and the error surfaces through the route the same way a real adapter failure would. Check t.errors afterwards.

What mocks do not preserve

A mock stands in for the adapter's send / subscribe, nothing more. These side effects of the real adapter are not reproduced:

  • Metadata headers from getMetadata. Real adapters like http() stamp headers on the exchange (status, response headers, etc.) via their getMetadata method. The override path skips this, since mock results are typically primitives with no adapter-specific shape.
  • Tracking ids and correlation data that specific adapters attach to exchanges.
  • Timing and I/O side effects (connection pooling, retries, backoff) that the real adapter performs around the call.

If your route asserts on something the real adapter would have added at a fetch-resolved call site, shape your mock's send return value to match the body the real adapter would have produced and assert on exchange.body downstream. (At a send-resolved .to() the result is discarded, matching the real adapter's void send; receipt headers from the real adapter's SendContext are not reproduced either.) The mock cannot mutate the incoming exchange (exchanges are immutable: frozen wrapper, headers, and principal), and the override path bypasses getMetadata, so any metadata-style fields the real adapter would have stamped onto headers must instead be carried through the result body in the mock.

import { http } from "@routecraft/routecraft";
import { mockAdapter } from "@routecraft/testing";

const httpMock = mockAdapter(http, {
  send: async (_exchange) => ({
    status: 200,
    headers: {},
    body: { ok: true },
    url: "x",
  }),
});

Same factory used multiple times

One mock covers every call site of the factory. Discriminate inside send using args, or chain a runner mock for ordered responses (vi.fn() here; mock() from bun:test has the same methods):

const httpMock = mockAdapter(http, {
  send: vi.fn()
    .mockResolvedValueOnce({ status: 200, body: { ok: true } })
    .mockRejectedValueOnce(new Error("429 Too Many Requests"))
    .mockResolvedValue({ status: 200, body: { ok: true } }),
});

What you can pass as the target

mockAdapter(target, behavior) accepts two kinds of target:

  • A factory function, e.g. mockAdapter(mail, ...), mockAdapter(http, ...). Matches every adapter instance that factory produced. Requires the factory to stamp its adapters via tagAdapter() internally. The first-party factories that do this today are mail(), http(), mcp(), file(), csv(), json() (file mode), jsonl() (every return path), and html() (file mode). The transformer-only return paths of json() and html() are intentionally not tagged because the override resolver only fires on subscribe/send/fetch.
  • An adapter class, e.g. mockAdapter(SomeAdapterClass, ...). Matches any adapter whose constructor === target. Works for every adapter, first-party or third-party, without opt-in tagging. Useful when a third-party adapter exports its class but not a tagged factory, or when you want to mock a specific role of a multi-role factory.

The factory form is nicer when the factory covers a single role. The class form is required when the factory has no tag or when you want to target one specific role of a multi-role factory. Both forms can be mixed on the same testContext().

In-process adapters like direct(), simple(), log(), and noop() do not talk to an external system. Use spy() or drive inputs directly for those.

Common testing patterns

Using the spy adapter

The spy() adapter is purpose-built for testing. It records all interactions and provides convenient assertion methods. It carries a send face (void, for .to() / .tap()), a fetch face that returns the current body (so a bare .enrich(spy()) observes without changing the body), and a process face:

import { spy } from "@routecraft/testing";

const spyAdapter = spy();

// Available properties:
spyAdapter.received         // Array of exchanges received
spyAdapter.calls.send       // Number of send() calls
spyAdapter.calls.process    // Number of process() calls (if used as processor)
spyAdapter.calls.enrich     // Number of fetch() calls (if used as enricher)

// Methods:
spyAdapter.reset()          // Clear all recorded data
spyAdapter.lastReceived()   // Get the most recent exchange
spyAdapter.receivedBodies() // Get array of just the body values

Spy on destinations to assert outputs

import { testContext, spy } from "@routecraft/testing";
import { craft, simple } from "@routecraft/routecraft";
import { expect, test } from "vitest";

const spyAdapter = spy();

const route = craft().id("out").from(simple("payload")).to(spyAdapter);
const t = await testContext().routes(route).build();
await t.test();

expect(spyAdapter.received).toHaveLength(1);
expect(spyAdapter.received[0].body).toBe("payload");
expect(spyAdapter.calls.send).toBe(1);

Assert on log output

testContext().build() returns a test context whose t.logger is a spy. Use it to assert on pino log calls (e.g. from .to(log()) or adapter logging):

import { testContext } from "@routecraft/testing";
import { craft, simple, log } from "@routecraft/routecraft";
import { expect, test } from "vitest";

test('logs messages correctly', async () => {
  const route = craft()
    .id("log-test")
    .from(simple("Hello, World!"))
    .to(log());

  const t = await testContext().routes(route).build();
  await t.test();

  expect(t.logger.info.mock.calls.length).toBeGreaterThan(0);
  const loggedMessage = t.logger.info.mock.calls[0][1];
  expect(loggedMessage).toContain("Hello, World!");
});

Tip: Use spy() adapter instead of log() when you need more control over assertions.

Filter logs by route id (from LogAdapter headers):

const infoCalls = t.logger.info.mock.calls.map((c) => c[0]);
const logsForRoute = infoCalls.filter(
  (arg) => typeof arg === "object" && arg != null && "headers" in arg && (arg as any).headers?.["routecraft.route"] === "channel-adapter-1",
);

Test custom sources that await the final exchange

import { testContext, spy } from "@routecraft/testing";
import { craft } from "@routecraft/routecraft";

let observed: any;
const spyAdapter = spy();

const route = craft()
  .id("return-final")
  .from({
    subscribe: async (sub) => {
      sub.ready();
      try {
        // emit() resolves with the fully processed exchange
        observed = await sub.emit({ message: "hello" });
      } finally {
        sub.complete();
      }
    },
  })
  .transform((body: string) => body.toUpperCase())
  .to(spyAdapter)
  .transform((body: string) => `${body}!`);

const t = await testContext().routes(route).build();
await t.test();

expect(observed.body).toBe("HELLO!");
expect(spyAdapter.received[0].body).toBe("HELLO!");

Timers and long-running routes

Use .routesReadyTimeout(ms) to give timer or slow-starting routes more time to become ready before t.test() proceeds:

const t = await testContext()
  .routesReadyTimeout(500)
  .routes(timerRoute)
  .build();
await t.test();

For cases where you need precise control over the run window, drive the lifecycle manually:

const t = await testContext().routes(timerRoute).build();
const execution = t.ctx.start();
await new Promise((r) => setTimeout(r, 150));
await t.ctx.stop();
await execution;

Assertion patterns

Spy adapter assertions

// Basic assertions
expect(spyAdapter.received).toHaveLength(3);
expect(spyAdapter.calls.send).toBe(3);

// Body content validation
expect(spyAdapter.receivedBodies()).toEqual(['msg1', 'msg2', 'msg3']);
expect(spyAdapter.lastReceived().body).toBe('final-message');

// Header validation
expect(spyAdapter.received[0].headers['routecraft.route']).toBe('my-route');

// Complex object validation
const lastExchange = spyAdapter.lastReceived();
expect(lastExchange.body).toHaveProperty("original");
expect(lastExchange.body).toHaveProperty("additional");

Using spy as processor or enricher

// Test processing behavior
const processSpy = spy();
const route = craft()
  .id("test-process")
  .from(simple("input"))
  .process(processSpy) // Use spy as processor
  .to(spy());

const t = await testContext().routes(route).build();
await t.test();
expect(processSpy.calls.process).toBe(1);
expect(processSpy.received[0].body).toBe("input");

// Test enrichment behavior  
const enrichSpy = spy();
const route2 = craft()
  .id("test-enrich")
  .from(simple({ name: "John" }))
  .enrich(enrichSpy) // Use spy as enricher
  .to(spy());

const t2 = await testContext().routes(route2).build();
await t2.test();
expect(enrichSpy.calls.enrich).toBe(1);

Route validation

// Ensure a route id is set after build
const r = craft().id("x").from(simple("y")).to(spy());
expect(r.build()[0].id).toBe("x");

Multiple spies in one route

const transformSpy = spy();
const destinationSpy = spy();

const route = craft()
  .id("multi-spy")
  .from(simple("start"))
  .process(transformSpy)
  .to(destinationSpy);

const t = await testContext().routes(route).build();
await t.test();

// Verify the pipeline
expect(transformSpy.calls.process).toBe(1);
expect(destinationSpy.calls.send).toBe(1);
expect(transformSpy.received[0].body).toBe("start");
expect(destinationSpy.received[0].body).toBe("start"); // Assuming spy processes pass-through

Headers and correlation

const captured: string[] = [];
// inside a .process/.tap
captured.push(exchange.headers["routecraft.correlation_id"] as string);
expect(new Set(captured).size).toBe(1);

Smoke-test the whole project

Tests stub the external systems. To boot the real project once and exit when the first exchange finishes, which suits a CI step:

bunx craft start --once --timeout 30s

A first exchange that failed exits non-zero. The CLI reference covers what --once waits for.

Troubleshooting

  • Hanging tests: use await t.test() for standard flows, or ensure you await t.ctx.stop() and then await execution when driving lifecycle manually.
  • Flaky timers: prefer fake timers or increase the wait to 100 to 200ms.
  • No logs captured: ensure your route includes .to(log()) and assert on t.logger.info (or t.logger.warn / t.logger.debug) after await t.test().
  • Errors in tests: check t.errors after await t.test(); Routecraft errors are collected automatically.

Error handling

Catch pipeline errors with .error(), the failure paths worth a test of their own.

Errors

Every error code, useful when asserting on t.errors.

CLI

craft start --once and every other command and flag.

Previous
Adapters