# pytest Cheat Sheet

The parts you use every day: fixtures, parametrize, mocking, and the flags worth memorising.

---

## Running tests

```bash
pytest                          # everything under testpaths / cwd
pytest tests/test_api.py        # one file
pytest tests/test_api.py::test_login          # one test  (node id)
pytest tests/test_api.py::TestAuth::test_login
pytest "tests/test_api.py::test_login[admin]" # one parametrized case

pytest -k "login and not slow" # select by name expression
pytest -m slow                  # select by marker
pytest -x                       # stop at the first failure
pytest --maxfail=3
pytest --lf                     # rerun ONLY last-failed
pytest --ff                     # last-failed first, then the rest
pytest -q                       # quiet   (-v verbose, -vv full diffs)
pytest -s                       # don't capture stdout — print() shows up
pytest --pdb                    # drop into the debugger on failure
pytest --durations=10           # the 10 slowest tests
pytest --collect-only           # what WOULD run, without running it
pytest -n auto                  # parallel  (pytest-xdist)
pytest --cov=mypkg --cov-report=term-missing   # coverage (pytest-cov)
```

- Failures print the full assert diff, so read the bottom of the output first.
- `--lf` then `-x` is the debugging loop: fix one, rerun only what broke.
- `-p no:cacheprovider` if `.pytest_cache` gets in the way; `--cache-clear` to reset.

## Assertions

```python
import pytest

def test_basics():
    assert result == 42                      # plain assert: pytest rewrites it
    assert "key" in payload                  # to show both sides on failure
    assert user.roles == ["admin", "staff"]

def test_raises():
    with pytest.raises(ValueError, match="invalid token"):   # match is a REGEX
        parse("nope")

def test_exception_detail():
    with pytest.raises(HTTPError) as exc_info:
        client.get("/missing")
    assert exc_info.value.status_code == 404 # inspect it AFTER the block

def test_floats():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert [0.1, 0.2] == pytest.approx([0.1, 0.2], rel=1e-6)

def test_warns():
    with pytest.warns(DeprecationWarning):
        old_api()

def test_no_raise():                         # asserting something does NOT raise
    parse("ok")                              # just call it; a raise fails the test
```

- One behaviour per test, and let the name say it: `test_login_rejects_expired_token`.
- `match=` is `re.search` against `str(exc)` — escape regex chars (`re.escape`) when
  matching a literal message.
- Comparing dicts/lists gives a readable diff; comparing objects needs `__eq__`.

## Fixtures

```python
import pytest

@pytest.fixture
def db():
    conn = connect(":memory:")
    yield conn                     # everything after yield is teardown
    conn.close()                   # runs even if the test fails

@pytest.fixture(scope="session")   # created once for the whole run
def engine():
    return create_engine(TEST_URL)

@pytest.fixture(autouse=True)      # applied to every test in scope, no arg needed
def reset_state():
    cache.clear()

@pytest.fixture
def user(db):                      # fixtures can request other fixtures
    return db.create_user(name="Ada")

def test_login(user, db):          # ask for what you need, by name
    assert db.authenticate(user) is True

# a factory when each test wants its own variant
@pytest.fixture
def make_user(db):
    def _make(**kwargs):
        return db.create_user(**{"name": "Ada", **kwargs})
    return _make

def test_admin(make_user):
    assert make_user(role="admin").is_admin
```

| Scope | Created once per | Use for |
|---|---|---|
| `function` (default) | test | anything mutable |
| `class` | test class | shared setup in a class |
| `module` | file | expensive read-only data |
| `package` | package | rare |
| `session` | whole run | engines, containers, servers |

- Put shared fixtures in `conftest.py` — every test below that directory sees them,
  with no import. Nested `conftest.py` files stack.
- A fixture is cached within its scope: two tests asking for the same
  session-scoped fixture get the same object.
- Wider scope than `function` means shared mutable state. Roll back a transaction
  or reset in teardown, or tests start depending on their order.
- `request` gives context: `request.param`, `request.node.name`,
  `request.addfinalizer(fn)`.

## Built-in fixtures

```python
def test_files(tmp_path):                 # a fresh Path, per test
    config = tmp_path / "config.toml"
    config.write_text("debug = true")
    assert load(config).debug is True

def test_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key")
    monkeypatch.delenv("PROXY", raising=False)
    monkeypatch.setattr("myapp.client.fetch", lambda url: {"ok": True})
    monkeypatch.chdir(tmp_path)           # all undone automatically at teardown

def test_output(capsys):
    greet("Ada")
    captured = capsys.readouterr()        # consumes the buffer
    assert captured.out == "hello Ada\n"
    assert captured.err == ""

def test_logs(caplog):
    with caplog.at_level(logging.WARNING):
        charge(amount=-1)
    assert "negative amount" in caplog.text
    assert caplog.records[0].levelname == "WARNING"
```

| Fixture | Gives you |
|---|---|
| `tmp_path` | a unique `Path` directory per test |
| `tmp_path_factory` | same, session-scoped |
| `monkeypatch` | env vars, attributes, cwd — auto-undone |
| `capsys` / `capfd` | captured stdout/stderr (Python level / fd level) |
| `caplog` | log records emitted during the test |
| `recwarn` | warnings raised during the test |
| `request` | metadata about the running test |

- `monkeypatch` beats manual save/restore: it always undoes itself, including on
  failure. Reach for it before `unittest.mock.patch`.
- `capsys.readouterr()` empties the buffer — call it once and keep the result.

## Parametrize

```python
@pytest.mark.parametrize("raw,expected", [("1", 1), ("-3", -3), ("07", 7)])
def test_parse(raw, expected):
    assert parse(raw) == expected

# readable failure names instead of raw[0], raw[1]...
@pytest.mark.parametrize(
    "path,status",
    [("/", 200), ("/missing", 404), ("/admin", 403)],
    ids=["root", "missing", "forbidden"],
)
def test_routes(client, path, status):
    assert client.get(path).status_code == status

# mark ONE case without splitting the test
@pytest.mark.parametrize("value", [1, 2, pytest.param(0, marks=pytest.mark.xfail)])
def test_reciprocal(value):
    assert 1 / value

# stacking multiplies: 3 x 2 = 6 tests
@pytest.mark.parametrize("role", ["admin", "staff", "guest"])
@pytest.mark.parametrize("method", ["GET", "POST"])
def test_matrix(role, method): ...

# a parametrized FIXTURE: every test using it runs once per param
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
    return connect(request.param)
```

- Parametrize instead of looping inside a test: you get one result per case, and a
  failure names the case that broke.
- Ids show up in node ids, so `pytest "test_routes[missing]"` reruns just that one.
- Keep cases as data. If a case needs its own setup, it wants its own test.

## Markers, skip & xfail

```python
@pytest.mark.slow                              # custom marker
def test_full_import(): ...

@pytest.mark.skip(reason="flaky upstream, see #431")
def test_broken(): ...

@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs 3.12 batched()")
def test_new_syntax(): ...

@pytest.mark.xfail(reason="known bug #92", strict=True)
def test_known_bug(): ...                      # strict: PASSING is now a failure

@pytest.mark.xfail(raises=TimeoutError)        # only THIS error counts as expected
def test_flaky_network(): ...

def test_conditional_skip():
    if not shutil.which("psql"):
        pytest.skip("psql not installed")      # skip at runtime
```

```toml
[tool.pytest.ini_options]
markers = ["slow: takes more than a second", "integration: needs docker"]
addopts = "--strict-markers"      # a typo'd marker becomes an error, not a silent skip
```

- Register every custom marker and turn on `--strict-markers`; otherwise
  `@pytest.mark.slwo` silently does nothing.
- `-m "not slow"` locally, everything in CI.
- `xfail` documents a known bug and keeps the suite green; `skip` hides a test.
  Prefer `xfail(strict=True)` so you find out when it's fixed.

## Mocking & patching

```python
from unittest.mock import AsyncMock, MagicMock, Mock, patch

def test_sends_email(monkeypatch):
    sent = []
    monkeypatch.setattr("myapp.signup.send_email", lambda **kw: sent.append(kw))
    signup("ada@example.com")
    assert sent == [{"to": "ada@example.com", "template": "welcome"}]

@patch("myapp.signup.EmailClient")             # patch where it is USED,
def test_with_mock(MockEmail):                 # not where it is defined
    signup("ada@example.com")
    MockEmail.return_value.send.assert_called_once_with(to="ada@example.com")

def test_mock_api():
    client = Mock(spec=EmailClient)             # spec: typo'd attributes raise
    client.send.return_value = {"id": "msg_1"}
    client.send.side_effect = TimeoutError      # or: raise on call
    assert client.send.call_count == 1
    client.send.assert_called_once_with(to="x@y.z")
    args, kwargs = client.send.call_args

async def test_async_client():
    client = AsyncMock()
    client.get.return_value = {"ok": True}
    assert await fetch(client) == {"ok": True}
```

- Patch the name the code under test looks up: if `signup.py` does
  `from .email import send`, patch `myapp.signup.send`.
- Always pass `spec=` / `autospec=True`. A bare `Mock` answers to every attribute,
  so a renamed method leaves your test passing against an API that no longer exists.
- Injecting a fake as an argument beats patching. Patching is what you do when the
  code gives you no seam.
- `pytest-mock` wraps all of this in a `mocker` fixture with automatic teardown.

## Async tests

```python
import pytest

@pytest.mark.asyncio                 # pytest-asyncio
async def test_fetch():
    assert await fetch_user(1) == {"id": 1}

@pytest.fixture
async def client():                  # async fixtures work the same way
    async with AsyncClient() as c:
        yield c
```

```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"       # every async test is collected, no marker needed
```

- Without a plugin, pytest collects an async test and silently skips it —
  install `pytest-asyncio` (or `anyio`) and set `asyncio_mode = "auto"`.
- Each test gets its own event loop by default. A session-scoped async fixture
  therefore needs a matching loop scope, or you get "attached to a different loop".

## Config & layout

```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers --strict-config"
filterwarnings = ["error"]            # warnings become failures — catches deprecations early
pythonpath = ["src"]
```

```
myproject/
├── src/mypkg/
├── tests/
│   ├── conftest.py          # shared fixtures, no import needed
│   ├── unit/
│   └── integration/
│       └── conftest.py      # fixtures for this subtree only
└── pyproject.toml
```

- Name files `test_*.py` and functions `test_*`, or pytest won't collect them.
  Test classes must be `Test*` with no `__init__`.
- `conftest.py` is discovered automatically — never import it.
- Keep unit tests fast and hermetic; put anything needing docker or the network
  behind a marker so `-m "not integration"` stays quick.
- Test behaviour through the public API. Tests that assert on internals break on
  every refactor and tell you nothing about whether the code works.
