---
name: testing-py
description: "Use when writing or fixing Python tests with pytest and the suite is green but bugs still ship, when you must mock an external dependency and aren't sure where to patch, when coverage looks high but catches nothing, or when adding property-based tests for parsers/encoders/invariants. Triggers: 'write pytest tests', 'how do I mock this call', 'why does my mock pass when the code is broken', 'coverage is high but useless', 'patch where it's used', 'add Hypothesis property tests', 'fixture scope leaking state between tests', 'tests en Python', 'cómo mockeo esta llamada', 'la cobertura no detecta nada', 'tests de propietats'. NOT browser end-to-end flows (that is e2e-testing), NOT JS/TS unit tests (that is testing-web), NOT Go tests (that is testing-go)."
tags: [pytest, python, testing, coverage, hypothesis, mocking, fixtures, property-based]
recommends: [python, code-review, github-actions, e2e-testing]
origin: risco
---

# Testing Python

A green suite is not a tested suite. Most Python suites pass while shipping bugs because they lie in four predictable ways, and your job is to refuse each lie:

1. **Line-only coverage** reports 95% while every `else` and `except` arm is unvisited.
2. **Mocks patched at the wrong place** never apply, so the test runs the real code or asserts nothing.
3. **autospec-less mocks** pass even when you typo a method name or change a signature — a false green.
4. **autouse fixtures** mutate hidden global state, so tests pass alone and fail in CI order.

The posture, non-negotiable: pytest 9 strict markers and strict config on, **branch coverage on**, `autospec=` on every mock, and Hypothesis property tests for any pure logic with an invariant. Assert on behavior, never on a mock's internals.

Versions you are on (2026-06): pytest **9.0.2**, coverage.py **7.14.1**, pytest-cov **7.x** (needs coverage ≥ 7.10.6), Hypothesis **6.155.x**.

## Which test do I need?

Pick the cheapest test that still catches the bug.

| Situation | Test type | Reach for |
|---|---|---|
| Pure function, many input shapes, an invariant holds | property test | Hypothesis `@given(...)` |
| Same logic, a small fixed matrix of inputs/outputs | parametrized test | `@pytest.mark.parametrize` |
| Code calls an external boundary (HTTP, DB, SDK, clock) | unit test + mock **at the boundary** | `autospec=True` / `mocker` |
| Several real components must work together | integration test | real objects, fixtures, `tmp_path` |
| One test needs an env var / attr swapped and reverted | inline patch | `monkeypatch` |

Default to property + parametrized for logic; reserve mocks for boundaries you do not own.

## Layout and config

`tests/` mirrors the package tree. Put each fixture in the **nearest** `conftest.py`, not always the root one — proximity is documentation. Drive everything from `pyproject.toml`:

```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers --strict-config --cov=mypkg --cov-branch --cov-report=term-missing --cov-fail-under=85"
markers = [
  "slow: deselect with -m 'not slow'",
  "integration: touches a real boundary",
]

[tool.coverage.run]
branch = true            # measure both arms of every conditional, not just lines
source = ["mypkg"]

[tool.coverage.report]
show_missing = true
fail_under = 85
exclude_lines = [
  "if TYPE_CHECKING:",
  "raise NotImplementedError",
  "def __repr__",
  "pragma: no cover",
]
```

`--strict-markers` turns a typo'd `@pytest.mark.slwo` into an error instead of a silently-skipped test. `--strict-config` rejects unknown config keys. pytest 9 also exposes `strict_markers`, `strict_config`, `strict_xfail`, and `strict_parametrization_ids` as config aliases — treat strict as the default, not an upgrade.

## Fixtures

Scope is a contract about reuse and isolation. Default to `function` and widen only when setup is genuinely expensive and the object is genuinely immutable.

| Scope | Recreated | Use when |
|---|---|---|
| `function` (default) | every test | anything mutable — the safe default |
| `class` | per test class | a group sharing read-only setup |
| `module` | once per file | costly read-only object (a parsed config) |
| `package` | once per dir | shared fixture for a sub-tree |
| `session` | once per run | a process-wide resource you never mutate |

A `yield` fixture's pre-yield code is setup, post-yield is teardown — **and teardown runs even when the test fails**, so it is where cleanup belongs.

```python
# Bad: everything in the root conftest.py, plus a hidden autouse reset.
# Nobody can trace why a test depends on the DB, and the reset masks state leaks.
@pytest.fixture(autouse=True)
def reset_db():
    db.truncate_all()

# Good: scoped fixture in tests/api/conftest.py, explicit, documented.
# The docstring shows up in `pytest --fixtures`, so it is discoverable.
@pytest.fixture
def api_client(tmp_path):
    """An isolated API client backed by a throwaway sqlite file."""
    client = ApiClient(db_path=tmp_path / "test.db")
    yield client
    client.close()  # runs even if the test raises
```

Parametrize a fixture when several tests should each run against multiple backings:

```python
@pytest.fixture(params=["sqlite", "memory"])
def store(request):
    return make_store(request.param)  # every test using `store` runs twice
```

Use `autouse=True` only for something every test in scope truly needs (a frozen clock, a captured log). A hidden global dependency makes a failure impossible to read.

## Mocking

**Patch the name where it is looked up, not where it is defined.** This is the single most common mocking bug. If `mymod` does `from httpx import get`, then `get` now lives at `mymod.get`; patching `httpx.get` changes a name `mymod` no longer reads, so the mock never applies and the test silently hits the network.

```python
# Bad: patches the definition site; mymod already bound its own `get` at import.
with patch("httpx.get") as m:        # no effect on mymod.get
    result = mymod.fetch(url)

# Good: patch the name in the module under test, and autospec it.
with patch("mymod.get", autospec=True) as m:
    m.return_value.json.return_value = {"ok": True}
    result = mymod.fetch(url)
    m.assert_called_once_with(url, timeout=5)
```

**`autospec=True` is mandatory.** Without it a typo'd method or a wrong arg count still passes — the mock invents any attribute you touch. With autospec the mock enforces the real object's signature, so a refactor that renames a method or drops a parameter fails the test like it should. Use `create_autospec(obj)` when you need a standalone fake of a class or callable.

Choosing the swap tool:

- **`monkeypatch`** (built into pytest, no install): env vars, attributes, `chdir`. `monkeypatch.setenv` / `delenv` / `setattr` / `chdir` auto-revert after the test. Use it for "swap one thing for this test."
- **`mocker`** (`pytest-mock`): `unittest.mock` with automatic undo and call tracking. Less boilerplate for `return_value` / `side_effect` and `assert_called_*`. Use it for "fake a callable and inspect how it was called."

Both are correct; pick by what you are swapping. Faking time, HTTP, DB, the filesystem, `side_effect`, asserting calls, and `AsyncMock` for async code live in **[references/mocking.md](references/mocking.md)**.

## Coverage that means something

Run it with branch coverage and a floor:

```bash
pytest --cov=mypkg --cov-branch --cov-report=term-missing --cov-fail-under=85
```

Line coverage lies because executing a line is not the same as testing its outcomes. A function with `if x: a() else: b()` reaches 100% line coverage from a single test that only takes the `if` arm — the `else` ships untested. `--cov-branch` (or `branch = true`) forces both arms to be exercised, which is exactly where happy-path-only suites leak bugs. `term-missing` prints the unhit line and branch numbers so you know what to write next. Exclude only code that is correctly never run by tests (`if TYPE_CHECKING:`, `raise NotImplementedError`, `__repr__`) — never exclude a branch just to make the number go up.

## Property testing

Reach for Hypothesis when a fixed example matrix can't cover the input space: parsers, serializers, encoders, math, anything with an invariant. You assert a *property* that must hold for all inputs and let Hypothesis hunt counterexamples.

```python
from hypothesis import given, assume, strategies as st

@given(st.text())
def test_encode_decode_roundtrip(s):
    assume("\x00" not in s)            # skip inputs the encoder legitimately rejects
    assert decode(encode(s)) == s      # roundtrip: the core property
```

The property shapes worth knowing: **roundtrip** (`decode(encode(x)) == x`), **idempotence** (`f(f(x)) == f(x)`), and **invariant** (a sorted list stays the same length and is ordered). On failure Hypothesis **shrinks** to the simplest failing input and stores it in the `.hypothesis/` example DB, so the next run replays that exact case until you fix it — commit the DB or persist it in CI to keep regressions pinned. Use `assume()` to discard inputs that aren't meant to be valid, and bound slow strategies so the deadline doesn't flake. Strategy catalog, composite strategies, settings/profiles, `@example`, and stateful testing live in **[references/property-testing.md](references/property-testing.md)**.

## Anti-patterns

| Anti-pattern | Why it is wrong | Do instead |
|---|---|---|
| `patch(...)` without `autospec=`/`spec=` | false green on a renamed or mis-called method | always `autospec=True` (or `create_autospec`) |
| Patching the definition site (`httpx.get`) | the mock never applies to the module under test | patch the use site (`mymod.get`) |
| `time.sleep(...)` to "fix" flakiness | slow and still flaky | fake the clock / poll on a condition |
| Asserting on `mock._mock_calls` or private attrs | test breaks on refactor, not on bugs | assert on the return value / observable behavior |
| Session-scoped mutable fixture with no reset | state leaks across tests, order-dependent failures | `function` scope, or explicit teardown |
| `--cov` without `--cov-branch` | 100% lines while branches are untested | always `--cov-branch` / `branch = true` |
| One giant test asserting 12 things | first failure hides the rest; can't tell what broke | one behavior per test, parametrize the matrix |
| `@given` with no `assume`/bounds on slow code | deadline timeouts, flaky property tests | bound the strategy, raise/disable deadline deliberately |
| `assert True` or bare `pytest.skip()` | a test that can never fail or never runs | assert a real outcome; `skip(reason=...)` |

## Verify

Before declaring a suite done, run the static gate over the test tree. It flags the skill's own banned patterns — mocks without `autospec`/`spec`, `--cov` without `--cov-branch`, `time.sleep` in tests, and no-op tests:

```bash
scripts/verify.sh tests/
```

It is read-only, prints `file:line` for each hit, exits non-zero on any finding, and exits 0 on a clean or empty tree. A clean run is necessary, not sufficient — it cannot tell you the branches are actually tested, only that the obvious lies are absent.
