Skip to main content
API Testing · lesson 5 of 11
API Testing intermediate 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

api-testing python http.server pytest testing-infrastructure

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.

Frequently Asked Questions

Why not just keep testing against httpbin.org?
httpbin is great for demonstrating requests/responses but you can't make it return a custom 401, simulate a flaky 500-then-200 sequence, or control pagination — and a suite that depends on a third party's uptime is not fully yours. Auth, retries, and pagination all need behavior only a server you control can produce on demand.
Why http.server instead of Flask or FastAPI?
Both are fine choices for a real project, but they're an extra dependency for what is, here, a teaching fixture — http.server ships with every Python install, so a reader can run every remaining example with nothing beyond requests and pytest. The tradeoff is verbosity: routing and JSON encoding that a framework gives you for free are written out by hand.
Why attach the book list to server.books instead of a module-level list?
A module-level list is shared by every test in the process — one test's POST leaks into the next test's GET, and the failure that results depends on run order, which is one of the most frustrating categories of test bug. Attaching state to the server instance means a fresh fixture creates a fresh list, so tests stay independent no matter what order they run in.
Why does the server run in a background thread instead of a separate process?
A thread starts in milliseconds and shares the test process, so there's no separate binary to manage, no port-is-ready polling loop, and no subprocess cleanup to get wrong. ThreadingHTTPServer handles concurrent requests safely because each one runs in its own thread against the same server.books list.
What does port 0 do in ThreadingHTTPServer(('127.0.0.1', 0), ...)?
It asks the OS to assign any free port instead of a fixed one. That's what makes the fixture safe to run in parallel or repeatedly without a 'port already in use' collision — the actual port is read back afterward from server.server_address[1].