3. Unit Testing for APIs
Unit tests for APIs focus on the small pieces of logic that make an API reliable. They check that incoming requests are validated correctly, that raw API responses are transformed into the right shape, and that core business rules behave as expected. They also verify that errors are handled safely, authentication and access rules are enforced, and unusual or edge-case inputs don't break the system. Each test runs in isolation, so when something fails, it points directly to the exact part of the API logic that needs attention.
Six places unit tests pay off
Six categories of logic in any real API are cheap to cover with unit tests, and expensive to leave untested. Here's each one with a tiny example that runs in isolation: no Flask, no database, no network.
1. Request validation
Small helpers that check incoming payloads are pure logic. Suppose you have a validate_city(payload) helper that strips whitespace and rejects empty or oversized input. You can pin its behaviour down without touching Flask:
import pytest
from app.validators import validate_city
def test_validate_city_strips_whitespace():
assert validate_city({"city": " Dublin "}) == "Dublin"
def test_validate_city_rejects_empty_payload():
with pytest.raises(ValueError):
validate_city({})
2. Data transformation
Pure functions that reshape raw third-party responses into clean domain objects. Given a transform_weather(raw) helper that flattens an OpenWeather payload into {city, temp_c, description}, the test stays focused on the shape:
from app.transformers import transform_weather
def test_transform_weather_flattens_shape():
raw = {
"name": "Dublin",
"main": {"temp": 11.2},
"weather": [{"description": "light rain"}],
}
assert transform_weather(raw) == {
"city": "Dublin",
"temp_c": 11.2,
"description": "light rain",
}
3. Business logic
Domain rules that don't depend on Flask, the database, or HTTP. An apply_discount(price, code) function is just arithmetic and a lookup, so the test covers the happy path and the fallthrough in two assertions:
from app.pricing import apply_discount
def test_welcome_code_is_ten_percent_off():
assert apply_discount(100.00, "WELCOME10") == 90.00
def test_unknown_code_leaves_price_unchanged():
assert apply_discount(100.00, "BOGUS") == 100.00
4. Error handling
The code path that turns an exception into a safe response. A to_error_response(exc) helper maps exception types to status codes and messages. Unit-test the mapping directly, without raising anything through a live HTTP call:
from app.errors import to_error_response
def test_value_error_maps_to_422():
result = to_error_response(ValueError("bad input"))
assert result == {"status": 422, "error": "invalid value"}
def test_unknown_exception_maps_to_500():
result = to_error_response(RuntimeError("boom"))
assert result["status"] == 500
5. Authentication logic
Token parsing and permission checks live as pure functions. No Flask session, no cookies, no database: just input in, decision out. A has_permission(user, action) helper is the shape to aim for:
from app.auth import has_permission
def test_admin_can_perform_any_action():
assert has_permission({"role": "admin"}, "delete") is True
def test_user_without_permission_is_denied():
user = {"role": "user", "permissions": ["read"]}
assert has_permission(user, "delete") is False
6. Edge cases
Empty inputs, Unicode, boundary values, off-by-one. Unit tests are cheap enough to cover every one. Here are three edge cases for the same validate_city helper from earlier, each pinning down a specific boundary:
import pytest
from app.validators import validate_city
def test_validate_city_accepts_unicode():
assert validate_city({"city": "São Paulo"}) == "São Paulo"
def test_validate_city_accepts_boundary_length():
assert validate_city({"city": "x" * 100}) == "x" * 100
def test_validate_city_rejects_over_boundary():
with pytest.raises(ValueError):
validate_city({"city": "x" * 101})
Where this leads
These six patterns account for most of a working API test suite. The rest of the guide turns up the realism. Next we'll send real HTTP requests through the responses library and watch which bugs only surface when the wire format itself gets checked.