green builds

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.

One bad regression away from a lost week.

Book a 20-minute intro call. I’ll tell you honestly whether I can help and what the right next step is: audit, sprint, or nothing yet.