pytest · git:20261024.038dcfd · 2026-10-24 · sha256 ebbd8dfd65f48388
pytest git:20261024.038dcfdA
Immutable. This exact content never changes and is served at /api/v1/blob/ebbd8dfd65f48388.
---
name: pytest
description: >-
pytest testing framework conventions and practices. Invoke whenever task involves any
interaction with pytest — writing tests, configuring pytest, fixtures, parametrize,
mocking, debugging test failures, or coverage.
---
# 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_*.py` or `*_test.py`. Prefer `test_<module>.py` matching source module.
- **Functions:** `test_<behavior>` — describe the behavior, not the method: `test_returns_empty_list_when_no_matches`
not `test_search`.
- **Classes:** `TestClassName` groups 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:
```python
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`/`tearDown` from
`unittest`.
- **Explicit injection.** Request fixtures by name in test parameters. Every dependency is visible in the test
signature.
- **Smallest viable scope.** Default is `function` scope (fresh per test). Use broader scopes (`class`, `module`,
`session`) only for expensive resources.
- **`autouse=True` sparingly.** Only for setup that genuinely applies to every test in scope (e.g., database transaction
rollback, temp directory cleanup).
### Yield Fixtures (Setup + Teardown)
```python
@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
```
- **`yield`** separates setup from teardown. Code after `yield` runs even if the test fails.
- **Prefer `yield`** over `addfinalizer` — clearer control flow.
- **Teardown must not raise.** If cleanup can fail, wrap in `try`/`except` and log.
### Fixture Factories
When tests need multiple instances with varying configuration:
```python
@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.py` at 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`** — `Path` to a temporary directory unique to the test (function scope)
- **`tmp_path_factory`** — Factory for creating temp directories (session scope)
- **`capsys`** — Capture `sys.stdout`/`sys.stderr` writes
- **`capfd`** — Capture file descriptor 1/2 output (catches C-level writes)
- **`caplog`** — Capture `logging` output with access to records
- **`monkeypatch`** — Dynamic attribute/env/dict patching with automatic restore
- **`request`** — Fixture metadata: `.param`, `.node`, `.config`, `.fspath`
- **`pytestconfig`** — 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
```python
@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
```python
@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:
```python
@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. Use `strict=True` to 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:
```toml
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks integration tests",
]
```
```python
@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)
```python
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
```
- **`monkeypatch`** auto-restores on test exit. No manual cleanup.
- **Use for:** environment variables, module attributes, dictionary entries, `sys.path`.
### unittest.mock (For Complex Mocking)
```python
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.
- **`AsyncMock`** for async functions. Auto-detected when patching async targets.
- **`patch` target 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)
```python
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()
```
- **`mocker` auto-restores** after each test. Prefer over manual `patch` context 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:
```python
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 `assert` with pytest's rewrite engine gives clear failure messages.
- **Multiple assertions per test are fine** when they verify the same behavior.
### Exception Testing
```python
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 `.value`** for exception attributes via `exc_info`.
- **`pytest.raises` is a context manager.** The code that raises must be inside the `with`.
### Approximate Comparisons
```python
assert result == pytest.approx(3.14, abs=0.01)
assert results == pytest.approx([1.0, 2.0, 3.0], rel=1e-3)
```
### Warning Testing
```python
def test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()
```
## Async Testing
With `pytest-asyncio`:
```python
import pytest
import pytest_asyncio
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://api.example.com")
assert result.status == 200
@pytest_asyncio.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.asyncio`** on every async test and **`@pytest_asyncio.fixture`** on every async fixture, in the
default `strict` mode. An `async def` under `@pytest.fixture` fails at setup: no plugin handles it. Configuring
`asyncio_mode = "auto"` in `pyproject.toml` lifts both requirements.
- **Async fixtures** work with `yield` for 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.py` is available to all tests. A fixture in
`tests/unit/conftest.py` is 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
```python
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
```toml
[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.asyncio`
- **`pytest-mock`** — `mocker` fixture wrapping `unittest.mock`
- **`pytest-cov`** — Coverage reporting (`--cov=src`)
- **`pytest-xdist`** — Parallel test execution (`-n auto`)
- **`pytest-httpx`** — Mock `httpx` requests in tests
- **`pytest-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.**