pytest
Test behavior, not implementation. Tests are executable documentation — if the test name doesn't explain what the code does, rewrite it.
pytest is Python's standard testing framework. It uses plain assert statements, fixtures for setup/teardown, and a
rich plugin ecosystem. All patterns target Python 3.14+.
References
- Fixture patterns, scope, factories, teardown — [
${CLAUDE_SKILL_DIR}/references/fixtures.md]: Fixture lifecycle, yield fixtures, factory pattern, request object, parametrized fixtures - Parametrize patterns, indirect, IDs — [
${CLAUDE_SKILL_DIR}/references/parametrize.md]: Multi-parameter examples, indirect fixtures, custom IDs, stacking decorators - Monkeypatch patterns, scoped patches — [
${CLAUDE_SKILL_DIR}/references/monkeypatch.md]: API overview, attribute/env/dict patching, scoped monkeypatch, common recipes - Plugin ecosystem and configuration — [
${CLAUDE_SKILL_DIR}/references/plugins.md]: pytest-asyncio, pytest-mock, pytest-xdist, pytest-cov configuration patterns
Test Structure
Discovery and Naming
- Files:
test_*.pyor*_test.py. Prefertest_<module>.pymatching source module. - Functions:
test_<behavior>— describe the behavior, not the method:test_returns_empty_list_when_no_matchesnottest_search. - Classes:
TestClassNamegroups related tests. No__init__method. Use classes when tests share setup; use bare functions for independent tests. - conftest.py is auto-discovered — no import needed. Place shared fixtures at the appropriate directory level.
Arrange-Act-Assert
Structure every test in three phases:
def test_user_creation_sets_defaults():
# Arrange
data = {"name": "Alice", "email": "alice@example.com"}
# Act
user = User.from_dict(data)
# Assert
assert user.name == "Alice"
assert user.is_active is True
assert user.roles == []
- One act per test. If you need multiple acts, write multiple tests.
- Comments optional when phases are obvious. Add when the test is long enough that phases aren't immediately clear.
Test Granularity
- One concept per test. Multiple assertions are fine when they verify the same behavior. Separate tests when behaviors are independent.
- Fast by default. Unit tests should run in milliseconds. Gate slow tests (network, DB) behind markers:
@pytest.mark.slow. - Isolation is mandatory. Tests must not depend on execution order or shared mutable state. Each test sets up its own world.
Fixtures
Core Rules
- Fixtures over setup methods. Fixtures are composable, scoped, and explicit. Never use
setUp/tearDownfromunittest. - Explicit injection. Request fixtures by name in test parameters. Every dependency is visible in the test signature.
- Smallest viable scope. Default is
functionscope (fresh per test). Use broader scopes (class,module,session) only for expensive resources. autouse=Truesparingly. Only for setup that genuinely applies to every test in scope (e.g., database transaction rollback, temp directory cleanup).
Yield Fixtures (Setup + Teardown)
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture
def temp_config(tmp_path: Path):
config_file = tmp_path / "config.toml"
config_file.write_text('[app]\ndebug = true\n')
yield config_file
# cleanup automatic — tmp_path handles it
yieldseparates setup from teardown. Code afteryieldruns even if the test fails.- Prefer
yieldoveraddfinalizer— clearer control flow. - Teardown must not raise. If cleanup can fail, wrap in
try/exceptand log.
Fixture Factories
When tests need multiple instances with varying configuration:
@pytest.fixture
def make_user():
def _make_user(name: str = "Alice", *, active: bool = True) -> User:
return User(name=name, is_active=active)
return _make_user
def test_inactive_users_excluded(make_user):
active = make_user("Alice", active=True)
inactive = make_user("Bob", active=False)
assert filter_active([active, inactive]) == [active]
Fixture Scope
function— Each test (default). Most fixtures — cheap setup, isolation.class— All tests in a class. Shared expensive setup within a test class.module— All tests in a file. Database connection per test file.session— Entire test run. Server startup, heavy resource initialization.Session-scoped fixtures must be in
conftest.pyat the root test directory.Don't mix scopes carelessly. A function-scoped fixture cannot depend on a function-scoped fixture that modifies state from a broader scope.
Built-in Fixtures
tmp_path—Pathto a temporary directory unique to the test (function scope)tmp_path_factory— Factory for creating temp directories (session scope)capsys— Capturesys.stdout/sys.stderrwritescapfd— Capture file descriptor 1/2 output (catches C-level writes)caplog— Captureloggingoutput with access to recordsmonkeypatch— Dynamic attribute/env/dict patching with automatic restorerequest— Fixture metadata:.param,.node,.config,.fspathpytestconfig— Access to the pytest config object
See ${CLAUDE_SKILL_DIR}/references/fixtures.md for fixture lifecycle details, parametrized fixtures, and advanced
patterns.
Parametrize
Basic Usage
@pytest.mark.parametrize("input_val, expected", [
("hello", 5),
("", 0),
(" spaces ", 10),
])
def test_string_length(input_val: str, expected: int):
assert len(input_val) == expected
- Use descriptive IDs:
pytest.param("", 0, id="empty-string")for readable output. - Each row is a distinct test. Failures report which parameter combination failed.
Stacking Decorators
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x: int, y: int):
assert x + y > 0
# Generates: (1,10), (1,20), (2,10), (2,20)
Indirect Parametrize
Pass parameter values to fixtures instead of directly to the test:
@pytest.fixture
def user(request) -> User:
return User(name=request.param)
@pytest.mark.parametrize("user", ["Alice", "Bob"], indirect=True)
def test_user_greeting(user: User):
assert user.name in user.greet()
See ${CLAUDE_SKILL_DIR}/references/parametrize.md for multi-parameter patterns, conditional skipping within
parametrize, and dynamic parametrize generation.
Markers
Built-in Markers
@pytest.mark.skip(reason="...")— unconditionally skip.@pytest.mark.skipif(condition, reason="...")— skip when condition is true:@pytest.mark.skipif(sys.platform == "win32", reason="Unix only").@pytest.mark.xfail(reason="...")— expected failure. Passes if the test fails, reports unexpected pass if it succeeds. Usestrict=Trueto fail on unexpected pass.@pytest.mark.usefixtures("fixture_name")— inject fixture without using its value.@pytest.mark.filterwarnings("ignore::DeprecationWarning")— per-test warning filter.
Custom Markers
Register in pyproject.toml to avoid warnings:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks integration tests",
]
@pytest.mark.slow
def test_full_pipeline():
...
Run subsets: pytest -m "not slow", pytest -m "integration and not slow".
Mocking
monkeypatch (Preferred for Simple Cases)
def test_reads_env_variable(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
assert get_api_key() == "test-key"
def test_overrides_attribute(monkeypatch):
monkeypatch.setattr("myapp.config.DEBUG", True)
assert is_debug_mode() is True
monkeypatchauto-restores on test exit. No manual cleanup.- Use for: environment variables, module attributes, dictionary entries,
sys.path.
unittest.mock (For Complex Mocking)
from unittest.mock import MagicMock, patch, AsyncMock
def test_service_calls_repository():
repo = MagicMock(spec=UserRepository)
repo.get.return_value = User(name="Alice")
service = UserService(repo=repo)
result = service.find_user("alice")
repo.get.assert_called_once_with("alice")
assert result.name == "Alice"
@patch("myapp.services.httpx.get")
def test_fetches_external_data(mock_get):
mock_get.return_value = MagicMock(json=lambda: {"status": "ok"})
assert fetch_status() == "ok"
- Always use
spec=on MagicMock — catches attribute typos at test time. AsyncMockfor async functions. Auto-detected when patching async targets.patchtarget is where the name is looked up, not where it's defined:@patch("myapp.services.httpx.get")not@patch("httpx.get").
pytest-mock (mocker Fixture)
def test_with_mocker(mocker):
mock_fetch = mocker.patch("myapp.services.fetch_data")
mock_fetch.return_value = {"key": "value"}
result = process_data()
mock_fetch.assert_called_once()
mockerauto-restores after each test. Prefer over manualpatchcontext managers.mocker.patch("module.Class", autospec=True)— recursively specs all attributes and method signatures from the real object. Catches signature mismatches at test time.mocker.spy(obj, "method")wraps the real method — tracks calls while preserving behavior.
Mocking Rules
- Mock at boundaries. Mock external services, databases, filesystems, clocks — not internal functions.
- Don't mock what you own when a fake or in-memory implementation is available.
- Prefer dependency injection over patching. Pass collaborators as parameters, mock in tests.
- Never mock the thing you're testing. If you need to mock part of the SUT, the SUT has too many responsibilities — split it.
Assertions
Plain Assert
pytest rewrites assert statements to show detailed failure messages:
assert result == expected # shows both values on failure
assert "error" in message # shows the full string
assert len(items) == 3 # shows actual length
assert all(x > 0 for x in values) # shows the values
- No assertion library needed. Plain
assertwith pytest's rewrite engine gives clear failure messages. - Multiple assertions per test are fine when they verify the same behavior.
Exception Testing
def test_raises_on_invalid_input():
with pytest.raises(ValueError, match=r"must be positive"):
calculate(-1)
def test_exception_attributes():
with pytest.raises(ValidationError) as exc_info:
validate(bad_data)
assert exc_info.value.field == "email"
assert "invalid format" in str(exc_info.value)
- Always use
match=when the exception type is broad — validates the message. - Access
.valuefor exception attributes viaexc_info. pytest.raisesis a context manager. The code that raises must be inside thewith.
Approximate Comparisons
assert result == pytest.approx(3.14, abs=0.01)
assert results == pytest.approx([1.0, 2.0, 3.0], rel=1e-3)
Warning Testing
def test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()
Async Testing
With pytest-asyncio:
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://api.example.com")
assert result.status == 200
@pytest.fixture
async def async_client():
async with AsyncClient() as client:
yield client
@pytest.mark.asyncio
async def test_with_async_client(async_client):
response = await async_client.get("/health")
assert response.status_code == 200
@pytest.mark.asyncioon every async test (or configureasyncio_mode = "auto"inpyproject.toml).- Async fixtures work with
yieldfor teardown — same pattern as sync fixtures.
conftest.py Patterns
Hierarchy
tests/
├── conftest.py # session/root fixtures
├── unit/
│ ├── conftest.py # unit test fixtures
│ └── test_models.py
└── integration/
├── conftest.py # integration fixtures (DB, services)
└── test_api.py
- Fixtures cascade downward. A fixture in
tests/conftest.pyis available to all tests. A fixture intests/unit/conftest.pyis available only to unit tests. - Don't import from conftest. pytest discovers and injects conftest fixtures automatically.
- Split by concern. Root conftest for shared utilities (factories, settings). Subdirectory conftest for environment-specific setup (database, external services).
Output Capture
def test_prints_greeting(capsys):
greet("Alice")
captured = capsys.readouterr()
assert "Hello, Alice" in captured.out
def test_logs_warning(caplog):
with caplog.at_level(logging.WARNING):
process_legacy_data()
assert "deprecated" in caplog.text
assert caplog.records[0].levelname == "WARNING"
Configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
markers = [
"slow: slow tests",
"integration: integration tests",
]
filterwarnings = [
"error", # treat all warnings as errors
"ignore::DeprecationWarning", # except deprecations from deps
]
asyncio_mode = "auto" # pytest-asyncio: auto-detect async tests
--strict-markers— fail on unregistered markers. Catches typos.-ra— show summary of all non-passing tests at the end.filterwarnings = ["error"]— catch hidden warnings early.
Plugin Ecosystem
pytest-asyncio— Async test support with@pytest.mark.asynciopytest-mock—mockerfixture wrappingunittest.mockpytest-cov— Coverage reporting (--cov=src)pytest-xdist— Parallel test execution (-n auto)pytest-httpx— Mockhttpxrequests in testspytest-randomly— Randomize test order to catch hidden dependencies
See ${CLAUDE_SKILL_DIR}/references/plugins.md for configuration patterns and usage details.
Application
When writing tests: apply all conventions silently — don't narrate each rule being followed. Match the project's existing test style. If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.
When reviewing tests: cite the specific issue and show the fix inline. Don't lecture — state what's wrong and how to fix it.
Bad: "According to pytest best practices, you should use fixtures
instead of setUp methods..."
Good: "setUp/tearDown -> @pytest.fixture with yield"
Integration
The python skill governs language choices; this skill governs pytest testing decisions. The coding skill governs workflow (discovery, planning, verification).
Test behavior, not implementation. When in doubt, mock less.