An API returning 200 is the start of an assertion, not the end. Verify the response contract, the stored state, and the caller’s authority. Keep most local business-rule tests close to the code; use API and contract tests at service boundaries and a smaller set of browser journeys for critical user flows. The distribution depends on system risk, not a fixed pyramid percentage.
Give each scenario an isolated lifecycle
The integration example below assumes a staging API where POST /items returns 201 with an id, GET returns the stored item, and DELETE returns 204. Supply a session authorized for a synthetic test account. Replace this contract with your service’s documented behavior.
from uuid import uuid4
def test_item_lifecycle(session, base_url):
collection = base_url.rstrip("/") + "/items"
payload = {"name": f"test-{uuid4().hex}", "price": 199}
created = session.post(collection, json=payload, timeout=10)
created.raise_for_status()
item_id = created.json()["id"]
item_url = f"{collection}/{item_id}"
try:
assert created.status_code == 201
response = session.get(item_url, timeout=10)
assert response.status_code == 200
stored = response.json()
assert stored["id"] == item_id
assert stored["name"] == payload["name"]
assert stored["price"] == payload["price"]
finally:
deleted = session.delete(item_url, timeout=10)
assert deleted.status_code == 204If creation succeeds but returns malformed JSON, this example cannot discover the identifier for cleanup. Real fixtures should also support cleanup by the unique test prefix or an isolated tenant. Report cleanup failures alongside the original assertion failure so neither is lost.
Keep the HTTP wrapper small
import requests
class APIClient:
def __init__(self, base_url):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
def request(self, method, path, **kwargs):
kwargs.setdefault("timeout", 10)
url = self.base_url + "/" + path.lstrip("/")
return self.session.request(method, url, **kwargs)
def close(self):
self.session.close()A fixture should close the client and supply credentials through approved configuration. Log request identifiers, status, and sanitized response details. Avoid recording authorization headers or complete customer payloads. A Requests timeout bounds connection/read waiting; it is not a universal total-duration deadline.
Test the boundary, not just the happy path
Separate latency observations from load tests
from time import perf_counter
def check_health(session, base_url, budget_seconds):
started = perf_counter()
response = session.get(base_url.rstrip("/") + "/health", timeout=10)
response.raise_for_status()
elapsed = perf_counter() - started
assert elapsed < budget_seconds
return elapsedThis measures a single request through response-body consumption. Choose a budget from the environment and service requirement. A sequential loop is a smoke check, not a realistic load test: use a load tool with explicit concurrency, arrival rate, warmup, and percentile reporting when capacity is the question.

