API Testing That Catches Real Bugs: Unit, Integration, and Contract Tests with pytest + httpx

API Testing That Catches Real Bugs: Unit, Integration, and Contract Tests with pytest + httpx

API bugs are rarely “the endpoint returns 500.” More often, it’s subtle: a field becomes nullable, an enum grows a new value, pagination changes, or an upstream dependency times out and your API returns the wrong error shape. The fastest way to prevent these regressions is to layer your tests:

  • Unit tests: validate business logic without HTTP.
  • Integration tests: hit your API over HTTP (in a test environment) and validate behavior end-to-end.
  • Contract tests: ensure responses match your OpenAPI schema, and fuzz your API to discover edge cases.

This article walks through a practical setup using pytest and httpx (plus a few optional tools) that junior/mid devs can apply immediately.

Project Setup

Install dependencies:

python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install pytest httpx respx pydantic schemathesis hypothesis

We’ll assume your API base URL is provided via API_BASE_URL (e.g., http://localhost:8000) and that your API publishes an OpenAPI document at /openapi.json.

1) Unit Testing Business Logic (No HTTP)

Start by isolating logic that should not require a running server: validation, calculations, transformations, and decision rules. Here’s a tiny “order pricing” example.

app/pricing.py

from dataclasses import dataclass @dataclass(frozen=True) class LineItem: sku: str unit_price_cents: int qty: int def subtotal_cents(items: list[LineItem]) -> int: if not items: return 0 total = 0 for it in items: if it.qty <= 0: raise ValueError("qty must be positive") if it.unit_price_cents < 0: raise ValueError("unit_price_cents must be non-negative") total += it.unit_price_cents * it.qty return total def apply_discount_cents(subtotal: int, percent: int) -> int: if percent < 0 or percent > 100: raise ValueError("percent out of range") return subtotal - (subtotal * percent // 100)

tests/test_pricing_unit.py

import pytest from app.pricing import LineItem, subtotal_cents, apply_discount_cents def test_subtotal_happy_path(): items = [ LineItem("A", 199, 2), LineItem("B", 500, 1), ] assert subtotal_cents(items) == 199*2 + 500 @pytest.mark.parametrize("qty", [0, -1]) def test_subtotal_rejects_bad_qty(qty): with pytest.raises(ValueError): subtotal_cents([LineItem("A", 100, qty)]) def test_discount_is_integer_math(): assert apply_discount_cents(999, 10) == 900 # 99.9 cents rounds down def test_discount_percent_range(): with pytest.raises(ValueError): apply_discount_cents(1000, 101)

Why this matters: these tests run in milliseconds and pinpoint logic errors without network noise.

2) Integration Testing Your API Over HTTP

Integration tests validate the real HTTP contract: status codes, error shapes, headers, and JSON payloads. Use httpx as a modern HTTP client.

tests/conftest.py

import os import pytest import httpx @pytest.fixture(scope="session") def api_base_url() -> str: return os.environ.get("API_BASE_URL", "http://localhost:8000") @pytest.fixture() def client(api_base_url: str): # Keep timeouts strict so slow endpoints surface quickly in CI. timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=5.0) with httpx.Client(base_url=api_base_url, timeout=timeout) as c: yield c

Now test an endpoint. Suppose your API has:

  • POST /orders to create an order
  • GET /orders/{id} to fetch it

tests/test_orders_integration.py

import uuid def test_create_order_and_fetch(client): payload = { "customer_id": str(uuid.uuid4()), "items": [ {"sku": "A", "unit_price_cents": 199, "qty": 2}, {"sku": "B", "unit_price_cents": 500, "qty": 1}, ], "discount_percent": 10 } r = client.post("/orders", json=payload) assert r.status_code == 201, r.text body = r.json() assert "id" in body assert body["status"] in ("created", "pending") # allow expected variants assert body["total_cents"] == (199*2 + 500) - ((199*2 + 500) * 10 // 100) order_id = body["id"] r2 = client.get(f"/orders/{order_id}") assert r2.status_code == 200, r2.text body2 = r2.json() assert body2["id"] == order_id assert body2["total_cents"] == body["total_cents"]

Tip: Prefer asserting the “important invariants” (status code, required fields, business totals) instead of snapshotting entire JSON. Snapshots can be brittle when new fields are added.

3) Test Error Responses Like a First-Class API Feature

Good APIs return consistent error shapes. If your API returns errors like:

  • 400 with {"error": {"code": "...", "message": "...", "details": {...}}}

Then lock it down with tests.

tests/test_errors.py

def assert_error_shape(r): body = r.json() assert "error" in body err = body["error"] assert isinstance(err.get("code"), str) assert isinstance(err.get("message"), str) # details can vary, but should exist (even if null) if that's your contract. assert "details" in err def test_create_order_rejects_missing_items(client): r = client.post("/orders", json={"customer_id": "abc", "items": []}) assert r.status_code == 400 assert_error_shape(r) assert r.json()["error"]["code"] in ("validation_error", "bad_request")

When your frontend or integrators rely on error consistency, this test suite becomes your guardrail.

4) Mock Upstream Dependencies with respx (So Tests Are Stable)

Many APIs call external services: payment providers, email gateways, fraud checks. In integration tests, you typically don’t want to hit the real network. If your API calls an upstream HTTP service, mock it with respx inside unit-level service tests.

Example: Your code calls https://payments.example/charge. You can test your payment client without touching the network.

app/payments_client.py

import httpx class PaymentError(Exception): pass def charge(token: str, amount_cents: int) -> str: r = httpx.post( "https://payments.example/charge", json={"token": token, "amount_cents": amount_cents}, timeout=3.0, ) if r.status_code != 200: raise PaymentError(f"payment failed: {r.status_code}") return r.json()["charge_id"]

tests/test_payments_client.py

import pytest import respx import httpx from app.payments_client import charge, PaymentError @respx.mock def test_charge_success(): route = respx.post("https://payments.example/charge").mock( return_value=httpx.Response(200, json={"charge_id": "ch_123"}) ) cid = charge("tok_abc", 500) assert cid == "ch_123" assert route.called @respx.mock def test_charge_failure(): respx.post("https://payments.example/charge").mock( return_value=httpx.Response(402, json={"error": "card_declined"}) ) with pytest.raises(PaymentError): charge("tok_bad", 500)

Pattern: keep “HTTP client wrapper” code in small modules, and mock them. Your API tests can focus on your API, not third parties.

5) Contract Testing with OpenAPI (Plus Fuzzing)

Integration tests cover known scenarios. Contract tests make sure your API stays compatible across many inputs and response shapes—especially useful when multiple teams (frontend, mobile, partners) depend on you.

schemathesis reads your OpenAPI schema and generates test cases automatically, using property-based testing via hypothesis.

tests/test_contract_openapi.py

import os import schemathesis import httpx BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000") schema = schemathesis.from_uri(f"{BASE_URL}/openapi.json") @schema.parametrize() def test_api_contract(case): # This sends a generated request (with headers/body/query params) to your API response = case.call(base_url=BASE_URL, timeout=5) # Validate status code + response body matches the OpenAPI schema case.validate_response(response)

How to run:

API_BASE_URL=http://localhost:8000 pytest -q

What you’ll catch with this:

  • Endpoints that return a different status code than documented
  • Responses missing required fields
  • Wrong types (string vs number), broken enums, invalid formats (date-time)
  • Unhandled edge-case inputs that lead to 500s

Practical advice: Start by running contract tests only on “public” endpoints (or tag them), then expand coverage. If your schema is incomplete, this will also push you to document it accurately—another win.

6) A Lean CI Recipe

In CI, you want fast feedback. A common approach:

  • Run unit tests on every push
  • Run integration + contract tests when the API is built and started (e.g., in a service container)

Even without showing a full CI config here, structure your test commands like this:

# Fast local loop pytest -q tests/test_pricing_unit.py tests/test_payments_client.py # Full suite (requires running API) API_BASE_URL=http://localhost:8000 pytest -q

Checklist: What “Good API Tests” Look Like

  • Stable: no real network calls to third parties; timeouts are explicit.
  • Readable: helper assertions for error shapes; clear expected invariants.
  • Layered: most logic tested without HTTP; fewer (but meaningful) end-to-end tests.
  • Schema-backed: OpenAPI contract tests prevent silent breaking changes.
  • Actionable failures: failures show response bodies (assert ... , r.text) and point to what broke.

Wrap-Up

If you implement only one improvement this week, add contract testing against your OpenAPI schema. It’s the quickest way to catch breaking changes you didn’t know you were shipping. Combine that with fast unit tests for logic and a handful of integration tests for critical flows, and you’ll have an API test suite that scales with your product—without becoming a maintenance nightmare.


Leave a Reply

Your email address will not be published. Required fields are marked *