API Testing · lesson 3 of 11
Fixtures, Sessions, and Parametrize for API Tests
Stop repeating BASE_URL and header setup in every test — share a requests.Session through a pytest fixture, parametrize across cases, and read the ScopeMismatch error that follows.
By SkillByExample · Published
Every example so far repeated BASE_URL and rebuilt the request from scratch. Fixtures are
pytest’s answer to that repetition.
A shared session fixture
# conftest.py
import pytest
import requests
BASE_URL = "https://httpbin.org"
@pytest.fixture
def api_session():
session = requests.Session()
session.headers.update({"Accept": "application/json"})
yield session
session.close()
conftest.py is special to pytest — fixtures defined there are visible to every test file in
the same directory, no import needed.
# test_with_fixture.py
BASE_URL = "https://httpbin.org"
def test_get_with_shared_session(api_session):
response = api_session.get(f"{BASE_URL}/get")
assert response.status_code == 200
assert response.json()["headers"]["Accept"] == "application/json"
pytest test_with_fixture.py -v
test_with_fixture.py::test_get_with_shared_session PASSED [100%]
============================== 1 passed in 1.19s ==============================
pytest sees the api_session parameter, matches it to the fixture by name, runs everything
before yield as setup, hands the session to the test, then runs everything after yield —
session.close() — as teardown, whether the test passed or failed.
Parametrize: one test, many inputs
import pytest
BASE_URL = "https://httpbin.org"
@pytest.mark.parametrize("status_code", [200, 201, 204, 301, 404, 500])
def test_status_endpoint_returns_requested_code(api_session, status_code):
response = api_session.get(f"{BASE_URL}/status/{status_code}", allow_redirects=False)
assert response.status_code == status_code
pytest test_parametrize.py -v
test_parametrize.py::test_status_endpoint_returns_requested_code[200] PASSED [ 16%]
test_parametrize.py::test_status_endpoint_returns_requested_code[201] PASSED [ 33%]
test_parametrize.py::test_status_endpoint_returns_requested_code[204] PASSED [ 50%]
test_parametrize.py::test_status_endpoint_returns_requested_code[301] PASSED [ 66%]
test_parametrize.py::test_status_endpoint_returns_requested_code[404] PASSED [ 83%]
test_parametrize.py::test_status_endpoint_returns_requested_code[500] PASSED [100%]
============================== 6 passed in 6.36s ==============================
Six status codes, one test function, six separate results — each one individually addressable
with pytest -k 500 if it fails. allow_redirects=False matters for 301: requests follows
redirects by default, which would silently turn a 301 test into a 200 test against whatever the
redirect pointed to.
A failure that names the exact case
@pytest.mark.parametrize("requested,expected", [(200, 200), (404, 404), (500, 501)])
def test_status_endpoint_matches_expected(api_session, requested, expected):
response = api_session.get(f"{BASE_URL}/status/{requested}")
assert response.status_code == expected
test_parametrize_bug.py::test_status_endpoint_matches_expected[200-200] PASSED [ 33%]
test_parametrize_bug.py::test_status_endpoint_matches_expected[404-404] PASSED [ 66%]
test_parametrize_bug.py::test_status_endpoint_matches_expected[500-501] FAILED [100%]
================================== FAILURES ===================================
_______________ test_status_endpoint_matches_expected[500-501] ________________
api_session = <requests.sessions.Session object at 0x000002A17FCAA210>
requested = 500, expected = 501
@pytest.mark.parametrize("requested,expected", [(200, 200), (404, 404), (500, 501)])
def test_status_endpoint_matches_expected(api_session, requested, expected):
response = api_session.get(f"{BASE_URL}/status/{requested}")
> assert response.status_code == expected
E assert 500 == 501
E + where 500 = <Response [500]>.status_code
test_parametrize_bug.py:9: AssertionError
The test ID [500-501] in the failure line names the exact parameter pair without opening the
file — the third case’s expected value was simply typed wrong. Two passed, one failed, and
the report tells you which one without needing to bisect.
The fixture scope mismatch
Fixtures can depend on other fixtures. Build an authenticated session on top of api_session:
@pytest.fixture(scope="module")
def auth_session(api_session):
api_session.headers.update({"Authorization": "Bearer test-token"})
return api_session
def test_session_scoped_fixture_keeps_header(auth_session):
response = auth_session.get(f"{BASE_URL}/get")
assert response.json()["headers"]["Authorization"] == "Bearer test-token"
ERROR at setup of test_session_scoped_fixture_keeps_header
ScopeMismatch: You tried to access the function scoped fixture api_session with a module
scoped request object. Requesting fixture stack:
test_practice.py:6: def auth_session(api_session)
Requested fixture:
conftest.py:7: def api_session()
auth_session asked to live for the whole module, but it depends on api_session, which is
torn down after every single test. pytest can’t satisfy both lifetimes, so it refuses to even
run the test — this is a collection-time error, not a test failure. The fix is matching
scopes: give api_session the same scope="module".
@pytest.fixture(scope="module")
def api_session():
session = requests.Session()
session.headers.update({"Accept": "application/json"})
yield session
session.close()
@pytest.fixture(scope="module")
def auth_session(api_session):
api_session.headers.update({"Authorization": "Bearer test-token"})
return api_session
test_scope_fixed.py::test_session_scoped_fixture_keeps_header PASSED [100%]
============================== 1 passed in 1.25s ==============================
A fixture can only depend on fixtures with an equal or wider scope than itself — never a narrower one.
Practice
1. Parametrize a test over all four HTTP verbs using getattr.
@pytest.mark.parametrize("method_name", ["get", "post", "put", "delete"])
def test_each_verb_reaches_its_matching_endpoint(api_session, method_name):
method = getattr(api_session, method_name)
response = method(f"{BASE_URL}/{method_name}")
assert response.status_code == 200
test_practice.py::test_each_verb_reaches_its_matching_endpoint[get] PASSED [ 14%]
test_practice.py::test_each_verb_reaches_its_matching_endpoint[post] PASSED [ 28%]
test_practice.py::test_each_verb_reaches_its_matching_endpoint[put] PASSED [ 42%]
test_practice.py::test_each_verb_reaches_its_matching_endpoint[delete] PASSED [ 57%]
getattr(session, "get") looks up the bound method by name — this is how one parametrized test
covers every verb without four near-duplicate functions.
2. Parametrize a test with two params at once: a query dict and its expected echo.
@pytest.mark.parametrize(
"params,expected_args",
[
({"q": "books"}, {"q": "books"}),
({"q": "books", "page": 2}, {"q": "books", "page": "2"}),
],
)
def test_query_combinations(api_session, params, expected_args):
response = api_session.get(f"{BASE_URL}/get", params=params)
assert response.json()["args"] == expected_args
test_practice.py::test_query_combinations[params0-expected_args0] PASSED [ 71%]
test_practice.py::test_query_combinations[params1-expected_args1] PASSED [ 85%]
Each tuple in the parametrize list becomes one test case; pytest names the ones it can’t
render nicely (a dict) params0, params1, and so on.
3. Write a test confirming the api_session fixture's headers survive within one test.
def test_fixture_teardown_closes_the_session(api_session):
response = api_session.get(f"{BASE_URL}/get")
assert response.status_code == 200
assert api_session.headers["Accept"] == "application/json"
test_practice.py::test_fixture_teardown_closes_the_session PASSED [100%]
The header set in the fixture is still there mid-test — teardown (session.close()) only runs
after the test function returns, confirmed by pytest’s exit code here being 0.
Next: asserting on the shape of a JSON response, not just individual fields.