Mocking a Tool's Return Value to Test the Next Model Turn
9 min read · updated August 11, 2026
You want to test what your code does with a tool result: how it is serialised, which id it is attached to, whether it is truncated, what the model is shown next. None of that requires the tool to be real, and none of it requires reading the model’s reply.
Pick the boundary deliberately
There are three places to fake a tool and they test different amounts of your system.
- The function. Replace
getOrderwith a stub returning a fixed object. Fastest, and it skips your serialisation code only if the serialisation lives inside the tool — which is the first thing to check. - The transport. Let the real tool run and intercept its HTTP call. Slower, and it exercises the tool’s own parsing and error handling, which is often where the interesting shape comes from.
- The registry. Swap the whole tools object handed to the loop. This is the one to use when the thing under test is the loop, because it needs no knowledge of any individual tool.
The mistake is picking the function boundary and then believing you have tested the round trip. If the tool returns a rich object and something downstream flattens it, a function-level stub returning the already-flattened shape tests nothing at all.
A useful rule for choosing: fake at the boundary immediately below the code you are asserting on, and no lower. If the assertion is about the message array, fake the registry. If it is about how a tool’s error response becomes a tool result, fake the transport so the tool’s own error handling runs. Faking two layers down and then asserting two layers up leaves untested code in between, which is precisely where the flattening, the truncation and the redaction all live.
Injecting the tool
Dependency injection beats module mocking here for a reason that shows up on the second test rather than the first: two cases in one file usually want two different return values, and a per-test object is trivially two objects.
import { describe, it, expect, vi } from "vitest";
const scriptedModel = (responses: any[]) => {
const seen: any[][] = [];
let i = 0;
const fn = async (messages: any[]) => {
seen.push(structuredClone(messages)); // snapshot, not a live reference
return responses[i++];
};
return { fn, seen };
};
it("shows the model the order it asked for", async () => {
const get_order = vi.fn(async () => ({
order_id: "55219", status: "delivered",
delivered_at: "2026-07-30T09:12:00Z", items: 3,
}));
const { fn, seen } = scriptedModel([
toolCallResponse("get_order", { order_id: "55219" }, "call_1"),
textResponse("It arrived on 30 July."),
]);
await runAgent("where is order 55219?", { model: fn, tools: { get_order } });
const secondRequest = seen[1];
const toolMsg = secondRequest.find((m) => m.role === "tool");
expect(toolMsg.tool_call_id).toBe("call_1");
expect(JSON.parse(toolMsg.content)).toEqual({
order_id: "55219", status: "delivered",
delivered_at: "2026-07-30T09:12:00Z", items: 3,
});
});structuredClone on capture is not fussiness. The loop pushes onto the same messages array it passed in, so a stored reference shows you the array as it looked at the end of the run, and every assertion about “what the model saw on turn two” quietly becomes an assertion about turn five. This is the single most common way a test in this shape lies.
Assert on the outgoing request
The model’s second reply is generated text and you did not write it. The second request is entirely yours: the message ordering, the id threading, the serialisation, the truncation. Assert there.
Three things are worth checking on that request. That the tool result carries the correct tool_call_id — mismatched ids are the failure that turns into a provider 400 rather than a wrong answer. That the assistant message containing the tool call is still present and still before the result; both APIs reject a result whose call is missing. And that the content is what the tool returned, not a stringified [object Object], which is what happens when somebody replaces a JSON.stringify with template interpolation.
There is a fourth check that only matters once you have more than one tool call in flight: that the results appear in an order the API accepts. Chat Completions wants one role: "tool" message per call, all of them after the assistant message that requested them; the Messages API wants all the tool_result blocks inside a single user message. A loop that emits one user message per result on the second shape produces a request that is rejected, and the only place a unit test can see that is in the outgoing array. The parallel case goes into this properly.
What you should not assert on is the model’s second reply. It is tempting — the fake returns a fixed string, so the assertion passes trivially — but a passing assertion on a value your own fake produced tests only that the fake was wired up. If the point is that a particular tool result leads to a particular kind of answer, that is an evaluation with a real model and a scored rubric, not a unit test, and it belongs in a suite built for it.
The serialisation is the bug
Tool results are the largest thing you insert into context and the part most likely to be quietly mangled. A fixed return value lets you test the handling that only appears with awkward values:
- Empty results. A search returning
[]. Serialised as"[]"the model usually says it found nothing; serialised as an empty string it often invents. Assert which one you send. - Oversized results. Return 200 rows and assert your truncation fired — that the content is under your cap and that it says it was truncated. A silently cut result is a model confidently summarising the first fifth of the data. How much of the context tool results are entitled to is the wider question.
- Nulls and dates. A
Dateobject throughJSON.stringifybecomes an ISO string; through a template literal it becomes a locale-dependent sentence. One of those is reproducible across machines. - Errors. Have the stub reject, and assert the loop sent a result rather than propagating the throw. A tool that raises must still produce a tool message, or the next request is invalid.
- Secrets. Return an object containing an internal token field and assert it is absent from the serialised content. Redaction that lives only in the logging layer does not protect the prompt.
When the tool is an HTTP call
If you want the tool’s own code in the test, intercept below it. In JavaScript that is Mock Service Worker, whose Node entry point and request handlers are documented by the msw project; the current major version imports http and HttpResponse from msw and setupServer from msw/node.
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("https://orders.internal/v1/orders/55219", () =>
HttpResponse.json({ order_id: "55219", status: "delivered", items: 3 }),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it("surfaces a 404 as a tool error, not a crash", async () => {
server.use(
http.get("https://orders.internal/v1/orders/55219", () =>
HttpResponse.json({ error: "not_found" }, { status: 404 }),
),
);
// ...run the loop and assert a tool message was still produced
});onUnhandledRequest: "error" is the setting worth turning on immediately. Without it a test that accidentally reaches a real endpoint — including the model provider — passes, slowly, and costs money. With it, any unmocked call fails loudly, which is also how you discover that your tool calls a second service nobody documented.
The Python equivalents work the same way at a different seam: responses and requests-mock intercept at the HTTP client, and monkeypatch or a plain argument swaps the callable. Whichever you use, keep the fixed return value in a file next to the test rather than inline once it is more than a few lines — the same value usually wants to drive three tests, and a fixture format that survives review is worth deciding on before you have twenty of them.