API Testing · lesson 5 of 11
Building a Local Test API with the Standard Library
Every remaining lesson in this track needs an API you fully control — build one in under 40 lines with http.server, no framework and no third-party dependency, and give each test a fresh, isolated instance.
By SkillByExample · Published
Auth, retries, and pagination all need an API that behaves exactly how the test says it should. httpbin can’t do that — this lesson builds one that can.
The handler
# local_api.py
import json
from http.server import BaseHTTPRequestHandler
class BookAPIHandler(BaseHTTPRequestHandler):
def _send_json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
books = self.server.books
if self.path == "/books":
self._send_json(200, books)
return
if self.path.startswith("/books/"):
book_id = int(self.path.removeprefix("/books/"))
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
self._send_json(404, {"error": "not found"})
else:
self._send_json(200, book)
return
self._send_json(404, {"error": "not found"})
def do_POST(self):
books = self.server.books
if self.path == "/books":
length = int(self.headers["Content-Length"])
payload = json.loads(self.rfile.read(length))
new_id = max((b["id"] for b in books), default=0) + 1
book = {"id": new_id, **payload}
books.append(book)
self._send_json(201, book)
return
self._send_json(404, {"error": "not found"})
def log_message(self, format, *args):
pass
BaseHTTPRequestHandler calls do_GET/do_POST per request; there’s no router, so the method
itself checks self.path. books lives on self.server, not as a module-level list — that
choice matters in a moment. log_message is overridden to silence http.server’s default
per-request logging to stderr, which would otherwise clutter every test run.
The fixture
# conftest.py
import threading
from http.server import ThreadingHTTPServer
import pytest
from local_api import BookAPIHandler
@pytest.fixture
def local_api_url():
server = ThreadingHTTPServer(("127.0.0.1", 0), BookAPIHandler)
server.books = [
{"id": 1, "title": "Dune", "year": 1965},
{"id": 2, "title": "Foundation", "year": 1951},
]
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
thread.join()
Port 0 asks the OS for any free port, so nothing collides if tests run in parallel. The
server runs in a daemon thread; serve_forever blocks, so it has to run somewhere that isn’t
the main test thread. Teardown calls server.shutdown() and joins the thread so nothing is
left running after the test.
Using it
# test_local_api.py
import requests
def test_list_books(local_api_url):
response = requests.get(f"{local_api_url}/books")
assert response.status_code == 200
assert len(response.json()) == 2
def test_get_one_book(local_api_url):
response = requests.get(f"{local_api_url}/books/1")
assert response.json()["title"] == "Dune"
def test_get_missing_book_returns_404(local_api_url):
response = requests.get(f"{local_api_url}/books/999")
assert response.status_code == 404
def test_create_book(local_api_url):
response = requests.post(f"{local_api_url}/books", json={"title": "Neuromancer", "year": 1984})
assert response.status_code == 201
assert response.json()["id"] == 3
pytest -v
test_local_api.py::test_list_books PASSED [ 25%]
test_local_api.py::test_get_one_book PASSED [ 50%]
test_local_api.py::test_get_missing_book_returns_404 PASSED [ 75%]
test_local_api.py::test_create_book PASSED [100%]
============================== 4 passed in 2.65s ==============================
The test never hardcodes a port or waits for the server to “be ready” — the fixture only
yields the URL once thread.start() has returned and the OS has bound the socket.
Why server.books instead of a module-level list
def test_earlier_tests_created_book_did_not_leak_into_this_one(local_api_url):
response = requests.get(f"{local_api_url}/books")
assert len(response.json()) == 2
test_local_api.py::test_earlier_tests_created_book_did_not_leak_into_this_one PASSED [100%]
This runs after test_create_book, which added a third book — and it still sees 2. Because
local_api_url is function-scoped, every test gets a brand-new ThreadingHTTPServer with its
own server.books list. If books were a module-level list instead, this test would see 3
and the failure would depend entirely on which order pytest happened to run the tests in — one
of the hardest categories of bug to reproduce.
Practice
1. POST two books in one test and confirm IDs increment correctly.
def test_creating_a_book_assigns_the_next_id(local_api_url):
requests.post(f"{local_api_url}/books", json={"title": "Neuromancer", "year": 1984})
second = requests.post(f"{local_api_url}/books", json={"title": "Snow Crash", "year": 1992})
assert second.json()["id"] == 4
test_practice.py::test_creating_a_book_assigns_the_next_id PASSED [ 33%]
The fixture starts each test at 2 books (id 1 and 2), so the first POST gets id 3 and the
second gets id 4 — max(...) + 1 in the handler tracks the running total.
2. Assert an unregistered path returns 404.
def test_unknown_path_returns_404(local_api_url):
response = requests.get(f"{local_api_url}/authors")
assert response.status_code == 404
test_practice.py::test_unknown_path_returns_404 PASSED [ 66%]
The handler’s fallthrough self._send_json(404, ...) at the end of do_GET catches any path
that isn’t /books or /books/<id> — the same pattern a real router’s 404 handler follows.
3. Confirm every response carries a JSON content type, not just the 200s.
def test_content_type_is_json_on_every_response(local_api_url):
response = requests.get(f"{local_api_url}/books")
assert response.headers["Content-Type"] == "application/json"
test_practice.py::test_content_type_is_json_on_every_response PASSED [100%]
_send_json sets the header for every response path, including the 404 branch — worth
verifying directly, since a handwritten server (unlike a framework) won’t set it for you by
default.
Next: extending this server with auth headers, and testing 401 versus 403.