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:

tests/test_validators.py
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:

tests/test_transformers.py
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:

tests/test_pricing.py
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:

tests/test_errors.py
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:

tests/test_auth.py
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:

tests/test_validators.py (continued)
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.