Advanced Framework Patterns
Fluent Interface
Fluent interfaces chain calls to read like a sentence. Used for builders, request construction, and assertion chains.
class OrderBuilder:
def __init__(self) -> None:
self._items: list = []
self._user_id: str | None = None
self._promo_code: str | None = None
def for_user(self, user_id: str) -> "OrderBuilder":
self._user_id = user_id
return self
def with_item(self, sku: str, qty: int = 1) -> "OrderBuilder":
self._items.append({"sku": sku, "quantity": qty})
return self
def with_promo(self, code: str) -> "OrderBuilder":
self._promo_code = code
return self
def build(self) -> dict:
return {
"user_id": self._user_id,
"items": self._items,
"promo_code": self._promo_code,
}
order = (
OrderBuilder()
.for_user(user.id)
.with_item("WIDGET-001", qty=2)
.with_item("GADGET-007")
.with_promo("SAVE10")
.build()
)
Fluent chaining risks:
- Mutable object — do not reuse builders across tests
- No validation at with_* time — catch missing required fields in build()
Wrapper Pattern
Wraps a third-party library to isolate the framework from it.
Swapping requests for httpx touches one file, not 200 tests.
import httpx
import logging
log = logging.getLogger(__name__)
class HttpDriver:
def __init__(self, base_url: str, timeout: int = 30) -> None:
self._client = httpx.Client(base_url=base_url, timeout=timeout)
def get(self, path: str, **kwargs) -> httpx.Response:
log.debug("GET %s", path)
response = self._client.get(path, **kwargs)
log.debug("Response %s: %s", response.status_code, path)
return response
def post(self, path: str, **kwargs) -> httpx.Response:
log.debug("POST %s payload=%s", path, kwargs.get("json"))
response = self._client.post(path, **kwargs)
log.debug("Response %s: %s", response.status_code, path)
return response
def close(self) -> None:
self._client.close()
Custom Assertion Helpers
Move repeated assertion logic out of tests into named, reusable functions. Named assertions produce better failure messages.
def assert_user_created(response, expected_email: str) -> None:
assert response.status_code == 201, (
f"User creation failed with {response.status_code}: {response.text}"
)
body = response.json()
assert body["email"] == expected_email, (
f"Email mismatch: expected {expected_email!r}, got {body['email']!r}"
)
assert "id" in body, "Response missing 'id' field"
def assert_validation_error(response, field: str) -> None:
assert response.status_code == 422, (
f"Expected 422 validation error, got {response.status_code}"
)
errors = response.json().get("errors", [])
field_errors = [e for e in errors if e.get("field") == field]
assert field_errors, f"No validation error for field '{field}' in {errors}"
Template Test Base Class
When many tests share identical setup/teardown, a base class avoids conftest sprawl. Use sparingly — overuse couples tests to the base class.
import pytest
class BaseApiTest:
@pytest.fixture(autouse=True)
def _setup(self, api_client, env_config):
self.api = api_client
self.config = env_config
def assert_success(self, response) -> None:
assert response.status_code < 400, (
f"Unexpected error {response.status_code}: {response.text}"
)
class TestUserApi(BaseApiTest):
def test_get_user_returns_200(self, verified_user):
response = self.api.get(f"/users/{verified_user['id']}")
self.assert_success(response)
Data-Driven Tests with Parametrize
Run the same test logic with multiple inputs. One function, many scenarios.
import pytest
INVALID_EMAILS = [
("no-at-sign", "missing @ symbol"),
("@nodomain", "missing local part"),
("user@", "missing domain"),
("user @domain.com", "space in email"),
("", "empty string"),
]
@pytest.mark.parametrize("email,reason", INVALID_EMAILS)
def test_invalid_email_rejected(api_client, email, reason):
response = api_client.post("/users", json={"email": email})
assert response.status_code == 422, (
f"Expected rejection for '{email}' ({reason}), "
f"got {response.status_code}"
)
When to use parametrize vs separate tests:
- Same assertion logic, different inputs → parametrize
- Different assertion logic or different setup → separate tests
- More than 7 parameter sets → consider loading from external YAML/JSON