Testing - agbossjs/agboss GitHub Wiki

Testing — agboss/testing

Test a workflow without models and without the network, from its first day: the real container, components and graph rules, with everything external replaced automatically. Vitest (≥ 3.2) is an optional peer dependency.

// vitest.config.ts — one line registers the matchers
import { defineConfig } from "vitest/config";

export default defineConfig({ test: { setupFiles: ["agboss/testing/setup"] } });
import { replyWith, callTool, routeTo, testWith } from "agboss/testing";
import { expect } from "vitest";

const test = testWith(JobScout);

test("saving waits for approval; after it the turn ends (#100)", async ({ app, mockLlm, mcpOf }) => {
  mockLlm(MainRouter).thenReturn(routeTo(Shortlist)); // one router call: after the decision the turn ends
  mockLlm(Shortlist).thenReturn(callTool(SaveShortlist, { jobs: [job] }), replyWith("Saved 1 job."));
  mcpOf(ShortlistServer).thenReturn({
    read_text_file: () => Promise.reject(new Error("ENOENT")),
    write_file: () => Promise.resolve({ content: "ok" }),
  });

  const paused = await app.execute(ChatWorkflowStart, { text: "save the first job" });
  const done = await app.resume(paused.thread, { approved: true, by: "dana" });

  expect(paused).toHavePausedAt(Shortlist);
  expect(done).toFinishWith(ChatWorkflowFinish, { text: "Saved 1 job." });
  expect(mockLlm(MainRouter).requests).toHaveLength(1);
});

Components are referenced by class everywhere; strings only for data.

testWith(Workflow, { real?, allowNetwork? })

A Vitest test whose fixtures are:

Fixture What it is
app the workflow's App (execute, resume, cancel, close) — built on first use, so mockOf in the test body takes effect; plus app.clock and slices
mockLlm the script of an agent or a router: mockLlm(Scout).thenReturn(…)
mockOf a typed mock injected instead of a component
mcpOf the stub of an MCP server
recoverApp await recoverApp() closes the current app (its onStop hooks run, as on a real stop) and returns a new one over the same test state (checkpoints, ledger, clock, ids); the closed app fails every call with test.app-closed

The workflow is assembled when the test starts: an assembly error fails the test before it runs.

Everything external is replaced

  • Models → scripted (the model gateway is replaced). An agent or router without a script is a blocked live call. Guards nobody scripted pass; conversation compaction nobody scripted writes a fixed summary.
  • MCP servers → stubs: mcpOf(Server).thenReturn({ <server tool>: handler }); .calls lists what the server got. A server tool without a handler fails the test with the server's and the tool's name. real: [Server] connects that server for real (an integration test).
  • Network → blocked: any fetch or node:http(s) request to a host not in allowNetwork fails before it leaves the process. allowNetwork: ["localhost"] covers every loopback address (127.0.0.1, ::1).

A blocked call fails with test.live-call-blocked and a message naming the class, the server tool or the host — even when the run swallowed it (a tool error the agent read): the call that ran it (app.execute, app.resume, a slice) throws it.

Scripted models

mockLlm(Scout).thenReturn(callTool(GreenhouseJobs, { query: "TypeScript" }), replyWith("3 jobs found"));
mockLlm(MainRouter).thenReturn(routeTo(Scout), routeTo(ChatWorkflowFinish));
mockLlm(Writer).thenReturn(replyWith("first")).thenReturnAlways(replyWith("again"));
mockLlm(Reviewer).thenReturn(failWith(ModelFailure.Timeout));
Turn Meaning
replyWith(text, { cost?, truncated? }) the agent's model answers; cost: usd(0.002) is priced as output tokens
callTool(Tool, args) the agent's model calls a tool — args typed by the tool's input DTO
routeTo(Target, { reason?, confidence?, cost? }) the router chooses a route: a node class, a named node, or Self
failWith(ModelFailure.Timeout | RateLimited | ServerError | NetworkError) (the same enum as agboss/models) the call fails: an agent fails the run at the agent, a router with router.failed
  • By position, never by call order across components: a component's n-th model call gets its n-th turn, counted over the whole test (every run and every app of it). LangGraph never repeats a checkpointed call, so a run resumed after recoverApp() continues the same script — "a paused run survives a restart".
  • A script shorter than the run fails with test.script-exhausted (script exhausted for Writer: 1 turn scripted, asked for #2) — or end it with .thenReturnAlways(turn).
  • routeTo(X) for a node that is not among the router's routes fails with test.not-a-route; an agent scripted with routeTo or a router with replyWith fails with test.wrong-script.

What a component sent its model

mockLlm(X).requests (every request, in order), .onlyRequest (fails unless exactly one), .lastRequest, .toolCalls. An agent's request is { kind: "chat", system, input, messages } (messages: { role, text } — tool results have role tool); a router's is { kind: "decision", input, options, routes, instructions }; a decision judge's { kind: "decide", input, state, questions }.

mockOf(Class)

A typed mock (vi.fn() per method), injected instead of the class; inside the test mockOf(X) returns the same instance the app uses:

mockOf(OrderBook).statusOf.mockResolvedValue("order lost");
// … run …
expect(mockOf(OrderBook).statusOf.mock.calls).toEqual([["7"]]);

Call it before the first app.execute (after it is a clear error). A class the workflow does not use (not a provider, a tool or a knowledge base of it) is an error too. Mocks have no lifecycle hooks.

Slices

One component of the real app on its own:

  • app.tool(X).invoke(input) — the tool with its real (or mocked) dependencies → ToolResult;
  • app.router(X).routeTo(text) → the node it chooses (Self for self);
  • app.agent(X).replyWith(task) → its answer (its model, prompt and tools; no flow, no approval pause).

Matchers

Matcher On Passes when
toFollowPath([Start, Router, Agent, …]) a run result the visited nodes are exactly these, in order
toFinishWith(Finish, data?) a run result it finished there (with that output)
toHavePausedAt(Agent) a run result it waits for a human in that agent
toFailWith({ code?, node? }) an error (.rejects) a code along its causes (router.failed, limits.perDay.cost, graph.dead-end, test.…) and the node it names
toHaveCalledTools([Tool, …]) mockLlm(Agent) its model called exactly these tools, in order
toHaveBeenAskedWith({ input?, system?, … }) mockLlm(X) some request has these parts (strings as substrings)

Failure messages name the classes: expected the path ChatStart → MainRouter → Support, it was ….

Lifecycle and isolation

  • Real components get onStart when the app is built and onStop after the test — also when the test failed (App → Lifecycle hooks).
  • Per test: its own checkpoints, spend ledger, Terns and paused runs (in memory), deterministic ids (thread-1, run-1, …) and a clock fixed at the start. Two tests — even in parallel, with the same thread id — never see each other.
  • app.clock.advance("25h") moves the time (units ms, s, m, h, d): the per-day limit (limits.perDay.cost) starts a new UTC day.

Three kinds of tests

agboss create sets up three Vitest projects by file name — empty ones pass:

Project Files Models
unit *.test.ts none — scripts and fakes
integration *.integration.test.ts recorded answers (VCR), real MCP servers
suites *.suite.test.ts real models, budget-capped, scheduled

npm test runs unit + integration; the generated test:suites script (vitest run --project suites) runs the suites. A new project's workflow test (src/<workflow>/<workflow>.workflow.test.ts) scripts the main router and the first agent.

Compile-time tests

Type mistakes are tested too: a *.test.ts line marked // @ts-expect-error — <reason> must stay a compile error — tsc (in make check) fails when such a line compiles. One file per decorator family:

// @ts-expect-error — orderId is a string in the tool's input DTO
callTool(OrderStatus, { orderId: 42 });

What decorator mistakes look like in the compiler's output:

Code Message (shortened) Typical cause
TS1238 Unable to resolve signature of class decorator … @Tool({ deps }) does not fit the constructor (order, type, count)
TS1240 Unable to resolve signature of property decorator … a DTO field decorator on a field of another type (@Text() on a number)
TS1270 Decorator function return type '…' is not assignable to type '…' the class does not implement what its decorator requires (ToolHandler, WorkflowDefinition)

Examples in the framework: tests/components/compile-time.test.ts, tests/dto/types.test.ts, tests/testing/types.test.ts.

Lower level

Unit tests of a single class stay plain: toolOf(new Tool(fakes)), mcpServerStub(Server, handlers), fakes from @langchain/core/utils/testing.

Dynamic answers — .thenAnswer()

When the answer depends on the request, script a function instead of a list of turns:

mockLlm(Scout).thenAnswer((request) =>
  request.kind === "chat" && request.messages.some((line) => line.role === "tool")
    ? replyWith("Here are the jobs.")
    : callTool(GreenhouseJobs, { query: String(request.input) }),
);
mockLlm(MainRouter).thenAnswer((request) =>
  String(request.input).includes("save") ? routeTo(Shortlist) : routeTo(Scout),
);

Blind spots

  1. Nested workflows run fully: a child workflow is not mocked as a whole; script its agents (mockLlm of a child's agent works in the parent's test).
  2. Side effects outside the container: code that calls fetch directly is blocked like any live call and cannot be replaced with mockOf — put side effects in @Injectable services.
  3. The graph is tested by running it: assembly rules are checked when the test starts; the paths a run can take are proven by the runs you script (toFollowPath).

VCR — recorded runs

Instead of scripts, a test can answer from a cassette: recorded once with the real models, then replayed in every later run — in milliseconds, without a key or network.

import { expect } from "vitest";
import { VCRMode, testWith } from "agboss/testing";

const test = testWith(JobScout, {
  vcr: {
    cassetteName: "onboarding", // __snapshots__/onboarding.cassette.json
    mode: process.env.CI ? VCRMode.REPLAY : VCRMode.AUTO, // the default already
  },
});

test("the scout answers a first message", async ({ app }) => {
  const result = await app.execute(ChatWorkflowStart, { text: "Hi, I'm looking for a job" });
  expect(result).toFinishWith(ChatWorkflowFinish);
});
  1. Recording (first run, with a model key): every model call — agents, judges, compaction, routers, guards, decision judges — goes to the real model and is written to <dir>/<cassetteName>.cassette.json (dir defaults to __snapshots__ under the working directory). Commit the cassette.
  2. Replay (CI and later runs): answers come from the cassette. A call is matched by the model, the bound tools and every message; a request the cassette does not hold — a changed prompt, a new tool — fails with CassetteMissingError (test.cassette-missing): re-record it.

Modes: VCRMode.AUTO (default outside CI) records a missing cassette and replays an existing one; VCRMode.RECORD always calls the models and rewrites the file; VCRMode.REPLAY (default under CI) only replays and fails without a cassette.

⚠️ **GitHub.com Fallback** ⚠️