Assertion Pattern, Test Template Pattern, Data-Driven Pattern
Assertion Pattern
Concept
Centralise assertion logic in reusable helpers or custom matchers to: - Avoid duplication of complex assertion blocks - Provide meaningful error messages - Version assertion logic alongside the schema
Custom Assertion Helpers
import httpx
def assert_created(response: httpx.Response, *, id_field: str = "id") -> dict:
assert response.status_code == 201, (
f"Expected 201 Created, got {response.status_code}: {response.text}"
)
body = response.json()
assert id_field in body, f"Response missing '{id_field}' field: {body}"
return body
def assert_validation_error(response: httpx.Response, *, field: str) -> None:
assert response.status_code == 422, (
f"Expected 422 Unprocessable, got {response.status_code}"
)
errors = response.json().get("detail", [])
fields = [e["loc"][-1] for e in errors if isinstance(e.get("loc"), list)]
assert field in fields, f"Expected validation error on '{field}', got errors on: {fields}"
def assert_paginated(response: httpx.Response, *, min_items: int = 0) -> dict:
assert response.status_code == 200
body = response.json()
for key in ("items", "total", "page", "size"):
assert key in body, f"Paginated response missing '{key}'"
assert len(body["items"]) >= min_items
return body
Pytest Custom Matchers (pytest-check style)
import pytest
class ResponseMatcher:
def __init__(self, response: httpx.Response) -> None:
self._r = response
def has_status(self, code: int) -> "ResponseMatcher":
assert self._r.status_code == code, (
f"Expected status {code}, got {self._r.status_code}\nBody: {self._r.text}"
)
return self
def has_field(self, key: str) -> "ResponseMatcher":
assert key in self._r.json(), f"Response missing field '{key}'"
return self
def field_equals(self, key: str, value) -> "ResponseMatcher":
actual = self._r.json().get(key)
assert actual == value, f"Field '{key}': expected {value!r}, got {actual!r}"
return self
Test Template Pattern
Concept
A test template defines a reusable flow (sequence of steps) that parameterises the variable parts, ensuring all scenarios follow the same structure.
Useful for: CRUD flows, auth scenarios, validation sweeps.
Base Template Class
import abc
import httpx
class CrudTestTemplate(abc.ABC):
@abc.abstractmethod
def create_payload(self) -> dict: ...
@abc.abstractmethod
def update_payload(self) -> dict: ...
@abc.abstractmethod
def resource_path(self) -> str: ...
def run_crud_flow(self, client: httpx.Client) -> None:
# Create
res = client.post(self.resource_path(), json=self.create_payload())
assert res.status_code == 201
resource_id = res.json()["id"]
# Read
res = client.get(f"{self.resource_path()}/{resource_id}")
assert res.status_code == 200
# Update
res = client.patch(f"{self.resource_path()}/{resource_id}", json=self.update_payload())
assert res.status_code == 200
# Delete
res = client.delete(f"{self.resource_path()}/{resource_id}")
assert res.status_code == 204
class UserCrudTest(CrudTestTemplate):
def create_payload(self) -> dict:
return UserBuilder().as_dict()
def update_payload(self) -> dict:
return {"username": "updated-name"}
def resource_path(self) -> str:
return "/users"
Data-Driven Pattern
Concept
Data-driven tests separate test logic from test data. The same test function runs with multiple data sets via parameterisation.
pytest.mark.parametrize
import pytest
VALID_EMAILS = ["user@example.com", "user+tag@example.co.uk", "u@x.io"]
INVALID_EMAILS = ["notanemail", "@nodomain", "missing@", "double@@domain.com"]
@pytest.mark.parametrize("email", VALID_EMAILS)
def test_valid_email_accepted(api_client, email):
response = api_client.post("/users", json=UserBuilder().email(email).as_dict())
assert response.status_code == 201
@pytest.mark.parametrize("email", INVALID_EMAILS)
def test_invalid_email_rejected(api_client, email):
response = api_client.post("/users", json=UserBuilder().email(email).as_dict())
assert_validation_error(response, field="email")
External Data Sources
import json
import pathlib
import pytest
DATA_DIR = pathlib.Path(__file__).parent / "data"
def load_cases(name: str) -> list[dict]:
return json.loads((DATA_DIR / f"{name}.json").read_text())
@pytest.mark.parametrize("case", load_cases("user_validation"))
def test_user_validation(api_client, case):
response = api_client.post("/users", json=case["input"])
assert response.status_code == case["expected_status"]
Data-Driven vs Parameterized
| Approach | Source | Best for |
|---|---|---|
parametrize inline |
Code | Small, stable data sets |
parametrize from function |
Code lists | Medium data sets |
| External JSON / CSV | Files | Large, business-owned data sets |
| Property-based (Hypothesis) | Generated | Edge-case discovery |