Quick Reference · Python testing framework

pytest cheat sheet9.1

A test is a plain function that asserts. Everything else is three ideas: fixtures supply what the test needs, marks change how it runs, and params run it again with new data. Every test moves through the same three phases — setup, call, teardown.

run & CLI assertions fixtures marks & params new in 9.x config & plugins gotcha / removed most common

Verified 2026-08-26 against pytest 9.1.1 (current latest, installed & run) · docs.pytest.org changelog + deprecations · pypi.org · plugin versions checked live. Ordering, scopes and collection rules confirmed by execution, not recall.

One run, one test: the pipeline above · the scopes that wrap it below
THE RUN Config pytest.toml pyproject.toml → rootdir Collect test_*.py · *_test.py Test* classes test_* functions Generate parametrize → items marks applied fixture closure built FOR EVERY COLLECTED ITEM setup fixtures built fails → E error call your assert runs fails → F failure teardown code after each yield Report . F E s x X exit 0–6 THE SCOPES THAT WRAP IT session once per pytest invocation package per directory with __init__.py module per test file class per Test* class function the default — fresh for every single test outer scopes are built first and torn down last Real ordering, one file, two tests session fixture → setup module fixture → setup autouse fixture → setup requested fixture → setup test_a() runs requested fixture → teardown autouse fixture → teardown test_b() runs — function scope rebuilt module fixture → teardown session fixture → teardown teardown is strict LIFO · autouse beats requested at the same scope
The whole framework in twelve lines
# test_wallet.py  —  run it with:  pytest -q
import pytest
from wallet import Wallet, InsufficientFunds

@pytest.fixture                                  # 1. supply what the test needs
def wallet(tmp_path):                                #    tmp_path is built in
    w = Wallet(path=tmp_path / "w.json", balance=100)
    yield w                                         #    everything after yield = teardown
    w.close()

@pytest.mark.parametrize("spend, left", [(10, 90), (100, 0)])   # 2. same test, new data
def test_spend(wallet, spend, left):
    wallet.spend(spend)
    assert wallet.balance == left                   # 3. a bare assert is the whole API

def test_overspend(wallet):
    with pytest.raises(InsufficientFunds, match="balance"):
        wallet.spend(500)
01Install & First Testzero boilerplate
02Run & Select Teststhe daily loop
03Output & Reportingread the failure
04How Tests Are Foundcollection rules
05Assertionsplain Python
06Expecting Errors & Warningsraises · warns
07Comparing Floatspytest.approx
08Fixtures · the basicssetup by request
09Fixtures · scope & autousehow often it runs
10conftest.pysharing, by directory
11Every Built-in Fixturepytest --fixtures
12Files & Patchingisolate the test
13Output & Log Capturecapsys · caplog
14Parametrizeone test, many rows
15Marksmetadata & selection
16Skip & xfailtests that can't pass
17New in pytest 9Nov 2025 → Jun 2026
18Configurationpick exactly one file
19Plugins Worth Havingversions as of Jul 2026
20Classes, unittest & docteststhe older styles
21Gone or Goingwhat breaks on upgrade
Reading the Outputletters & exit codes

Four things worth seeing once

The phase model explains every confusing failure message; the conftest tree explains every "fixture not found". Both are drawn from behaviour verified against pytest 9.1.1.

setup · call · teardown

Each test is reported in three phases. Where it broke decides whether you see an E or an F — and teardown always runs.

ONE TEST ITEM setup fixtures build call your test body teardown code after yield E F E error failure error An E means your test never ran — fix the fixture, not the assertion.

where fixtures are visible

A fixture is offered to everything at or below its conftest.py. Nothing is imported; the directory is the namespace.

project/ ├─ conftest.py ├─ pytest.toml └─ tests/ ├─ conftest.py ├─ test_smoke.py └─ api/ ├─ conftest.py └─ test_users.py every test tests/** api/** nearest definition of a name wins.

stacked parametrize = product

Two decorators do not run two sets of cases — they multiply. Three sizes and two colours is six tests, not five.

@parametrize("size", ["s","m","l"]) @parametrize("colour", ["red","blue"]) def test_shirt(size, colour): ... COLLECTS AS 6 ITEMS test_shirt[red-s] test_shirt[red-m] test_shirt[red-l] test_shirt[blue-s] test_shirt[blue-m] test_shirt[blue-l] The bottom decorator comes first in the ID; the top one varies fastest.

skip vs xfail

Both keep a red suite green, but only one of them still runs your code — and only one tells you when the bug is fixed.

skip / skipif “can't run here” body executes no reported as s tells you it's fixed never use for: wrong OS, missing dep xfail “known broken” body executes yes reported as x · X tells you it's fixed if strict use for: an open bug you'll fix Prefer xfail(strict=True) — it fails the day the bug is fixed.

Worth memorizing

-k vs -m-k matches test names · -m matches marks
E vs FE = broke in a fixture · F = your assert failed
exit 5"no tests collected" — a green CI that tested nothing
Test* + __init__class is skipped with a warning, never an error
five scopesfunction · class · module · package · session
teardown orderstrict LIFO — outermost scope tears down last
autouse firstbeats explicitly requested fixtures at the same scope
match= is a regexre.search, so match="" matches anything
readouterr() drainscall it once and keep the result
stacked params multiply2 × 3 = 6 tests; the bottom decorator leads the ID
strict_markersthe cheapest way to stop typo'd marks silently passing
one config filepytest.ini wins; the others are ignored, not merged
tmp_path > tmpdirpathlib, not the legacy py.path.local
assert rewritingtest modules + conftest only — register helper modules
return ≠ asserta test that returns non-None fails since 8.4
9.0 → 9.1land on 9.0, clear the new errors, then upgrade