Most tests share setup. They need an authenticated API client, a clean database, a freshly launched browser. They also tend to repeat themselves — the same shape of test, ten times, with different inputs. pytest's two killer features handle both: fixtures for shared setup/teardown and parametrize for data-driven tests. This lesson covers both, plus conftest.py, fixture scope, autouse, and the patterns you'll use to avoid boilerplate in real test code.
What a fixture is
A fixture is a function decorated with @pytest.fixture that produces a value tests can ask for by parameter name:
import pytest
@pytest.fixture
def admin_user():
return {"name": "Alice", "email": "alice@test.com", "role": "admin"}
def test_admin_has_role(admin_user):
assert admin_user["role"] == "admin"
def test_admin_email_format(admin_user):
assert "@" in admin_user["email"]Read it in two pieces:
@pytest.fixturemarks the function as a fixture. It's a normal Python function otherwise.- The test parameter
admin_usermatches the fixture's name. pytest sees the parameter, callsadmin_user(), and passes the return value into the test.
This is dependency injection — you ask for what you need, pytest figures out how to make it. No setUp() to remember; no global state to reset.
yield for setup and teardown
For fixtures that need cleanup (close a browser, drop a temp DB), use yield instead of return:
import pytest
from helpers.api_client import ApiClient
@pytest.fixture
def api_client():
client = ApiClient("https://staging.api.example.com")
client.login("admin@test.com", "password")
yield client # ← test runs here
client.logout() # teardown
def test_get_users(api_client):
users = api_client.get("/users")
assert len(users) > 0Code before yield is setup; the fixture's value is whatever you yield; code after yield is teardown — guaranteed to run even if the test fails.
yield vs return: a return fixture is fine if there's nothing to clean up; the moment you need teardown, switch to yield. The shape on the test side is identical.
Fixture scope — share work across tests
By default fixtures run per test (scope="function"). Pass a different scope when the setup is expensive:
@pytest.fixture(scope="session")
def base_url():
return "https://staging.myapp.com"
@pytest.fixture(scope="session")
def browser():
pw = sync_playwright().start()
chromium = pw.chromium.launch()
yield chromium
chromium.close()
pw.stop()Five scopes, from narrowest to widest:
| Scope | Lives for… |
|---|---|
function | One test (the default) |
class | All tests in one class |
module | All tests in one .py file |
package | All tests in one package |
session | The entire test run |
The trade-off is isolation versus speed. function scope guarantees no test pollutes another; session scope is cheapest but means a misbehaving test can corrupt state for everything that follows.
A common pattern: launch the browser at session scope, but create a fresh browser context (incognito-style) per test. Speed plus isolation.
conftest.py — share fixtures without imports
Fixtures live where you can use them. Put them in a conftest.py next to (or above) your tests, and pytest will auto-discover them — no import needed:
my_tests/
├── conftest.py
├── tests/
│ ├── conftest.py
│ ├── test_login.py
│ └── test_users.py
# my_tests/conftest.py — shared by every test
import pytest
@pytest.fixture(scope="session")
def base_url():
return "https://staging.myapp.com"# my_tests/tests/test_login.py
def test_homepage_loads(base_url):
# base_url is visible here even though we didn't import anything
...The deeper rule: when pytest is collecting tests/test_login.py, it walks up the directory tree and registers the fixtures defined in every conftest.py it finds. Fixtures in tests/conftest.py are visible only to tests under tests/; fixtures in the root conftest.py are visible everywhere.
This is the canonical place for shared fixtures — base_url, api_client, browser, db_connection, anything more than one test needs.
Fixtures depend on other fixtures
Fixtures can request fixtures by adding them as parameters — pytest resolves the chain:
@pytest.fixture(scope="session")
def base_url():
return "https://staging.myapp.com"
@pytest.fixture(scope="session")
def api_client(base_url):
client = ApiClient(base_url)
client.login("admin", "pass")
yield client
client.logout()
@pytest.fixture
def admin_session(api_client):
"""A logged-in session, refreshed per test."""
return api_client
def test_get_users(admin_session):
users = admin_session.get("/users")
assert len(users) > 0admin_session requests api_client; api_client requests base_url. pytest builds the dependency graph and runs each fixture exactly once per its scope.
autouse=True — fixtures that always run
Pass autouse=True to make a fixture apply to every test in scope, even tests that don't ask for it:
@pytest.fixture(autouse=True)
def log_test_name(request):
print(f"\n--- {request.node.name} ---")
yield
print(f"--- finished {request.node.name} ---")Use sparingly — autouse is global mutation, the same kind that made setUp/tearDown chains hard to follow in older frameworks. Save it for cross-cutting concerns (logging, timing, environment cleanup) where every test genuinely benefits.
Parametrize — one test, many inputs
@pytest.mark.parametrize turns a single test function into N tests, one per input row:
import pytest
from helpers import login
@pytest.mark.parametrize("email,password,expected", [
("admin@test.com", "AdminPass", "success"),
("user@test.com", "UserPass", "success"),
("wrong@test.com", "WrongPass", "error"),
("", "password", "error"),
("user@test.com", "", "error"),
])
def test_login(email, password, expected):
result = login(email, password)
assert result["status"] == expectedpytest expands this into five separate tests:
test_login[admin@test.com-AdminPass-success] PASSED
test_login[user@test.com-UserPass-success] PASSED
test_login[wrong@test.com-WrongPass-error] PASSED
test_login[-password-error] PASSED
test_login[user@test.com--error] PASSED
Each row is its own test in the report — failures show which row broke, not just "the test failed." That granularity is the entire point.
The first argument is a comma-separated string of parameter names. The second is a list of tuples, one per row. Match the count and order.
Adding readable IDs
The auto-generated test IDs (test_login[admin@test.com-AdminPass-success]) get hard to read with long values. Use pytest.param(..., id="...") to label rows:
@pytest.mark.parametrize("email,password,expected", [
pytest.param("admin@test.com", "AdminPass", "success", id="admin_logs_in"),
pytest.param("", "AdminPass", "error", id="empty_email"),
pytest.param("admin@test.com", "", "error", id="empty_password"),
])
def test_login(email, password, expected):
...Now the report reads test_login[admin_logs_in] — far easier when scanning a failure list.
Stacking multiple parametrize decorators
Two parametrize decorators on one function produce the Cartesian product of inputs:
@pytest.mark.parametrize("browser", ["chromium", "firefox", "webkit"])
@pytest.mark.parametrize("viewport", [(1920, 1080), (1366, 768)])
def test_homepage_loads(browser, viewport):
...That's 3 × 2 = 6 tests — every combination. Useful for browser/resolution matrices; deadly if you accidentally stack three or four sets of variants and end up with hundreds of tests. Watch the multiplication.
Parametrize plus fixtures — the real power
Parametrize provides the data; fixtures provide the infrastructure. The combination is what real test suites are built on:
@pytest.fixture(scope="session")
def api_client(base_url):
return ApiClient(base_url)
@pytest.mark.parametrize("role,can_admin", [
("admin", True),
("tester", False),
("viewer", False),
])
def test_admin_access(api_client, role, can_admin):
user = api_client.create_user(role=role)
response = api_client.get(f"/admin/dashboard", as_user=user)
assert response.ok == can_adminThe session-scoped api_client is built once. parametrize then runs the test three times with different roles. Three tests, one expensive setup, no duplication.
Indirect parametrize on a fixture
You can also parametrize a fixture itself, so every test that uses it runs once per parameter:
@pytest.fixture(params=["chromium", "firefox", "webkit"])
def page(request, playwright):
browser = playwright[request.param].launch()
page = browser.new_page()
yield page
browser.close()
def test_homepage(page): # automatically runs three times
page.goto("https://example.com")
assert "Example" in page.title()Every test that takes page runs once per browser. This is how pytest-playwright works under the hood for --browser chromium --browser firefox.
Parametrize, drawn
@pytest.mark.parametrize expands one test into many
| password | expected | ||
|---|---|---|---|
| row 0 | 'admin@test.com' | 'AdminPass' | 'success' → test PASSED |
| row 1 | 'user@test.com' | 'UserPass' | 'success' → test PASSED |
| row 2 | 'wrong@test.com' | 'WrongPass' | 'error' → test PASSED |
| row 3 | '' (empty) | 'password' | 'error' → test PASSED |
| row 4 | 'user@test.com' | '' (empty) | 'error' → test PASSED |
Five rows in the parametrize, five test instances in the report. When a row fails, the report points at the exact row — which email/password combination broke — not just "the test".
A complete worked example
A small but realistic shape — conftest.py, fixtures with scope, and a parametrized test:
# conftest.py
import pytest
from helpers.api_client import ApiClient
@pytest.fixture(scope="session")
def base_url():
return "https://staging.api.example.com"
@pytest.fixture(scope="session")
def api_client(base_url):
client = ApiClient(base_url)
client.login("admin@test.com", "AdminPass")
yield client
client.logout()
@pytest.fixture
def fresh_user(api_client):
user = api_client.create_user(role="tester")
yield user
api_client.delete_user(user["id"])# tests/test_user_lifecycle.py
import pytest
@pytest.mark.parametrize("role,can_admin", [
pytest.param("admin", True, id="admin"),
pytest.param("tester", False, id="tester"),
pytest.param("viewer", False, id="viewer"),
])
def test_role_grants_admin_access(api_client, fresh_user, role, can_admin):
api_client.update_user(fresh_user["id"], role=role)
response = api_client.get(f"/admin/dashboard", as_user=fresh_user)
assert response.ok == can_adminThree tests, one shared session, one fresh user per row, automatic teardown. Every line is the test; no plumbing.
⚠️ Common mistakes
returninstead ofyieldwhen teardown is needed. A fixture usingreturncannot run cleanup. The browser stays open, the temp file stays on disk. Switch toyieldand put the cleanup after the yield line.- Wrong fixture scope, then wondering why state leaks.
scope="session"means one instance for the whole run — if your test mutates the value, the next test sees the mutation. For mutable per-test state (a fresh user, a clean cart), keep the defaultfunctionscope. - Forgetting that parametrize multiplies fixtures, not just tests. A parametrize on a
function-scope fixture means the fixture is built fresh per parameter. Stack two parametrize decorators on a parametrized fixture and you get setup work multiplied — quickly painful for expensive setups. Keep parametrize on the test, not the heavy fixture.
🎯 Practice task
Write a parametrized suite with fixtures. 30 minutes.
- In your venv, ensure
pytestis installed. - Create
helpers.py. Definedef add(a, b): return a + banddef divide(a, b): return a / b. - Create
conftest.pywith a@pytest.fixturecallednumbersthat returns{"x": 10, "y": 5}. - Create
test_math.py. Write one test that asks for thenumbersfixture and assertsadd(numbers["x"], numbers["y"]) == 15. - Add a parametrize:
@pytest.mark.parametrize("a,b,expected", [(1, 2, 3), (-1, 1, 0), (0, 0, 0)])on atest_add(a, b, expected). Run withpytest -vand confirm three test rows. - Add labels with
pytest.param(..., id="positives")etc. Confirm the report shows your IDs. - Add a fixture with
yield—temp_log— that creates a file atoutput/test.log, yields the path, and on teardown deletes it. Use it in a test that writes a line to the file. Confirm the file is gone after the test. - Add a
@pytest.fixture(autouse=True)calledlog_name(request)that prints the test name before and after. Run withpytest -v -s(the-skeeps prints visible) and verify the log lines. - Stack two parametrize decorators on a
test_divide(a, b)to produce a small Cartesian matrix. Run withpytest -vand verify the count islen(a) * len(b). - Stretch: turn the
numbersfixture into aparams=[(10,5), (4,2), (3,3)]parametrized fixture. Every test that usesnumbersnow runs three times automatically.
You can now share setup across tests and run the same logic over many inputs. The next lesson digs into assertions — including pytest.raises, approximate comparisons, and your own custom matchers.