Most agent tests are integration tests wearing a unit test costume. If you want fast, deterministic feedback on a ReAct-style agent loop, you need a clear strategy for unit testing ReAct agent loops that isolates orchestration from model noise. The loop itself is a small state machine; treat it like one.
Below is an ordered path we use to test these loops without dragging a live LLM into CI.
1. Pin down the loop’s seam boundaries
A ReAct loop is just a state machine: call model, parse output, dispatch tool, append observation, repeat. The unit under test is the state machine, not the model weights or the tool side effects.
Define explicit interfaces before writing tests:
class LLMClient:
def chat(self, messages: list[dict]) -> str: ...
class Tool:
def __call__(self, **kwargs) -> str: ...
Your react_loop function should depend on these abstractions, not on openai or requests directly. That seam is what makes unit testing ReAct agent loops feasible. If your loop reads os.environ["OPENAI_API_KEY"] at module top level, refactor it behind a client object first. Otherwise every test imports network code.
Decide what counts as “inside” the loop. Prompt construction, history trimming, and termination logic belong inside. Tool implementations and the HTTP layer belong outside. Draw the line, then mock everything on the far side.
2. Mock the LLM client, not the model
Never call a real endpoint in a unit test. Mock the chat method and return canned strings that exercise your parser and control flow.
from unittest.mock import Mock
def test_three_step_loop():
llm = Mock()
llm.chat.side_effect = [
"Action: calc\nAction Input: {'x': 1}",
"Action: calc\nAction Input: {'x': 2}",
"Final Answer: done",
]
tools = {"calc": lambda x: str(int(x)+1)}
out = react_loop(llm, tools, "go")
assert out == "done"
assert llm.chat.call_count == 3
Use side_effect lists to simulate multi-turn sequences. Assert on call_count and the shape of the messages argument passed to each call. This catches history corruption early.
If you route through a gateway that performs automatic fallback when a provider is degraded, simulate a 429 on the first call and a success on retry. Your mock should verify the loop does not crash and does not double-count tokens in its local metering.
3. Test thought-action parsing in isolation
The parser is where most ReAct bugs hide. Unit test it directly with messy inputs before testing the full loop.
def test_parse_action_handles_whitespace():
raw = " Action: search \n Action Input: {\"q\": \"hi\"} "
action = parse_action(raw)
assert action.name == "search"
assert action.args == {"q": "hi"}
assert not action.is_final
def test_parse_final_answer():
raw = "Final Answer: 42"
action = parse_action(raw)
assert action.is_final
assert action.content == "42"
Cover three cases: valid action, final answer, and malformed output. For malformed output, decide explicitly: raise, or coerce to a “retry” action? Test that decision.
Tradeoff: strict parsing catches model drift early but increases fragility when the provider tweaks formatting. Loose parsing is resilient but hides regressions. Pick one and encode it in tests. Do not assert on the exact raw string in loop tests; assert on the parsed Action object.
4. Test tool dispatch and observation formatting
Your loop must map action names to registered tools and format the observation back into the history. Test that unknown tools are handled and that known tools receive correctly typed arguments.
def test_unknown_tool_raises():
llm = Mock()
llm.chat.return_value = "Action: nonexistent\nAction Input: {}"
try:
react_loop(llm, {"known": lambda: "x"}, "q")
except KeyError:
pass
else:
assert False, "expected KeyError"
Also test that the observation string is prefixed correctly (e.g., "Observation: ...") because the next model call depends on it. A missing space breaks the chain silently. If your tools return objects, test the serialization step separately—JSON vs str() matters.
A common bug: passing args as a string instead of a dict. Write a test where the parser yields {"q": "hi"} and the tool signature expects q: str. The loop should unpack, not forward a dict-as-string.
5. Test termination and max-iteration guard
Infinite loops are the easiest way to burn tokens. Assert the guard triggers and that it triggers at the right step.
def test_max_steps_raises():
llm = Mock()
llm.chat.return_value = "Action: loop\nAction Input: {}"
tools = {"loop": lambda: "again"}
try:
react_loop(llm, tools, "q", max_steps=4)
except TimeoutError:
assert llm.chat.call_count == 4
else:
assert False
If you allow max_steps=None for interactive use, test that path with a mock that never returns final—but wrap it in a test-level timeout (pytest-timeout) so CI does not hang. Also test early termination: when the model returns Final Answer on step one, the loop should not call tools at all.
6. Test error handling and fallback paths
Tools fail. Networks blink. Your loop should catch and convert errors into observations rather than exploding.
def test_tool_exception_becomes_observation():
llm = Mock()
llm.chat.side_effect = [
"Action: flaky\nAction Input: {}",
"Final Answer: recovered",
]
def flaky(): raise RuntimeError("boom")
out = react_loop(llm, {"flaky": flaky}, "q")
assert out == "recovered"
Inspect the messages passed to the second chat call; it should contain "boom" inside an observation. If you swallow exceptions silently, you have a test gap.
When using a gateway that honors client routing directives, test that a thrown RateLimitError from the client is retried, not passed to the model as a normal observation. If you route through n4n.ai, its automatic fallback when a provider is rate-limited means your loop should not hard-fail on a single 429; mock that sequence and assert per-token usage metering stays correct across the retry.
7. Test state accumulation and history passing
The model is stateless; your loop carries context. Assert the messages list grows as expected and that system prompts are preserved across steps.
def test_history_appends_observation():
llm = Mock()
llm.chat.side_effect = [
"Action: t\nAction Input: {}",
"Final Answer: ok",
]
captured = []
def spy(messages):
captured.append(list(messages))
return llm.chat(messages)
# wrap or monkeypatch to inspect
At minimum, assert that after step one, the history contains the assistant message and a user observation. Forgetting to append is the second most common bug after parser failures.
If you implement history trimming (to bound context window), test that the system prompt survives and that the most recent observation is never dropped. Trimming logic is pure function—test it with a fixed input list.
8. Common pitfalls and tradeoffs
- Asserting on raw model text. If you assert
"Action: search"exactly, a minor format change fails CI. Assert on parsed structure. - Mocking too little. A test that hits a real vector DB isn’t a unit test. Use in-memory fakes for tools.
- Ignoring cache-control. If your client forwards provider cache-control hints, test that repeated prefixes don’t trigger redundant token counts in your metering.
- Overspecifying call order. Only assert order where it matters (e.g., observation after action). Otherwise, refactors break tests needlessly.
- Testing happy path only. The value of unit testing ReAct agent loops is in the guard clauses: max steps, unknown tools, malformed parse, exception recovery.
Unit testing these loops is mostly about drawing hard lines between orchestration, model I/O, and tool side effects. Do that, and your tests will run in milliseconds and survive prompt tweaks.