Mocking external calls
Why a real LLM API call has no place in a test
An agent backend’s route often calls something genuinely external — an LLM API, another service. Testing that route by letting it make a real call has three real problems: it’s slow (a network round-trip on every single test run, for every test that touches this route), it costs real money (an actual LLM API call, every time the test suite runs — including in CI, potentially dozens of times a day), and it’s not deterministic (a real LLM’s response can vary, and the network itself can simply fail, unrelated to whether your code is actually correct).
unittest.mock — replacing the real call with a controlled fake
unittest.mock.patch temporarily replaces a function (or object) with
a fake one, for the duration of a test, then automatically restores
the real one afterward:
# main.py
async def call_llm_api(prompt: str) -> str:
# a real network call, in production
...
@app.post("/generate")
async def generate(prompt: str):
result = await call_llm_api(prompt)
return {"result": result}# test_main.py
from unittest.mock import patch, AsyncMock
@patch("main.call_llm_api", new_callable=AsyncMock)
def test_generate_returns_llm_result(mock_call_llm_api, client):
mock_call_llm_api.return_value = "mocked response"
response = client.post("/generate", json={"prompt": "hello"})
assert response.status_code == 200
assert response.json() == {"result": "mocked response"}
mock_call_llm_api.assert_called_once_with("hello")click Run to see this pane's outputNot a single real network request happened — call_llm_api never ran
its real body at all, yet generate still executed for real,
including the part that turns whatever call_llm_api produces into
the actual response.
@patch("main.call_llm_api", new_callable=AsyncMock) replaces
call_llm_api, specifically as it’s referenced inside main.py,
with a mock for the duration of this test — AsyncMock specifically,
since
the real function is async def, and the mock needs to be await-able the same way.
Setting mock_call_llm_api.return_value controls exactly what the
mock produces when awaited, with no real network call happening at
all. mock_call_llm_api.assert_called_once_with("hello") additionally
confirms the route actually called it correctly — with the right
argument, exactly once — which is a check a real network call
couldn’t give you nearly as precisely.
Mocking a failure, not just a success
Mocking is just as useful for testing how a route handles the external call going wrong — something genuinely hard to trigger reliably against a real API on demand, but trivial against a mock:
@patch("main.call_llm_api", new_callable=AsyncMock)
def test_generate_handles_llm_failure(mock_call_llm_api, client):
mock_call_llm_api.side_effect = ConnectionError("LLM API unreachable")
response = client.post("/generate", json={"prompt": "hello"})
assert response.status_code == 503click Run to see this pane's outputTriggering this reliably against a real LLM API would mean actually
taking the network down, or hoping for a flaky moment — against a
mock, it’s one line (side_effect = ConnectionError(...)), on demand,
every single run.
side_effect (rather than return_value) makes the mock raise that
exception when called, standing in for a real network failure —
assuming generate itself catches ConnectionError and translates it
into a proper 503 Service Unavailable response, exactly the kind of
error-path behavior
Concept 5’s discipline
says deserves its own explicit test.
This is what keeps a test a unit test
Recall
the unit-vs-integration distinction from earlier in this lesson:
mocking is the specific tool that keeps a test from silently becoming
something else. Without mocking call_llm_api,
test_generate_returns_llm_result would actually be an integration
test against a real external service — slow, costly, and
non-deterministic, exactly the three problems named at the top of this
concept. Mocking cuts out the one genuinely external dependency,
leaving a test that still exercises the route’s own logic (does it
call the LLM function correctly, does it shape the response correctly,
does it handle a failure correctly) without depending on anything
outside your own code actually running.
What are the three concrete problems with a test that makes a real LLM API call on every run?
Given the route below (already written, in main.py -- read-only), write test_main.py with two test functions: test_generate_success, mocking call_llm_api to return "mocked response" and asserting a 200 with the correct body; and test_generate_llm_failure, mocking call_llm_api to raise a ConnectionError and asserting a 503. Both mocked calls actually run against the real route above, so a correct mock plus a correct assertion is what makes each test pass.