pytest · git:20260305.a08fbb4 · 2026-03-05 · sha256 5c3abdf4707e6be5

pytest git:20260305.a08fbb4A

Immutable. This exact content never changes and is served at /api/v1/blob/5c3abdf4707e6be5.

---
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

| Topic | Reference | Contents |
|-------|-----------|----------|
| 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 them 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

| Scope | Lifetime | Use For |
|-------|----------|---------|
| `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

| Fixture | Purpose |
|---------|---------|
| `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

@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.asyncio`** on every async test (or configure `asyncio_mode = "auto"`
  in `pyproject.toml`).
- **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

| Plugin | Purpose |
|--------|---------|
| `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.**