What Can We Test Without Calling a Model?
An agent framework can run our tools, but a test model and a real model answer different questions. Build contract checks before spending time on live evaluations.
13 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
A Test Double Gives Us Control Over One Boundary
BOTIE
The useful idea is control. Supply a known response at the model boundary and check the application’s reaction. Replacing that boundary deliberately removes evidence about the real model, so integration tests and task evaluations still have work to do.
Sources for this history
The Model Changes Its Wording, but Our Bug Is in the Code
We rename a tool argument and accidentally pass the wrong asset to the manual service. Running the real model ten times will not make that wiring clearer. We need a test that supplies a known call and checks exactly what our program does with it.
Then we need a different kind of test: will the intended model choose the correct tool on an unfamiliar request? Both matter. Keeping them separate makes a failed test much easier to investigate.
These are invented, recorded examples. The browser runs your construction without calling a language model. Passing them checks the surrounding program, not the quality of a live model.
Step 1 of 2
Choose the test for the question
Each test controls different parts of the system.
| Question | Useful test | What it leaves open |
|---|---|---|
| Did our code pass P7 to the service? | Known model call; fake service | Whether the real model chooses it |
| Does the provider accept our tool schema? | Provider integration test | Quality across the task population |
| Does the agent solve maintenance requests? | Task evaluation with the intended configuration | Behaviour outside the tested cases |
Did our code pass P7 to the service?
- Useful test
- Known model call; fake service
- What it leaves open
- Whether the real model chooses it
Does the provider accept our tool schema?
- Useful test
- Provider integration test
- What it leaves open
- Quality across the task population
Does the agent solve maintenance requests?
- Useful test
- Task evaluation with the intended configuration
- What it leaves open
- Behaviour outside the tested cases
Step 2 of 2
Arrival order changes
The expected list is A, B. The observed list arrives B, A.
| Case | Expected | Observed |
|---|---|---|
| A | read_manual(P7) | read_manual(P7) |
| B | read_manual(P8) | read_manual(P9) |
A
- Expected
- read_manual(P7)
- Observed
- read_manual(P7)
B
- Expected
- read_manual(P8)
- Observed
- read_manual(P9)
Identity before counting
Matching and counting are different operations. Inspect the matched rows before interpreting the total.
Did each request reach the intended tool?
Join expected and observed calls by case ID. Keep missing runs visible.
BOTIE
Pass, fail and missing counts
{
"passed": 2,
"failed": 0,
"missing": 0
}Follow the operations
1. Expected single calls
[ { "id": "a", "tool": "read", "asset": "P7" }, { "id": "b", "tool": "read", "asset": "P8" } ]2. Observed single calls
[ { "id": "b", "tool": "read", "asset": "P8" }, { "id": "a", "tool": "read", "asset": "P7" } ]3. Match observed calls to test cases
[ { "id": "a", "passed": true }, { "id": "b", "passed": true } ]4. Count passes, failures and missing runs
{ "passed": 2, "failed": 0, "missing": 0 }5. Result
{ "passed": 2, "failed": 0, "missing": 0 }
These are invented, recorded examples. The browser runs your construction without calling a language model. Passing them checks the surrounding program, not the quality of a live model.
1. Draw the boundary you are replacing
Our program sends messages and tool definitions to a model, receives a proposed call, validates it, and invokes a service. A test double can replace the model response. A separate fake can replace the service.
Replacing the model alone does not make tool effects harmless: framework test models may still call real tool functions. Inject isolated dependencies and block unexpected network requests in the test environment.
Choose a known fixture at each external boundary. Never let a local test accidentally create a production work order.
2. Assert the argument that matters
For this exercise, each case expects exactly one manual-read call. We compare its tool name and asset. In a real test, also inspect identity, authorisation decisions and the returned observation. A correct-looking final answer cannot establish these facts.
If several valid routes can solve the task, do not require one exact sequence unless that sequence is itself a requirement. Strict trajectory equality can fail useful alternatives.
Our single-call contract is intentionally narrow. A full trajectory requires call IDs, ordering constraints and effect checks.
Compare named fields, not sentences
This test inspects one recorded call. It does not ask a model to grade its own wording.
wanted = {"tool": "read_manual", "asset": "P7"}
got = {"tool": "read_manual", "asset": "P8"}
print(wanted["tool"] == got["tool"])
print(wanted["asset"] == got["asset"])True
FalseChoosing the right tool did not prevent it from receiving the wrong asset.
3. Run a Pydantic AI test with the network disabled
TestModel is a procedural stand-in, not a small language model. The following smoke test has no tools and returns a supplied string. It establishes that the application can run an agent and read its output through the framework API.
For a tool test, override the model on your existing agent and supply fake dependencies. TestModel normally exercises tools with generated arguments; FunctionModel gives finer control when a particular sequence matters. Read the recorded calls rather than assuming a final string proves the calls were correct.
The recipe runs in your own Python environment. The browser challenge below tests a plain-Python report over recorded calls.
Run in your own environment
A provider-free Pydantic AI smoke test
No provider credentials are needed for this example. No external tools are registered.
Create a separate virtual environment and install pydantic-ai using python -m pip install pydantic-ai.
Record the installed version in your project lockfile. This recipe was checked against the documentation on 19 October 2026.
from pydantic_ai import Agent, models
from pydantic_ai.models.test import TestModel
models.ALLOW_MODEL_REQUESTS = False
agent = Agent(TestModel(custom_output_text="Manual lookup needs an asset."))
result = agent.run_sync("Which manual applies?")
assert result.output == "Manual lookup needs an asset."
print(result.output)A passing assertion checks this framework boundary. It says nothing about the choice a production model would make.
Keep ALLOW_MODEL_REQUESTS disabled for this test suite and replace any real service dependencies before adding tools.
Pydantic AI testing. Documentation checked .
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
Is the test isolated?
Use the information below.
A TestModel replaces the LLM. The tool still calls the production work-order service.4. Keep the same separation in LangChain and LangGraph
Use a mock chat model when testing application reactions to a known response. If the agent uses LangGraph persistence, use an isolated checkpointer and a distinct thread for each test unless the test deliberately exercises resumption. Otherwise an earlier run can supply state that hides a missing step.
A small provider integration suite can then exercise tool schema compatibility and message formatting. A task evaluation tests the assembled system on representative requests. Record provider errors separately from scored answers.
The earlier framework lessons show the actual agent and graph APIs. These testing boundaries apply whichever framework owns the loop.
5. A missing result is not a passing test
Expected cases form the denominator. Match observed calls by ID and mark absent results as missing. Reject duplicate IDs because there is no unambiguous call to compare under this one-call-per-case contract.
The same rule applies when workers finish out of order. Joining two lists by their positions can compare P7’s expected call to P8’s actual call and create misleading failures.
An empty batch reports zero passes, zero failures and zero missing results. It provides no acceptance evidence.
6. Add the broken case before repairing the wiring
Keep the wrong-asset call as a failing regression fixture. Change the application mapping, rerun that fixture, then try another asset and a missing result. This tests the repair against the rule rather than only the particular name P7.
Finally run the intended model on new task cases. A deterministic test can establish that a valid call is handled properly; it cannot establish how often the model proposes one.
Keep unit, integration and task-evaluation results separately named in CI so a reader knows what passed.
Check yourself
Questions on Parts 1 to 6
Question 1 of 2
What report is correct?
Use the information below.
Expected cases: A and B. Observed: only A, which matches.Try it with Botie
Build with Botie
Write and run Python