API Testing in Practice: Reliable Checks with Pytest, HTTPX, and JSON Schema
API tests are the safety net that lets you refactor backend code without sweating every deploy. For junior/mid devs, the fastest path to “real” API testing is to focus on three things:
- Behavior: status codes, payload shapes, and business rules
- Contracts: responses stay compatible as endpoints evolve
- Repeatability: tests run locally and in CI with the same results
In this hands-on guide, you’ll build a practical test setup using pytest, httpx (a modern HTTP client), and jsonschema for contract validation. The examples assume you’re testing an API with endpoints like:
POST /auth/login→ returns an access tokenGET /users/me→ returns the current userPOST /projects→ creates a projectGET /projects/{id}→ fetches a project
1) Project Setup: Dependencies and Test Layout
Create a folder like this:
api-tests/ requirements.txt pytest.ini tests/ conftest.py test_auth.py test_users.py test_projects.py schemas.py
requirements.txt:
pytest==8.3.2 httpx==0.27.0 python-dotenv==1.0.1 jsonschema==4.23.0
pytest.ini (nice defaults):
[pytest] testpaths = tests addopts = -q
Install deps:
python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt
2) Configuration: Base URL and Credentials via Environment
Hard-coding URLs and passwords makes tests brittle and unsafe. Put configuration in environment variables and optionally load a local .env file for dev.
Create .env (do not commit it):
API_BASE_URL=http://localhost:8080 [email protected] API_PASSWORD=demo-password
Now build reusable fixtures in tests/conftest.py:
import os import pytest import httpx from dotenv import load_dotenv load_dotenv() def _env(name: str) -> str: value = os.getenv(name) if not value: raise RuntimeError(f"Missing env var: {name}") return value @pytest.fixture(scope="session") def base_url() -> str: return _env("API_BASE_URL").rstrip("/") @pytest.fixture(scope="session") def client(base_url: str): # Using a session-scoped client is faster and keeps connection pooling. with httpx.Client(base_url=base_url, timeout=10.0) as c: yield c @pytest.fixture(scope="session") def credentials(): return { "email": _env("API_EMAIL"), "password": _env("API_PASSWORD"), }
Key idea: fixtures keep your tests short and consistent.
3) Login Once, Reuse Token Everywhere
Most APIs need auth. Instead of logging in for every test, log in once per test session and reuse the token.
tests/test_auth.py:
import pytest @pytest.fixture(scope="session") def access_token(client, credentials) -> str: res = client.post("/auth/login", json=credentials) assert res.status_code == 200, res.text data = res.json() assert "access_token" in data assert isinstance(data["access_token"], str) assert len(data["access_token"]) > 10 return data["access_token"]
Then expose an auth_headers fixture in conftest.py (or in test_auth.py and import it):
@pytest.fixture(scope="session") def auth_headers(access_token: str): return {"Authorization": f"Bearer {access_token}"}
This pattern makes your suite faster and avoids rate-limiting your own auth endpoint.
4) Contract Testing with JSON Schema (Simple, Powerful)
“It returned 200” is not enough. You want to ensure the payload shape stays stable. JSON Schema is a practical way to do that without heavyweight tooling.
tests/schemas.py:
USER_SCHEMA = { "type": "object", "required": ["id", "email", "name"], "properties": { "id": {"type": "integer"}, "email": {"type": "string"}, "name": {"type": "string"}, }, "additionalProperties": True } PROJECT_SCHEMA = { "type": "object", "required": ["id", "name", "owner_id"], "properties": { "id": {"type": "integer"}, "name": {"type": "string", "minLength": 1}, "owner_id": {"type": "integer"}, }, "additionalProperties": True }
Now validate API responses against the schema. tests/test_users.py:
from jsonschema import validate from tests.schemas import USER_SCHEMA def test_get_me(client, auth_headers): res = client.get("/users/me", headers=auth_headers) assert res.status_code == 200, res.text data = res.json() validate(instance=data, schema=USER_SCHEMA) # Optional: add a small business rule check assert "@" in data["email"]
If someone “helpfully” renames owner_id to ownerId or removes name, the schema test fails immediately.
5) Hands-On CRUD: Create, Fetch, and Validate
Let’s test creating a project and then fetching it back. Add cleanup-friendly patterns and strong assertions.
tests/test_projects.py:
from jsonschema import validate from tests.schemas import PROJECT_SCHEMA def test_create_and_get_project(client, auth_headers): payload = {"name": "API Test Project"} create_res = client.post("/projects", json=payload, headers=auth_headers) assert create_res.status_code == 201, create_res.text created = create_res.json() validate(instance=created, schema=PROJECT_SCHEMA) assert created["name"] == payload["name"] project_id = created["id"] get_res = client.get(f"/projects/{project_id}", headers=auth_headers) assert get_res.status_code == 200, get_res.text fetched = get_res.json() validate(instance=fetched, schema=PROJECT_SCHEMA) assert fetched["id"] == project_id assert fetched["name"] == payload["name"]
Tip: Don’t over-assert. You want stability, not tests that break because of unrelated fields like updated_at.
6) Negative Tests: Prove Your API Fails Correctly
The best APIs fail predictably. Add tests for unauthorized access and validation errors. This catches regressions in middleware and request validation.
import pytest @pytest.mark.parametrize("headers", [{}, {"Authorization": "Bearer invalid"}]) def test_projects_requires_auth(client, headers): res = client.post("/projects", json={"name": "Nope"}, headers=headers) assert res.status_code in (401, 403) def test_create_project_validation_error(client, auth_headers): # empty name should be rejected res = client.post("/projects", json={"name": ""}, headers=auth_headers) assert res.status_code == 422 or res.status_code == 400 data = res.json() # Adjust to your API style: this is a common minimum expectation assert isinstance(data, dict)
These tests prevent accidental “open endpoints” and ensure validation stays enforced.
7) Reduce Flakiness: Timeouts, Retries (Carefully), and Deterministic Data
API tests get flaky when they depend on time, random data, or non-isolated state. Practical rules:
- Use explicit timeouts (already set on the client fixture).
- Prefer deterministic names, but avoid collisions by adding a suffix.
- Don’t add retries by default; fix the root cause. If you must retry, do it for known eventual-consistency endpoints only.
If you need unique data without randomness chaos, use a predictable suffix:
import time def unique_name(prefix="project"): return f"{prefix}-{int(time.time())}"
Use it in the create payload to avoid “already exists” errors in shared environments.
8) Running the Suite Locally and in CI
Run:
export API_BASE_URL=http://localhost:8080 export [email protected] export API_PASSWORD=demo-password pytest
In CI, set the same environment variables as secrets/variables. The important point is that your tests do not assume localhost; they follow API_BASE_URL.
If your API runs in Docker, you can point tests at a container hostname (like http://api:8080) from another container, or at a published port (like http://127.0.0.1:8080) depending on how your pipeline is wired.
Practical Checklist
- Use fixtures for
client,auth_headers, and config. - Assert status codes and validate response shape with JSON Schema.
- Test the happy path and at least one negative path per endpoint.
- Keep tests deterministic: avoid timing assumptions and shared state collisions.
- Make tests portable: everything configurable via environment variables.
Once you have this base in place, you can grow into broader coverage (pagination, filtering, rate limits), data seeding, and parallel test execution. But even this “small” suite will catch a surprising number of real-world bugs before they hit production.
Leave a Reply