Practical API Testing with Pytest + HTTPX: Fast Feedback, Real Confidence
If you’re building APIs, you’re shipping a contract: inputs, outputs, status codes, auth rules, and edge-case behavior. The fastest way to avoid “it worked locally” bugs is to test that contract continuously. This article shows a hands-on workflow for API testing using pytest + httpx (sync or async), with patterns you can drop into most projects.
We’ll cover:
- Project setup and folder structure
- A reusable API client for tests
- Smoke tests, happy paths, and error cases
- Schema validation (lightweight, practical)
- Auth tokens + environment configuration
- Running tests locally and in CI
1) Setup: dependencies and structure
Install the essentials:
pip install pytest httpx python-dotenv jsonschema
A simple, scalable structure:
your-repo/ tests/ conftest.py test_health.py test_users.py test_orders.py schemas/ user.json error.json .env pytest.ini
Optional pytest.ini for nicer output:
[pytest] addopts = -q testpaths = tests
2) Configure environments safely
Hardcoding URLs and tokens in tests is a recipe for pain. Use environment variables and a local .env (ignored by git) for convenience.
.env example:
API_BASE_URL=https://api.example.com API_TOKEN=replace-me
Load these in tests with python-dotenv. We’ll do it in conftest.py, which is a shared pytest config file.
3) Build a tiny API client (so tests stay clean)
Instead of repeating headers and base URLs everywhere, wrap them in a small client. This is not production code—it’s a test helper that keeps your test files readable.
# tests/conftest.py import os import pytest import httpx from dotenv import load_dotenv load_dotenv() class APIClient: def __init__(self, base_url: str, token: str | None = None, timeout: float = 10.0): headers = {} if token: headers["Authorization"] = f"Bearer {token}" self._client = httpx.Client( base_url=base_url, headers=headers, timeout=timeout, ) def get(self, url: str, **kwargs): return self._client.get(url, **kwargs) def post(self, url: str, json=None, **kwargs): return self._client.post(url, json=json, **kwargs) def put(self, url: str, json=None, **kwargs): return self._client.put(url, json=json, **kwargs) def delete(self, url: str, **kwargs): return self._client.delete(url, **kwargs) def close(self): self._client.close() @pytest.fixture(scope="session") def api(): base_url = os.getenv("API_BASE_URL", "").strip() token = os.getenv("API_TOKEN", "").strip() or None if not base_url: raise RuntimeError("API_BASE_URL is not set. Put it in .env or export it.") client = APIClient(base_url=base_url, token=token) yield client client.close()
Why this helps: tests become focused on behavior, not boilerplate.
4) Start with smoke tests: “is the API alive?”
Smoke tests are fast checks that tell you whether it’s even worth running the rest.
# tests/test_health.py def test_health_endpoint(api): r = api.get("/health") assert r.status_code == 200 data = r.json() assert data["status"] in ("ok", "healthy")
Keep smoke tests tiny and stable. If this fails, you want a clear signal: the service is down, misconfigured, or unreachable.
5) Test a real workflow: create, fetch, validate, cleanup
Good API tests cover end-to-end behavior. Here’s a typical “user resource” flow.
# tests/test_users.py import uuid def test_create_and_get_user(api): email = f"test-{uuid.uuid4()}@example.com" payload = { "name": "Test User", "email": email } # Create r = api.post("/users", json=payload) assert r.status_code == 201, r.text created = r.json() assert "id" in created assert created["email"] == email user_id = created["id"] # Fetch r2 = api.get(f"/users/{user_id}") assert r2.status_code == 200, r2.text fetched = r2.json() assert fetched["id"] == user_id assert fetched["email"] == email # Cleanup (if API supports it) r3 = api.delete(f"/users/{user_id}") assert r3.status_code in (200, 204), r3.text
Notes for junior/mid devs:
- Use random test data (
uuid) to avoid collisions. - Assert on status codes and key fields, not the whole JSON (which may include timestamps, etc.).
- Cleanup makes tests repeatable and avoids polluting staging.
6) Error cases: test the “unhappy paths” on purpose
Error-handling is part of the contract. Don’t just test success.
# tests/test_users.py def test_create_user_requires_email(api): payload = {"name": "No Email User"} r = api.post("/users", json=payload) assert r.status_code in (400, 422), r.text data = r.json() # Your API may use different keys; adapt these asserts accordingly: assert "error" in data or "message" in data or "errors" in data
If your API uses a consistent error format, you can validate it across all tests (next section).
7) Lightweight JSON schema validation (practical, not overkill)
Schema validation is great for catching unexpected shape changes. You don’t need a massive framework—just validate critical objects.
Create tests/schemas/user.json:
{ "type": "object", "required": ["id", "name", "email"], "properties": { "id": { "type": ["string", "integer"] }, "name": { "type": "string" }, "email": { "type": "string" } }, "additionalProperties": true }
Then validate in a test:
# tests/test_users.py import json from pathlib import Path from jsonschema import validate
def load_schema(name: str):
path = Path(file).parent / "schemas" / name
return json.loads(path.read_text(encoding="utf-8"))
def test_user_schema(api):
r = api.get("/users/1")
assert r.status_code == 200, r.text
schema = load_schema("user.json")
validate(instance=r.json(), schema=schema)
Tip: keep schemas small and focused on what matters. Don’t try to lock down every optional field unless you truly need it.
8) Auth patterns: tokens, refresh, and role-based tests
Most APIs need auth. A simple approach is using an existing token via API_TOKEN. If your API supports logging in to get a token, you can generate it at test time.
# tests/conftest.py (alternative token creation pattern) import os import pytest import httpx @pytest.fixture(scope="session") def token(): # If you already have a token, prefer env var: existing = os.getenv("API_TOKEN", "").strip() if existing: return existing base_url = os.getenv("API_BASE_URL", "").strip() username = os.getenv("API_USERNAME", "").strip() password = os.getenv("API_PASSWORD", "").strip() if not (base_url and username and password): raise RuntimeError("Set API_TOKEN or API_USERNAME/API_PASSWORD for login.") with httpx.Client(base_url=base_url, timeout=10.0) as c: r = c.post("/auth/login", json={"username": username, "password": password}) r.raise_for_status() return r.json()["access_token"]
Then pass token into your APIClient fixture (same idea as before). You can also create multiple fixtures for roles (admin vs. user) to test permissions reliably.
9) Make tests stable: retries, timeouts, and test data strategy
Flaky API tests are usually caused by environment issues, shared data, or slow dependencies. A few practical rules:
- Use unique data for creates (UUID emails, random order IDs, etc.).
- Assert on what you control (IDs, required fields, state transitions).
- Keep timeouts explicit so hangs fail fast (we set
timeout=10). - Prefer cleanup or test-only endpoints/tenants in staging.
- Separate “unit tests” and “integration tests” if you can. Integration tests can run on PRs; full end-to-end can run nightly.
10) Run locally and in CI
Local run:
pytest
Run with environment variables (example):
API_BASE_URL=https://staging.api.example.com API_TOKEN=... pytest
In CI, store secrets in your CI provider (GitHub Actions, GitLab CI, etc.) and export them as env vars. The key is consistency: the same tests should run locally and in CI with only configuration changing.
What to build next
Once you have this baseline, you can expand confidently:
- Add
markerslike@pytest.mark.smokeand@pytest.mark.integrationto control what runs when. - Validate pagination contracts (e.g.,
limit/cursorbehavior) and ensure sorting is stable. - Add “contract tests” for critical endpoints: response schema + status codes for a matrix of inputs.
- Test idempotency: send the same request twice and verify expected behavior.
With a small client, clear fixtures, and a handful of high-value tests, API testing stops feeling like busywork—and starts acting like a safety net you actually trust.
Leave a Reply