Chapter 24 — Testing API Code Without Hitting the Network
Lesson 24.1 — Why We Test API Code Differently
You've built genuinely solid testing habits since Week 1, Chapter 9 — known input in, expected output out, checked automatically with assert. But there's an honest problem with applying that exact same approach directly to the API code you've built this week: a test that makes a real network call has real, practical problems that have nothing to do with whether your code is actually correct.
Think through what could go wrong with a test that calls a real, live API every time it runs. The API could be temporarily down — now your test fails, not because your code is broken, but because someone else's server is having a bad day. Your internet connection could hiccup. The API's data could change between when you wrote the test and when it runs again next week, silently breaking an assertion that was perfectly correct when you wrote it. And practically speaking, real network calls are slow — a test suite with even a modest number of them can take minutes instead of the sub-second runs you've gotten used to since Week 1.
None of these problems are about your code being wrong. They're about depending on something outside your control, inside a test that's specifically meant to give you a fast, reliable, honest answer about your own logic.
The solution is a technique called mocking — creating a fake, controlled stand-in for the real network call, one that behaves exactly the way you tell it to, every single time, with no internet connection required at all. Instead of asking "does this actually reach the real API successfully," a mocked test asks a more precise question: "if the API responded with exactly this, does my code handle it exactly right?"
That second question is genuinely the more useful one for testing your own logic. It's also the only reasonable way to test something like Chapter 22's retry function — you can't reliably make a real server fail twice and then succeed on command, but with mocking, you can simulate exactly that sequence, perfectly, every time you run the test.
This is the biggest new testing idea in the whole course. Take it one step at a time over this chapter — by the end, it'll feel like a natural extension of everything you already know from Week 1 and Week 2's testing chapters.
Lesson 24.2 — Mocking with unittest.mock: Faking a Response on Purpose
Let's build your first mocked test, step by step. Python's built-in unittest.mock module gives you everything you need — no extra installation required.
First, a small function worth testing — a simple wrapper around a GET request:
pythonimport requests def get_user_name(user_id): response = requests.get(f"https://jsonplaceholder.typicode.com/users/{user_id}") if response.status_code == 200: return response.json()["name"] return None
Now, let's test it without actually calling that URL. The tool we need is unittest.mock.patch, which temporarily replaces a real function — here, requests.get — with a fake one, just for the duration of the test.
pythonfrom unittest.mock import patch, Mock from api_client import get_user_name def test_get_user_name_returns_name_on_success(): fake_response = Mock() fake_response.status_code = 200 fake_response.json.return_value = {"id": 1, "name": "Priya Shah"} with patch("api_client.requests.get", return_value=fake_response): result = get_user_name(1) assert result == "Priya Shah"
Output / Note1 passed in 0.01s
Let's slow down and read this carefully, piece by piece, because every line here matters.
Mock() creates a completely fake object — one that will accept absolutely any attribute or method you set on it, standing in for a real requests response. fake_response.status_code = 200 sets a fake status code, exactly like a real successful response would have. fake_response.json.return_value = {...} tells the fake object: "whenever someone calls .json() on you, hand back this exact dictionary" — no real parsing, no real network call, just a fixed, known answer.
patch("api_client.requests.get", return_value=fake_response) is the genuinely important line: for the duration of the with block, any call to requests.get inside the api_client module — the file where get_user_name actually lives — will return our fake_response instead of making a real request. Notice the string is the path to where the function is used, not just "requests.get" on its own — this is a small, commonly confused detail worth remembering: you patch where a thing is looked up, not where it was originally defined.
Because of that patch, calling get_user_name(1) never touches the real internet at all — it calls requests.get, gets back our fake response, checks its (fake) status code, and returns the (fake) name, all in a fraction of a second, completely reliably, every single time this test runs.
Try It Yourself:
Write a second test, test_get_user_name_returns_none_on_failure, that mocks a response with status_code = 404, and confirms get_user_name correctly returns None in that case.
Lesson 24.3 — Testing a Retry Sequence: Fail, Then Succeed
Now let's test something that would be genuinely difficult to test with a real API at all: does Chapter 22's retry logic actually retry, the correct number of times, and correctly succeed once a "server" finally cooperates?
The tool for this is side_effect — instead of a mock always returning the same thing, side_effect lets you give it a list of things to return, one after another, on each successive call.
Let's test a simplified version of the retry function from Chapter 22.2:
pythonimport time import requests def get_with_retries(url, max_attempts=3): for attempt in range(1, max_attempts + 1): response = requests.get(url) if response.status_code == 200: return response if attempt < max_attempts: time.sleep(0) # real code would sleep longer; kept at 0 to keep tests fast return None
Now, the test:
pythonfrom unittest.mock import patch, Mock from api_client import get_with_retries def test_get_with_retries_succeeds_after_two_failures(): failing_response = Mock() failing_response.status_code = 500 success_response = Mock() success_response.status_code = 200 with patch("api_client.requests.get") as mock_get: mock_get.side_effect = [failing_response, failing_response, success_response] result = get_with_retries("https://example.com/data", max_attempts=3) assert result.status_code == 200 assert mock_get.call_count == 3
Output / Note1 passed in 0.01s
Read that side_effect list like a script you're handing to the mock: "the first time you're called, return this failing response. The second time, return it again. The third time, return the success." This precisely, reliably simulates the exact "fail twice, then succeed" scenario that would be genuinely hard to force a real server to reproduce on demand — and here, it's completely predictable, every time this test runs.
Notice mock_get.call_count == 3 at the end — this confirms not just that we got the right final result, but that requests.get was actually called exactly three times along the way, proving the retry logic genuinely retried, rather than succeeding by some other accident. This is a genuinely valuable pattern: checking not just the output, but the behavior that produced it.
Let's also confirm the honest failure case — every attempt fails, and the function correctly gives up:
pythondef test_get_with_retries_returns_none_after_all_failures(): failing_response = Mock() failing_response.status_code = 500 with patch("api_client.requests.get") as mock_get: mock_get.side_effect = [failing_response, failing_response, failing_response] result = get_with_retries("https://example.com/data", max_attempts=3) assert result is None assert mock_get.call_count == 3
Output / Note1 passed in 0.01s
Two tests, both running in a fraction of a second, both completely reliable regardless of any real server's mood that day, and both genuinely proving your retry logic does exactly what Chapter 22 built it to do.
Try It Yourself:
Write a third test confirming that when the very first attempt succeeds, get_with_retries returns immediately, and mock_get.call_count is exactly 1 — proving it doesn't keep retrying unnecessarily once it's already succeeded.
Lesson 24.4 — Hands-On: Add Tests to Your API Client and Parser
Let's bring this chapter together by putting a proper test suite under two real pieces of work from this week: the safe_parse_response function from Chapter 22.3, and the flattening logic from Chapter 23.1.
First, move both into a proper module, api_client.py:
pythonimport requests import pandas as pd def safe_parse_response(response): """Safely parse a response's JSON body. Returns None if invalid.""" try: return response.json() except requests.exceptions.JSONDecodeError: return None def flatten_users(users): """Flatten a list of raw user records into a clean DataFrame.""" df = pd.json_normalize(users) df_clean = df[["id", "name", "email", "address.city", "company.name"]] df_clean = df_clean.rename(columns={"address.city": "city", "company.name": "company"}) return df_clean
Now, the test suite:
pythonfrom unittest.mock import Mock import requests import pandas as pd from api_client import safe_parse_response, flatten_users def test_safe_parse_response_returns_data_on_valid_json(): fake_response = Mock() fake_response.json.return_value = {"id": 1, "name": "Priya Shah"} result = safe_parse_response(fake_response) assert result == {"id": 1, "name": "Priya Shah"} def test_safe_parse_response_returns_none_on_invalid_json(): fake_response = Mock() fake_response.json.side_effect = requests.exceptions.JSONDecodeError("msg", "doc", 0) result = safe_parse_response(fake_response) assert result is None def test_flatten_users_extracts_nested_city_and_company(): raw_users = [ { "id": 1, "name": "Priya Shah", "email": "priya.shah@company.com", "address": {"city": "Mumbai"}, "company": {"name": "Acme Corp"}, } ] result = flatten_users(raw_users) assert result["city"].iloc[0] == "Mumbai" assert result["company"].iloc[0] == "Acme Corp" def test_flatten_users_produces_expected_columns(): raw_users = [ { "id": 1, "name": "Priya Shah", "email": "priya.shah@company.com", "address": {"city": "Mumbai"}, "company": {"name": "Acme Corp"}, } ] result = flatten_users(raw_users) assert list(result.columns) == ["id", "name", "email", "city", "company"]
Output / Notetest_api_client.py .... [100%] 4 passed in 0.03s
Look closely at test_safe_parse_response_returns_none_on_invalid_json — notice side_effect being used slightly differently here than in Lesson 24.3. Instead of a list of return values, we handed it a single exception directly. When side_effect is set to an exception like this, the mock raises it instead of returning it, the moment .json() is called — a genuinely useful way to simulate not just "here's a different response," but "this call fails with a specific error," exactly the kind of failure safe_parse_response was built to survive.
Also notice test_flatten_users_extracts_nested_city_and_company and test_flatten_users_produces_expected_columns don't mock anything at all — they don't need to. flatten_users doesn't make any network calls itself; it just transforms data it's already been given. Not every function needs mocking — only the ones that actually reach out to the network. Recognizing which is which is its own genuinely useful skill, and one worth practicing deliberately.
You've now built a complete, fast, reliable test suite covering real API-handling code — response parsing, error handling, and data flattening — without a single real network call anywhere in it. Every test here runs in milliseconds, and every one of them will still pass tomorrow, next week, and next year, regardless of whether the real API happens to be online at that exact moment.
Try It Yourself:
Write one more test, test_flatten_users_handles_multiple_records, using a list of two raw user dictionaries instead of one, and confirm the resulting DataFrame has exactly two rows using len(result).