Python PyTest Parametrize and Conftest Best Practices
pytest's @pytest.mark.parametrize and conftest.py fixtures are the two features that separate a well-structured test suite from a pile of repetitive test functions. Parametrize runs one test function against many input/output pairs without duplication. Conftest provides shared, scoped fixtures that set up expensive resources (database connections, HTTP clients, test data) once and reuse them across many tests. This guide covers both in depth, plus marks, plugins, and async testing patterns.
Table of Contents
pytest.mark.parametrize
Parametrize lets you run the same test logic against multiple inputs and expected outputs. Each parameter set becomes a separate test case with its own name in the test report — far cleaner than writing five near-identical test functions or looping inside a test.
import pytest
def parse_price(value: str) -> float:
"""Parse a price string like '$1,234.56' into a float."""
return float(value.replace("$", "").replace(",", ""))
# Single parameter
@pytest.mark.parametrize("value", ["hello", "world", "pytest"])
def test_string_length_nonzero(value: str):
assert len(value) > 0
# Multiple parameters as tuples
@pytest.mark.parametrize("raw, expected", [
("$1.00", 1.00),
("$1,234.56", 1234.56),
("$0.00", 0.00),
("$9,999.99", 9999.99),
])
def test_parse_price(raw: str, expected: float):
assert parse_price(raw) == pytest.approx(expected)
# Named test IDs for readable output
@pytest.mark.parametrize("value, expected", [
pytest.param("$1.00", 1.00, id="one-dollar"),
pytest.param("$1,234.56", 1234.56, id="over-a-thousand"),
pytest.param("$0.00", 0.00, id="zero"),
], )
def test_parse_price_named(value, expected):
assert parse_price(value) == pytest.approx(expected)
# Cartesian product — combine two parametrize decorators
@pytest.mark.parametrize("n", [1, 2, 3])
@pytest.mark.parametrize("multiplier", [1, 10, 100])
def test_multiply(n, multiplier):
result = n * multiplier
assert result == n * multiplier # generates 9 tests: 1*1, 1*10, 1*100, 2*1...
Advanced Parametrize Patterns
Mark individual parameter sets as expected failures, skip them, or load test data from external files to keep your test logic separate from your test data.
import pytest
import json
from pathlib import Path
# Mark individual cases
@pytest.mark.parametrize("dividend, divisor, expected", [
(10, 2, 5.0),
(9, 3, 3.0),
pytest.param(1, 0, None, marks=pytest.mark.xfail(raises=ZeroDivisionError)),
pytest.param(1, 0.5, 2.0, marks=pytest.mark.skip(reason="float division WIP")),
])
def test_divide(dividend, divisor, expected):
assert dividend / divisor == pytest.approx(expected)
# Load test cases from a JSON fixture file
def load_test_cases(filename: str) -> list:
path = Path(__file__).parent / "fixtures" / filename
return json.loads(path.read_text())
# fixtures/price_cases.json: [["$1.00", 1.0], ["$2.50", 2.5]]
# @pytest.mark.parametrize("raw, expected", load_test_cases("price_cases.json"))
# def test_parse_price_from_file(raw, expected):
# assert parse_price(raw) == pytest.approx(expected)
# Indirect parametrize — pass values through a fixture
@pytest.fixture
def user_from_db(request):
"""Fixture that creates a user with the given role."""
role = request.param
return {"id": 1, "role": role, "active": True}
@pytest.mark.parametrize("user_from_db", ["admin", "editor", "viewer"], indirect=True)
def test_user_can_read(user_from_db):
assert user_from_db["active"] is True
Fixtures and conftest.py
conftest.py is a special pytest file that pytest discovers automatically. Fixtures defined there are available to all tests in the same directory and all subdirectories without any import. Place shared fixtures in the nearest conftest.py to the tests that use them.
# tests/conftest.py
import pytest
import httpx
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base, get_db
# In-memory SQLite for tests
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="session", autouse=True)
def create_tables():
"""Create all tables once before any test in the session."""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def db_session():
"""Provide a clean database session per test, rolled back on teardown."""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture()
def client(db_session):
"""FastAPI test client with database session override."""
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture()
def sample_user(db_session):
"""Create a sample user and return it."""
from app.models import User
user = User(email="test@example.com", name="Test User", is_active=True)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
# tests/test_users.py — fixtures available without import
def test_get_user(client, sample_user):
response = client.get(f"/users/{sample_user.id}")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
def test_create_user(client):
response = client.post("/users", json={"email": "new@example.com", "name": "New"})
assert response.status_code == 201
assert response.json()["email"] == "new@example.com"
Fixture Scopes and Teardown
Fixture scope controls how often the fixture is set up and torn down. Use the widest scope possible for expensive resources (database connections, HTTP servers) to speed up the test suite, and the narrowest scope for state that must be clean per test.
import pytest
# function scope (default) — fresh per test
@pytest.fixture(scope="function")
def fresh_list():
return []
# class scope — one per test class
@pytest.fixture(scope="class")
def shared_counter():
return {"count": 0}
# module scope — one per test file
@pytest.fixture(scope="module")
def expensive_client():
client = create_expensive_client()
yield client
client.close()
# session scope — one for the entire test run
@pytest.fixture(scope="session")
def database():
db = connect_to_test_db()
yield db
db.disconnect()
# autouse=True — apply to all tests in scope without requesting it
@pytest.fixture(autouse=True)
def reset_global_state():
"""Ensure global state is clean before each test."""
yield
# teardown: runs after every test
import some_module
some_module.reset()
def create_expensive_client(): ...
def connect_to_test_db(): ...
Factory Fixtures
A factory fixture returns a callable instead of a value, letting tests create multiple customised instances without defining a fixture per variant. This is the standard pattern for creating test models with different attributes.
import pytest
from dataclasses import dataclass
from typing import Callable
@dataclass
class User:
id: int
email: str
role: str = "viewer"
is_active: bool = True
@pytest.fixture()
def make_user() -> Callable[..., User]:
"""Factory fixture: returns a function that creates User instances."""
created: list[User] = []
_id_counter = [0]
def _make(email: str = "user@example.com", role: str = "viewer", **kwargs) -> User:
_id_counter[0] += 1
user = User(id=_id_counter[0], email=email, role=role, **kwargs)
created.append(user)
return user
yield _make
# Teardown: clean up all created users
for user in created:
pass # delete from DB in real tests
def test_admin_can_delete(make_user):
admin = make_user(email="admin@co.com", role="admin")
viewer = make_user(email="viewer@co.com", role="viewer")
assert admin.role == "admin"
assert viewer.role == "viewer"
assert admin.id != viewer.id
Async Fixtures and Tests
Use pytest-anyio or pytest-asyncio to write async test functions and async fixtures. anyio is framework-agnostic (works with asyncio and trio) and is preferred for FastAPI projects.
pip install anyio pytest-anyio httpx
# conftest.py
import pytest
import httpx
from anyio import from_thread
from app.main import app
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture()
async def async_client():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
# tests/test_async_api.py
import pytest
@pytest.mark.anyio
async def test_get_orders(async_client):
response = await async_client.get("/orders")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.anyio
async def test_create_order(async_client):
payload = {"product_id": 1, "quantity": 2}
response = await async_client.post("/orders", json=payload)
assert response.status_code == 201
data = response.json()
assert data["product_id"] == 1
assert data["quantity"] == 2
Custom Marks and pytest.ini
Custom marks let you categorise tests and selectively run subsets. Register them in pytest.ini or pyproject.toml to avoid warnings, and use -m expressions on the command line to filter.
# pytest.ini
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks integration tests requiring real services
unit: fast unit tests with no external dependencies
smoke: minimal smoke tests run on every commit
asyncio_mode = auto
testpaths = tests
addopts = -ra --tb=short --strict-markers
import pytest
@pytest.mark.unit
def test_price_parsing():
assert parse_price("$1.00") == 1.00
@pytest.mark.slow
@pytest.mark.integration
def test_full_order_flow(client, sample_user):
# Creates order, charges payment, sends email — hits real services in staging
...
@pytest.mark.smoke
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
# Run only fast unit tests
pytest -m unit
# Skip slow tests
pytest -m "not slow"
# Run integration tests
pytest -m integration
# Run smoke tests on every commit
pytest -m smoke --tb=short -q
Frequently Asked Questions
- When should I put a fixture in conftest.py vs the test file?
- Put fixtures in
conftest.pywhen they are shared by multiple test files in the same directory. Keep fixtures in the test file when they are used only by that file. pytest automatically discoversconftest.pyin each directory and all parent directories up to the root. - How do I pass data from one fixture to another?
- Just add the fixture name as a parameter of your fixture function — pytest resolves dependencies automatically. For example,
def sample_user(db_session)receives thedb_sessionfixture and uses it to create a user. There is no maximum depth on fixture chains. - What is the difference between yield fixtures and return fixtures?
- A
yieldfixture has a teardown phase: code afteryieldruns after the test completes, even if the test fails. Useyieldwhenever you need to clean up resources (close connections, delete files, rollback transactions). Usereturnwhen no teardown is needed.