Skip to main content
API Testing · lesson 3 of 11
API Testing beginner 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

api-testing python pytest fixtures requests

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.

Frequently Asked Questions

Why use a requests.Session instead of calling requests.get() directly?
A Session reuses the underlying TCP connection across requests (HTTP keep-alive), which is measurably faster for a suite making dozens of calls, and it lets you set headers or auth once instead of passing them to every call. A fixture is the natural place to build and tear one down.
What does a fixture scope actually control?
How often pytest recreates the fixture. `function` (the default) creates a fresh instance per test; `module` creates one per test file; `session` creates one for the entire pytest run. A narrower scope is safer — no state leaks between tests — but a wider one is faster when setup is expensive, like opening a real network connection.
Why did depending on a function-scoped fixture from a module-scoped one raise ScopeMismatch?
pytest tears down a function-scoped fixture after every test, but a module-scoped fixture built on top of it is only created once, at the start of the module — there's no point in the run where both lifetimes are compatible, so pytest refuses to even start. The fix is matching scopes: a module-scoped fixture can only depend on fixtures with an equal or wider scope.
When should I use parametrize instead of a for loop inside one test?
parametrize turns each case into its own reported test, so pytest -v shows exactly which input failed and pytest -k can target one case directly. A for loop over the same inputs stops at the first failure and hides the rest — you'd fix one bug, rerun, and discover the next one instead of seeing all of them at once.
Should fixtures live in conftest.py or in the test file?
conftest.py fixtures are visible to every test file in that directory and its subdirectories without an import — use it for setup shared across files, like the session fixture here. Fixtures used by only one file can live in that file; there's no need to centralize something nothing else uses.