pytest fixtures are a core feature that eliminate duplicate test code and centralize setup and teardown logic. Mastering yield, scope, and conftest.py will help you write maintainable, production-grade tests.
📌 Who This Article Is For
| ✅ Developers just getting started with pytest |
| ✅ Anyone who isn’t fully confident writing fixtures |
| ✅ Those who want to clearly understand yield, scope, and conftest.py |
📖 What You’ll Learn in This Article
| ✔ Basic fixture syntax and how injection works |
| ✔ The yield teardown pattern (the most common approach in real projects) |
| ✔ How to choose scope and what to watch out for in parallel runs |
| ✔ Where to place conftest.py correctly |
| ✔ How to use autouse and built-in fixtures like tmp_path |
| ✔ Common pitfalls and how to fix them |
I have worked as a QA engineer for over 15 years, focusing on test automation with Selenium, Playwright, and pytest.
This article goes beyond textbook explanations — it covers real problems I’ve seen in actual codebases, along with practical solutions.
✅ Key Takeaways
- Use
yieldto combine setup and teardown in a single fixture - Use
scopeto reuse expensive operations and speed up your test suite - Put shared fixtures in
conftest.pyso the whole team can use them
What Are pytest Fixtures?
A pytest fixture is a mechanism for managing setup, teardown, and shared data needed before and after tests.
You define a function with the @pytest.fixture decorator, and pytest automatically injects it into any test function that declares a matching argument name.
The concept is similar to @BeforeEach / @AfterEach in JUnit, but pytest fixtures are more powerful — they support flexible scope control, nesting, and parameterization.
Basic Syntax and Simple Usage
Let’s start with the smallest possible example.
import pytest
# ① Define a fixture
@pytest.fixture
def sample_data() -> dict:
return {"user": "alice", "age": 30}
# ② Argument name = fixture name → pytest auto-injects it
def test_user_name(sample_data: dict) -> None:
assert sample_data["user"] == "alice"
def test_user_age(sample_data: dict) -> None:
assert sample_data["age"] == 30
As long as the argument name matches the fixture name, pytest handles injection automatically.
No import is needed, and multiple test functions can share the same fixture.
5 Recommended Fixture Patterns
1. Use yield to Combine Setup and Teardown
This is the most commonly used pattern in real projects. Everything before yield is setup; everything after is teardown.
The teardown code runs regardless of whether the test passes or fails.
import pytest
import requests
import logging
logger = logging.getLogger(__name__)
@pytest.fixture
def api_session() -> requests.Session:
# --- Setup ---
session = requests.Session()
session.headers.update({"Content-Type": "application/json"})
logger.info("Session opened")
yield session # ← handed to the test function
# --- Teardown (always runs, even on test failure) ---
session.close()
logger.info("Session closed")
def test_get_users(api_session: requests.Session) -> None:
response = api_session.get("https://example.com/api/users") # sample URL
assert response.status_code == 200
You can technically implement teardown with return using try/finally, but pytest’s yield pattern lets you write teardown more naturally.
For resources that need to be closed — DB connections, HTTP sessions, browser drivers — the yield pattern is the widely adopted approach in real projects.
2. Use scope to Reuse Expensive Operations
By default, fixtures are created and destroyed for each individual test. Setting a scope extends their lifetime so they can be shared across multiple tests.
| scope | Lifetime | Typical Use |
|---|---|---|
function (default) | Per test | Lightweight setup, data that must be independent |
class | Per class | Shared data within a test class |
module | Per file | DB connections, heavy mock servers |
session | Entire test run | Browser launch, auth token retrieval |
import pytest
import requests
# Login API is called only once for the entire test session
@pytest.fixture(scope="session")
def auth_token() -> str:
response = requests.post(
"https://example.com/api/auth/login", # sample URL
json={"username": "testuser", "password": "secret"},
timeout=(3, 10),
)
response.raise_for_status()
data = response.json()
assert "token" in data
return data["token"]
def test_get_profile(auth_token: str) -> None:
headers = {"Authorization": f"Bearer {auth_token}"}
res = requests.get(
"https://example.com/api/profile", # sample URL
headers=headers,
timeout=(3, 10),
)
assert res.status_code == 200
scope="session" itself is fine, but mutating shared state inside a session-scoped fixture can cause interference between tests when running in parallel with tools like pytest-xdist.Keep session-scoped fixtures limited to read-only data and connection objects.
3. Share Fixtures via conftest.py
Fixtures defined in conftest.py are available to all tests in the same directory and its subdirectories — no import needed.
It’s the standard place to put shared fixtures for your whole team.
# Directory layout
tests/
├── conftest.py ← shared fixtures for all tests
├── test_auth.py
└── api/
├── conftest.py ← fixtures scoped to the api/ directory only
└── test_users.py
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def base_url() -> str:
return "https://example.com" # sample URL (use env vars in real projects)
@pytest.fixture
def headers(auth_token: str) -> dict:
return {"Authorization": f"Bearer {auth_token}"}
# tests/test_auth.py
# No import needed — conftest.py fixtures are auto-discovered
def test_base_url(base_url: str) -> None:
assert "example" in base_url
You can place conftest.py at multiple directory levels, making it easy to separate “project-wide fixtures” from “module-specific fixtures”.
4. Use params for Data-Driven Testing
The params option lets you run a test multiple times with different inputs by cycling through a list of values.
import pytest
import requests
INVALID_PAYLOADS = [
pytest.param({"username": "", "password": "pass"}, id="empty_username"),
pytest.param({"username": "user", "password": ""}, id="empty_password"),
pytest.param({}, id="empty_body"),
]
# `request` is a special built-in argument pytest provides automatically
# (optional type hint: pytest.FixtureRequest)
@pytest.fixture(params=INVALID_PAYLOADS)
def invalid_login_payload(request) -> dict:
return request.param
def test_login_validation(invalid_login_payload: dict) -> None:
response = requests.post(
"https://example.com/api/auth/login", # sample URL
json=invalid_login_payload,
timeout=(3, 10),
)
# All invalid requests should return a 4xx response
assert response.status_code in (400, 422)
Adding pytest.param(..., id="...") gives each case a name, making failure output much easier to read.
fixture params vs. @pytest.mark.parametrize — a quick comparison
| Criterion | fixture params | @pytest.mark.parametrize |
|---|---|---|
| Share same patterns across multiple test functions | ◎ | △ (must repeat per function) |
| Simple input variations for a single test function | △ | ◎ |
| Parameterize setup logic alongside data | ◎ | △ |
| Readability for beginners | △ | ◎ |
5. Inject Fixtures into Fixtures to Separate Concerns
A fixture can receive other fixtures as arguments. Splitting “connection info”, “authentication”, and “client creation” into separate fixtures keeps each responsibility clear and small.
When nesting fixtures, always inject a wider-scoped fixture into a narrower-scoped one. The reverse direction raises a
ScopeMismatch error.session → function ✅ (wide → narrow: OK)function → session ❌ (narrow → wide: ScopeMismatch error)import pytest
import requests
# 1. Base URL (session scope)
@pytest.fixture(scope="session")
def base_url() -> str:
return "https://example.com/api" # sample URL (use env vars in real projects)
# 2. Auth token (session scope — injects base_url)
@pytest.fixture(scope="session")
def auth_token(base_url: str) -> str:
response = requests.post(
f"{base_url}/auth/login",
json={"username": "testuser", "password": "secret"},
timeout=(3, 10),
)
response.raise_for_status()
data = response.json()
assert "token" in data
return data["token"]
# 3. Authenticated session (function scope — injects auth_token)
@pytest.fixture
def authed_session(base_url: str, auth_token: str) -> requests.Session:
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {auth_token}",
"Content-Type": "application/json",
})
yield session
session.close()
# Tests only need to receive authed_session
def test_get_users(authed_session: requests.Session) -> None:
res = authed_session.get(
"https://example.com/api/users", # sample URL
timeout=(3, 10),
)
assert res.status_code == 200
With this structure, switching environments only requires changing base_url.
Test functions stay clean — they only need to know about authed_session.
6. Use autouse to Apply a Fixture Automatically (Advanced)
Setting autouse=True applies the fixture to every test automatically, without needing to declare it as an argument.
Common uses include logging, timezone locking, and DB reset between tests.
import pytest
import logging
logger = logging.getLogger(__name__)
# Logs the start and end of every test automatically
@pytest.fixture(autouse=True)
def log_test_name(request: pytest.FixtureRequest) -> None:
logger.info(f"▶ START: {request.node.name}")
yield
logger.info(f"■ END : {request.node.name}")
autouse=True is convenient, but overusing it makes it harder to understand which fixtures a test depends on. In practice, it’s safer to use autouse only for things that are clearly needed by every single test — and make sure your team is aligned on what those are.Useful Built-in Fixtures
pytest ships with several built-in fixtures that are ready to use without any setup of your own.
| Fixture | Purpose | Typical Use Case |
|---|---|---|
tmp_path | Temporary directory per test | File read/write tests |
monkeypatch | Temporarily patch env vars / attributes | Mocking external APIs or config values |
caplog | Capture log output | Assert on logging messages |
capsys | Capture stdout / stderr | Assert on print() output |
from pathlib import Path
import pytest
# tmp_path: a fresh temporary directory is auto-created per test
def test_write_csv(tmp_path: Path) -> None:
output_file = tmp_path / "result.csv"
output_file.write_text("id,name\n1,alice\n")
assert output_file.read_text().startswith("id,name")
# monkeypatch: temporarily overrides an env var and restores it after the test
def test_api_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("API_BASE_URL", "https://staging.example.com")
import os
assert os.environ["API_BASE_URL"] == "https://staging.example.com"
7 Common Pitfalls in Real Projects
⚠️ pytest fixture pitfalls to watch out for
- Missing
test_prefix on test function names
pytest only collects functions that start withtest_by default. A function namedcheck_login()will be silently skipped. - conftest.py is in the wrong directory
conftest.py must be placed in the same directory as your tests or a parent directory. Putting it in a sibling or unrelated directory results in a “fixture not found” error. - Mutating state inside a session-scoped fixture
Modifying shared state in a session-scoped fixture can cause test interference when running in parallel withpytest-xdist. Keep session fixtures to read-only data and connection objects. - Custom marks not registered in pytest.ini (or pyproject.toml)
Using@pytest.mark.smokewithout registering the mark in your config file produces aPytestUnknownMarkWarning. - print() output is invisible
pytest captures stdout by default. Addpytest -sto see print output, or use theloggingmodule instead. - An exception before assert shows ERROR, not FAILED
If an exception occurs in a fixture or before the first assert, pytest reports the result as ERROR rather than FAILED. Read the traceback carefully to locate the actual source. - Running pytest from a subdirectory causes ModuleNotFoundError
Runningpytestfrom insidetests/can exclude the project root from Python’s path, causing import errors. Always run pytest from the root directory wherepytest.inilives.
Frequently Asked Questions (FAQ)
setup_method / setup_function? setup_method (inside a class) and setup_function (at module level) are xUnit-style initialization methods.
pytest fixtures offer more power — dependency injection, scope control, and parameterization.
For new projects, fixtures are the widely preferred style. That said, you don’t need to immediately rewrite existing xUnit-style code; both approaches can coexist.
pytest analyzes the dependency graph of fixtures declared in a test function’s arguments and executes dependencies first. The order of fixtures at the same scope with no mutual dependency is not guaranteed. To enforce a specific order, make one fixture depend on another, or consider using a plugin like pytest-ordering.
autouse=True? It’s a good fit for “things every test absolutely needs” — logging, DB resets, timezone locking, etc.
However, overusing it can make it hard to see what a test actually depends on, which complicates debugging.
As a general rule, prefer explicit fixture arguments and reserve autouse for cases where your team has a clear shared understanding of its purpose.
@pytest.mark.parametrize? @pytest.mark.parametrize is ideal when you want to apply multiple input patterns to a single test function.
Fixture params is better when you need to reuse the same patterns across multiple test functions or when you want to parameterize setup logic alongside the data. See the comparison table above for details.
Yes. A fixture using yield behaves like a try/finally block — teardown always runs even if the test reports FAILED.
However, if an exception occurs in the teardown code itself, pytest emits a warning. Add try/except around teardown logic if that’s a concern.
There is no limit — you can place one in each directory. Organizing them as “project-wide”, “module-wide”, and “test-type-specific” by scope keeps your fixtures easy to navigate.
Summary
| Key Point | Description |
|---|---|
yield for teardown | Write setup and teardown in one fixture; widely preferred over return in real projects |
scope for reuse | Broaden to session/module for heavy operations; avoid mutating shared state |
conftest.py for sharing | No import required; watch the directory placement carefully |
params for data-driven tests | Handy when the same patterns need to cover multiple test functions |
| Fixture injection | Separate concerns by nesting fixtures; improves readability and maintainability |
autouse=True | Auto-applies to all tests; convenient but easy to overuse |
| Built-in fixtures | tmp_path / monkeypatch / caplog — no implementation needed |
Using pytest fixtures well leads to less duplicated code, no forgotten teardowns, and a faster test suite overall.
To start, just master the yield pattern and how to use conftest.py — that’s enough to handle most everyday test automation work.
A practical first step: pick one setup block in your existing tests and extract it into a fixture. That’s all it takes to get started.

