pytest
pytest Best Practices
Comprehensive guidance for writing maintainable, efficient test suites with pytest, grounded in the official pytest documentation.
Project Layout
Use the src layout with tests outside the application package:
pyproject.toml
src/
mypkg/
__init__.py
app.py
tests/
conftest.py
test_app.py
Configure importlib import mode in pyproject.toml (recommended for new projects):
[tool.pytest.ini_options]
addopts = ["--import-mode=importlib"]
testpaths = ["tests"]
Install the package in editable mode so tests run against the local source:
pip install -e .
Test Discovery Conventions
- Name test files
test_*.pyor*_test.py - Name test functions and methods with a
test_prefix - Use
Test-prefixed classes (no__init__method) to group related tests - Place shared fixtures and plugins in
conftest.pyat the appropriate directory level
Assertions
Use plain assert statements — pytest rewrites them for detailed failure messages:
def test_addition():
assert 1 + 1 == 2
def test_with_message():
result = compute()
assert result > 0, f"Expected positive, got {result}"
Floating-point comparisons — use pytest.approx instead of manual tolerance checks:
def test_floats():
assert 0.1 + 0.2 == pytest.approx(0.3)
Exception assertions — use pytest.raises as a context manager:
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_exception_message():
with pytest.raises(ValueError, match=r"invalid value"):
parse_value("bad")
Never return a boolean from a test function — pytest ignores return values.
Fixtures
Fixtures are the primary mechanism for test setup and teardown. Define them in conftest.py for shared use, or directly in test modules for local use.
Basic fixture
import pytest
@pytest.fixture
def user():
return {"name": "Alice", "role": "admin"}
def test_user_role(user):
assert user["role"] == "admin"
Yield fixtures for teardown (preferred over addfinalizer)
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn # test runs here
conn.close() # teardown
Fixture scope — choose the broadest scope that is still safe
| Scope | Lifetime | Use case |
|---|---|---|
function |
Per test (default) | Mutable state, cheap to create |
class |
Per test class | Shared state within a class |
module |
Per test file | Expensive setup shared across a module |
session |
Entire test run | Database connections, containers |
@pytest.fixture(scope="session")
def app_config():
return load_config("test.env")
Safe fixture structure
Limit each fixture to one state-changing action, paired with its own teardown. This ensures cleanup runs even when other fixtures fail:
@pytest.fixture
def created_user(admin_client):
user = admin_client.create_user(name="test")
yield user
admin_client.delete_user(user) # always runs
Factory fixtures — for multiple instances in one test
@pytest.fixture
def make_order():
orders = []
def _make(product, qty):
order = Order(product=product, qty=qty)
orders.append(order)
return order
yield _make
for o in orders: o.cancel()
def test_two_orders(make_order):
o1 = make_order("book", 1)
o2 = make_order("pen", 5)
assert o1.product != o2.product
conftest.py placement
- Root
conftest.py— session-wide fixtures (DB, config) tests/unit/conftest.py— fixtures scoped to unit tests only- Fixtures are visible to all tests in the same directory and below
Parametrization
@pytest.mark.parametrize — avoid duplicate test logic
@pytest.mark.parametrize("value,expected", [
(2, 4),
(3, 9),
(-1, 1),
])
def test_square(value, expected):
assert square(value) == expected
Use pytest.param to attach marks to individual cases:
@pytest.mark.parametrize("n", [
0,
pytest.param(-1, marks=pytest.mark.xfail(reason="negative not supported")),
])
def test_sqrt(n):
assert sqrt(n) >= 0
Parametrized fixtures — run entire test sets against multiple configurations
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
return create_db(request.param)
Markers
Register all custom markers in pyproject.toml to prevent typo-silent failures:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: requires external services",
"unit: fast, isolated tests",
]
Enable strict marker validation to turn unknown markers into errors:
[tool.pytest.ini_options]
addopts = ["--strict-markers"]
Apply markers to individual tests, classes, or whole modules:
@pytest.mark.slow
def test_heavy_computation(): ...
# Module-level
pytestmark = pytest.mark.integration
Configuration (pyproject.toml)
Centralise all pytest settings:
[tool.pytest.ini_options]
addopts = [
"--import-mode=importlib",
"--strict-markers",
"--strict-config",
"-ra", # show summary of all non-passing tests
]
testpaths = ["tests"]
markers = [
"slow: deselect with '-m \"not slow\"'",
"integration: requires live services",
]
Enable strict mode for maximum safety on pinned pytest versions:
[tool.pytest.ini_options]
strict = true
Skipping and Expected Failures
Use skipif for condition-based skips; document the reason:
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
def test_symlinks(): ...
Use xfail to document known broken behaviour; add strict=True once fixed:
@pytest.mark.xfail(reason="issue #42: parser bug", strict=False)
def test_parser_edge_case(): ...
monkeypatch — preferred over unittest.mock for simple patching
def test_env_override(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
assert get_api_key() == "test-key"
def test_function_patch(monkeypatch):
monkeypatch.setattr("mymodule.fetch", lambda url: {"ok": True})
assert fetch_data() == {"ok": True}
monkeypatch automatically reverts all changes after the test — no manual cleanup needed.
Temporary Files
Use tmp_path (a pathlib.Path) instead of tempfile:
def test_writes_file(tmp_path):
out = tmp_path / "result.txt"
write_report(out)
assert out.read_text() == "done"
Quick Reference
| Goal | Tool |
|---|---|
| Assert equality | assert a == b |
| Assert float equality | pytest.approx |
| Assert exception raised | pytest.raises() |
| Assert warning emitted | pytest.warns() |
| Skip conditionally | @pytest.mark.skipif |
| Document known failure | @pytest.mark.xfail |
| Run test N times with data | @pytest.mark.parametrize |
| Shared setup/teardown | @pytest.fixture |
| Patch objects/env | monkeypatch |
| Temp files | tmp_path |
Additional Resources
Reference Files
references/fixtures.md— Fixture scopes, autouse, factory pattern, safe teardown patternsreferences/configuration.md— Fullpyproject.toml/pytest.inioption reference and strict mode details