# Pytest

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

- Skill: `xobotyi/pytest` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add xobotyi/pytest`
- Raw SKILL.md: https://api.skillmd.com/api/skills/xobotyi/pytest/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: xobotyi (https://skillmd.com/u/xobotyi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/xobotyi/pytest

---


# 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

@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

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

