Practical API Testing with Pytest + HTTPX: Realistic Integration Tests Without the Flakes
API testing is one of the highest ROI habits you can build as a web developer. A small, repeatable test suite catches breaking changes early, documents behavior, and gives you confidence to refactor. In this hands-on guide, you’ll build a practical API test setup using pytest and httpx, with clean patterns for:
- Fast feedback: run tests locally in seconds
- Realistic integration tests: hit a running API instance
- Mocking external dependencies: avoid calling real third-party services
- Contract-style checks: validate response shapes and error cases
The examples assume you have an API already running (any stack is fine). If you don’t, you can still follow along by adapting endpoints to your project.
What We’re Testing (Example Endpoints)
We’ll assume your API has endpoints like:
POST /auth/login→ returns a tokenGET /profile→ returns the current userPOST /orders→ creates an orderGET /orders/{id}→ fetches an order
You can map these patterns to your actual routes.
Project Setup
Create a tests folder and install dependencies:
pip install pytest httpx python-dotenv respx
Add a .env.test file for test configuration:
API_BASE_URL=http://localhost:8000 [email protected] TEST_USER_PASSWORD=secret123
Then load it in tests so your suite is configurable per environment.
Step 1: A Clean HTTP Client Fixture
Create tests/conftest.py (pytest automatically loads it):
import os from dotenv import load_dotenv import pytest import httpx load_dotenv(".env.test") @pytest.fixture(scope="session") def base_url() -> str: url = os.getenv("API_BASE_URL", "http://localhost:8000") return url.rstrip("/") @pytest.fixture() def client(base_url: str): # Use a fresh client per test to avoid shared state issues. with httpx.Client(base_url=base_url, timeout=10.0) as c: yield c
Why this matters: a shared global client can leak headers, cookies, or connection state between tests. Keeping it per-test is safer for junior/mid teams.
Step 2: Test Authentication Once, Reuse It Everywhere
Create a small helper to log in and return an auth header. Put this in tests/helpers.py:
import os import httpx def login_and_get_token(client: httpx.Client) -> str: email = os.getenv("TEST_USER_EMAIL") password = os.getenv("TEST_USER_PASSWORD") resp = client.post("/auth/login", json={"email": email, "password": password}) assert resp.status_code == 200, resp.text data = resp.json() assert "access_token" in data return data["access_token"] def auth_headers(token: str) -> dict: return {"Authorization": f"Bearer {token}"}
Now your tests don’t repeat login code and remain readable.
Step 3: Write a “Happy Path” Flow Test
This is the test juniors often skip: a realistic user flow that touches multiple endpoints. Create tests/test_orders_flow.py:
from tests.helpers import login_and_get_token, auth_headers def test_create_and_fetch_order(client): token = login_and_get_token(client) headers = auth_headers(token) create_payload = { "items": [ {"sku": "TSHIRT-001", "qty": 2}, {"sku": "MUG-004", "qty": 1}, ], "shipping_address": { "line1": "1 Main St", "city": "Tel Aviv", "country": "IL", "zip": "61000" } } create_resp = client.post("/orders", json=create_payload, headers=headers) assert create_resp.status_code == 201, create_resp.text created = create_resp.json() assert "id" in created assert created["status"] in ("created", "pending") order_id = created["id"] fetch_resp = client.get(f"/orders/{order_id}", headers=headers) assert fetch_resp.status_code == 200, fetch_resp.text fetched = fetch_resp.json() assert fetched["id"] == order_id assert len(fetched["items"]) == 2
Tip: Keep one or two flow tests per feature area. They’re great at catching broken auth, routing, serialization, or DB wiring.
Step 4: Don’t Forget Error Cases (They’re Part of the Contract)
Good APIs are predictable when things go wrong. Add tests/test_orders_validation.py:
from tests.helpers import login_and_get_token, auth_headers def test_create_order_missing_items_returns_422_or_400(client): token = login_and_get_token(client) headers = auth_headers(token) bad_payload = { "items": [], # invalid "shipping_address": {"line1": "1 Main St", "city": "Tel Aviv", "country": "IL", "zip": "61000"} } resp = client.post("/orders", json=bad_payload, headers=headers) # Different frameworks use different codes. Pick one and enforce it in your API. assert resp.status_code in (400, 422), resp.text data = resp.json() # Adapt these assertions to your API’s error format. assert "error" in data or "detail" in data
Even if your API uses a different error schema, the idea is the same: lock in behavior so clients (frontend/mobile) don’t suffer surprise changes.
Step 5: Lightweight “Schema” Checks Without Heavy Frameworks
You don’t need a full contract-testing platform to get benefits. Add a tiny validator function for critical endpoints.
Create tests/contracts.py:
def assert_profile_shape(data: dict): # Required keys (contract) for key in ["id", "email", "name"]: assert key in data, f"Missing key: {key}" assert isinstance(data["id"], (int, str)) assert isinstance(data["email"], str) assert "@" in data["email"] assert isinstance(data["name"], str)
Use it in tests/test_profile.py:
from tests.helpers import login_and_get_token, auth_headers from tests.contracts import assert_profile_shape def test_profile_contract(client): token = login_and_get_token(client) resp = client.get("/profile", headers=auth_headers(token)) assert resp.status_code == 200, resp.text data = resp.json() assert_profile_shape(data)
This style is fast, easy to maintain, and keeps response drift in check.
Step 6: Mock External APIs (Stop Paying for Tests)
If your API calls a third-party service (payments, email, shipping), your tests should not depend on it. Network flakiness and rate limits will destroy confidence.
Here’s a common pattern: your API calls https://shipping.example.com/rates. In tests, intercept it using respx.
Create tests/test_shipping_mock.py:
import respx import httpx from tests.helpers import login_and_get_token, auth_headers @respx.mock def test_order_creation_uses_mocked_shipping_rates(client): # Mock the external call that your backend makes. respx.post("https://shipping.example.com/rates").mock( return_value=httpx.Response(200, json={"rate": 12.50, "currency": "USD"}) ) token = login_and_get_token(client) headers = auth_headers(token) payload = { "items": [{"sku": "TSHIRT-001", "qty": 1}], "shipping_address": {"line1": "1 Main St", "city": "Tel Aviv", "country": "IL", "zip": "61000"} } resp = client.post("/orders", json=payload, headers=headers) assert resp.status_code == 201, resp.text # Optional: assert the external call was actually made. assert respx.calls.call_count >= 1
Key idea: Your API should be written in a way that uses a standard HTTP client under the hood (requests/httpx/fetch) so tests can intercept calls. If it’s hard to mock, that’s a design smell.
Step 7: Make Tests CI-Friendly (No Hidden Dependencies)
To keep tests reliable across machines:
- Use
.env.test(or CI secrets) for config - Make each test independent (no reliance on execution order)
- Prefer creating data through APIs rather than inserting directly into DB (unless you have a dedicated factory layer)
- Clean up what you create, or use a disposable test database
A quick improvement: generate a unique value per run to avoid collisions:
import uuid def unique_email() -> str: return f"test+{uuid.uuid4().hex[:8]}@example.com"
Practical Test Organization Tips
- Name tests by behavior:
test_create_order_returns_201is better thantest_orders_1. - Keep helpers small: helpers should reduce noise, not hide intent.
- Prefer 10 small tests over 1 giant test (except for a couple of flow tests).
- Assert what matters: status codes, key fields, and error formats—avoid brittle full-response equality.
Running the Suite
Start your API locally, then run:
pytest -q
If you want to see logs or printed debug output:
pytest -q -s
Where to Go Next
Once this baseline works, the next upgrades are straightforward:
- Add a
docker-composetest environment (API + DB) for consistent runs - Add coverage reporting (
pytest-cov) and fail if it drops - Add OpenAPI-based contract checks if your API publishes a spec
- Split tests into
unitvsintegrationmarkers for faster dev loops
With just pytest + httpx, you already get a professional testing workflow: readable tests, fewer flaky failures, and safer deployments.
Leave a Reply