Testing - agbossjs/agboss GitHub Wiki
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.
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.
- 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 });.callslists 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
fetchornode:http(s)request to a host not inallowNetworkfails 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.
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 withtest.not-a-route; an agent scripted withrouteToor a router withreplyWithfails withtest.wrong-script.
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 }.
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.
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 (Selfforself); -
app.agent(X).replyWith(task)→ its answer (its model, prompt and tools; no flow, no approval pause).
| 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 ….
- Real components get
onStartwhen the app is built andonStopafter 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 (unitsms,s,m,h,d): the per-day limit (limits.perDay.cost) starts a new UTC day.
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.
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.
Unit tests of a single class stay plain: toolOf(new Tool(fakes)), mcpServerStub(Server, handlers),
fakes from @langchain/core/utils/testing.
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),
);-
Nested workflows run fully: a child workflow is not mocked as a whole; script its agents
(
mockLlmof a child's agent works in the parent's test). -
Side effects outside the container: code that calls
fetchdirectly is blocked like any live call and cannot be replaced withmockOf— put side effects in@Injectableservices. -
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).
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);
});-
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(dirdefaults to__snapshots__under the working directory). Commit the cassette. -
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.