How to structure a pytest API testing framework
A clean pytest API testing framework structure, layered fixtures, a thin client wrapper, data builders, markers, and env-based config that scales past a few dozen tests.
Most pytest API suites start as a folder of requests.get(...) calls and collapse under their own weight around test fifty. The fix isn’t a bigger tool, it’s structure. Here’s the layout I install, and why each layer earns its place.
Start with a layout that separates concerns
tests/
conftest.py # session + auth fixtures, base config
clients/
api_client.py # thin wrapper over requests.Session
builders/
project.py # test-data factories
api/
test_projects.py
test_auth.py
pyproject.toml # markers, addopts
Tests state intent. Clients handle transport. Builders handle data. Keep those three apart and the suite stays readable at 500 tests.
A thin client, not raw requests everywhere
Wrap the HTTP layer once so auth, base URL, and error handling live in a single place:
# clients/api_client.py
class ApiClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {token}"
def create_project(self, name: str):
r = self.session.post(f"{self.base_url}/projects", json={"name": name})
r.raise_for_status()
return r.json()
When the auth scheme changes, you edit one file, not two hundred tests.
Layer your fixtures
Config comes from the environment (never hardcode a base URL). Expensive setup is session-scoped; per-test data is function-scoped:
# conftest.py
@pytest.fixture(scope="session")
def base_url():
return os.environ["API_BASE_URL"]
@pytest.fixture(scope="session")
def client(base_url):
token = fetch_token(base_url) # once per run
return ApiClient(base_url, token)
@pytest.fixture
def project(client):
p = client.create_project(f"test-{uuid4().hex[:8]}")
yield p
client.delete_project(p["id"]) # teardown, always
The yield + teardown is what keeps runs isolated and re-runnable, the single biggest cause of flaky API suites is tests inheriting each other’s data.
Builders over fixtures for varied data
When a test needs a specific shape, a factory reads better than ten near-identical fixtures:
# builders/project.py
def a_project(**overrides):
return {"name": "Acme", "plan": "free", **overrides}
# in a test
client.create_project_raw(a_project(plan="enterprise"))
Markers and config make CI selective
Tag slow or external tests so CI can stage them:
# pyproject.toml
[tool.pytest.ini_options]
markers = ["smoke: critical path", "slow: excluded from PR runs"]
addopts = "-ra --strict-markers"
pytest -m smoke # fast gate on every PR
pytest -m "not slow" # everything but the long tail
Parametrize instead of copy-pasting
@pytest.mark.parametrize("payload,status", [
({"name": "ok"}, 201),
({"name": ""}, 422),
({}, 422),
])
def test_create_validation(client, payload, status):
assert client.create_project_raw(payload).status_code == status
One test, three cases, one place to add the fourth.
That’s the whole skeleton: intent, transport, and data as separate layers, env-based config, teardown you can trust, and markers that let CI stage the run. If you’d rather have it built and wired into your pipeline for your API, that’s the QA Foundation Sprint, and the same structure carries straight over to testing AI features, where the assertions get swapped for evals.