🧩01What is a fixture?
A fixture is a function that prepares something a test needs, and hands that thing to the test. The word is borrowed from lab work: a "fixture" is the rig that holds your specimen in a known, repeatable position so every measurement starts from the same place.
In pytest, a fixture is a normal Python function marked with the @pytest.fixture decorator. A test asks for a fixture by naming it as a parameter. pytest sees that name, runs the fixture, and passes whatever the fixture produces into the test as an argument.
import pytest
@pytest.fixture
def sample_cart():
# build the "known specimen"
return {"apples": 3, "pears": 2}
def test_total_items(sample_cart): # <- asks for the fixture by name
assert sum(sample_cart.values()) == 5
The key mental shift: the test never calls sample_cart() itself. It just declares "I need a sample_cart" by putting that word in its parameter list, and pytest supplies it. That pattern — you declare a dependency by name and the framework provides it — is called dependency injection, and it is the whole idea behind fixtures.
You could. Fixtures earn their keep when the setup is (a) shared by many tests, (b) expensive (a database, a temp directory, a network client), or (c) needs cleanup afterward. Fixtures give you one place to define the setup and its teardown, and let pytest reuse it intelligently.
Fixtures are also composable: a fixture can request other fixtures the same way a test does. That lets you build small pieces (a database connection) and assemble them into bigger ones (a seeded database with a logged-in user) without repeating yourself.
🔌02How does a fixture work?
Under the hood pytest does something that feels like magic but is completely mechanical: it reads the signature of your test function and matches parameter names against a registry of known fixtures.
Here is the sequence pytest runs for every single test it is about to execute:
When a test asks for user, pytest reads that name, finds the user fixture, sees it asks for db, and keeps recursing until every dependency is resolved — building a directed graph and running it from the bottom up:
Two consequences fall out of this that trip people up:
- Names matter, types don't. pytest matches purely on the parameter name being identical to a fixture name. There is no type annotation lookup, no registration by class. Rename the fixture and every test that used it breaks.
- Fixtures form a graph, not a list. Because fixtures can request fixtures, pytest builds a directed acyclic graph and topologically sorts it. If
dbis needed byuserwhich is needed by your test,dbruns first, thenuser, then the test — automatically.
@pytest.fixture
def db():
return Database(":memory:") # leaf of the graph
@pytest.fixture
def user(db): # depends on db
return db.create_user("ada")
def test_login(user): # depends on user → depends on db
assert user.name == "ada"
# execution order pytest computes: db() → user(db) → test_login(user)
You can see the entire resolved graph for your suite without running anything by asking pytest to list fixtures: pytest --fixtures shows every available fixture and where it came from, and pytest --setup-plan prints the exact setup/teardown order it would execute.
⏸️03What is yield in Python?
yield is a keyword that pauses a function, hands a value back to the caller, and remembers exactly where it stopped so it can resume later from that same point — with all its local variables intact.
A normal function has one exit: return. When it returns, its stack frame is destroyed and all its local state is gone. A function containing yield is fundamentally different — Python compiles it into a generator function. Calling it does not run the body; it hands you a generator object, a resumable, paused computation.
def plain():
return 1 # runs, exits, frame discarded
def gen():
print("A: before yield")
yield 1 # PAUSE here, hand back 1
print("B: after yield") # resumes here on next request
yield 2
g = gen() # nothing printed yet! body hasn't run
next(g) # prints "A: before yield", returns 1, PAUSES
next(g) # prints "B: after yield", returns 2, PAUSES
next(g) # raises StopIteration — body finished
The crucial property is suspended state. When the generator pauses at a yield, its local variables, its position in the code, even a half-finished loop — all of it is frozen and stored on the generator object. The next time you drive it forward, execution picks up on the line right after the yield, as if it had never stopped.
Think of a return function as a worker who does a job and goes home. A yield function is a worker who does part of a job, freezes mid-task holding a result out to you, and waits — indefinitely — until you say "continue." That "freeze and hold something out" behavior is exactly what setup/teardown needs, which is why pytest built fixtures on it.
Generators are what power for loops (a for loop just calls next() until StopIteration), lazy sequences, and streaming pipelines. But for our purposes the single most important fact is: code before a yield runs now; code after it runs later, when someone resumes the generator.
🔁04How does yield work inside a pytest fixture?
This is where §03 pays off. pytest treats a yield fixture as a two-act play: everything before the yield is setup, the value you yield is what the test receives, and everything after the yield is teardown that runs once the test (or scope) is done.
@pytest.fixture
def tmp_server():
server = Server()
server.start() # ← SETUP (runs before the test)
yield server # ← the test runs right here, with `server`
server.stop() # ← TEARDOWN (runs after the test finishes)
def test_ping(tmp_server):
assert tmp_server.ping() == "pong"
Mechanically, pytest does this: it calls your fixture function, which (because it contains yield) returns a generator. pytest calls next() on it once — that runs the setup and stops at the yield, capturing the yielded server. pytest injects that value into the test. When the test is over, pytest calls next() again, which resumes the generator past the yield and runs your teardown until the generator finishes.
pytest your fixture generator │ ├── next(gen) ───────────▶ server.start(); yield server (pause) │ ◀── receives `server` │ ├── run test_ping(server) │ └── next(gen) ───────────▶ resumes: server.stop() (StopIteration)
🔁 Step through a yield fixture
click Step — setup runs, control pauses at yield, the test runs, then teardown resumes past the yield
ready — nothing has run yet ⏸️
Why teardown is reliable
The teardown after yield runs even if the test fails or raises an exception. pytest guarantees the generator is driven to completion during teardown, so your server.stop() is not skipped just because an assertion blew up. (The one case it won't run: if the setup before the yield itself raises, then there is nothing to tear down and pytest reports a fixture error.)
Before yield fixtures, cleanup was registered with request.addfinalizer(fn). That still exists and is occasionally useful (e.g. registering several finalizers in a loop), but yield is the idiomatic modern form — the teardown lives visually right next to the setup it undoes.
If you have several yield fixtures in play, their teardowns run in reverse order of setup — last set up, first torn down — mirroring how nested resources should be released. This is the same LIFO discipline as a stack of with blocks.
⏱️05What are fixture scopes?
A fixture's scope answers one question: how often should this fixture be set up and torn down? Once per test? Once per file? Once for the entire run? Scope is the dial that controls the lifetime of the fixture's cached value.
You set it with the scope= argument on the decorator. There are five, from narrowest to widest:
| Scope | Set up / torn down… | Typical use |
|---|---|---|
| function | Once per test function (the default) | Fresh data, isolated state — a new cart, a clean dict |
| class | Once per test class | Shared setup across methods of one Test* class |
| module | Once per .py test file | A resource all tests in one file share |
| package | Once per package (directory of tests) | Setup shared across a folder of test modules |
| session | Once per entire pytest run | Expensive singletons — a DB engine, a Docker container, an auth token |
The rule most people remember is the nesting session → module → function: a session-scoped fixture is built once and lives for the whole run; a module-scoped one is rebuilt for each file; a function-scoped one is rebuilt for every test.
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("postgresql://…") # slow — do it ONCE
yield engine
engine.dispose() # torn down at end of the run
@pytest.fixture(scope="function") # default; shown for contrast
def db_session(db_engine):
conn = db_engine.connect()
txn = conn.begin()
yield conn
txn.rollback() # fresh, isolated per test
conn.close()
⏱️ Fixture scope, live
pick a scope — watch how often the fixture is built & torn down across 6 tests in 2 files
fixture built 6× · torn down 6× across 6 tests — a fresh instance every test — maximum isolation
A fixture may only depend on fixtures of equal or wider scope. A session fixture cannot request a function fixture — the wide one outlives the narrow one, so there would be nothing coherent to inject. pytest raises a ScopeMismatch error if you try. The pattern above is correct: the function-scoped db_session depends on the session-scoped db_engine, never the reverse.
There is also dynamic scope: scope can be a function that returns the scope string at collection time, letting a command-line flag decide whether a fixture is session- or function-scoped. That's advanced, but worth knowing it exists.
♻️06Is fixture scope related to singleton behavior?
Yes — and this is a genuinely useful way to understand it. Within its scope, a fixture behaves like a singleton: the first request builds it, and every later request in that same scope gets the identical, cached object back, not a fresh one.
A classic singleton means "there is exactly one instance of this thing, globally." A fixture is a scoped singleton: exactly one instance per scope window. Widen the scope and the window of one-ness widens with it.
@pytest.fixture(scope="session")
def shared():
return object()
def test_a(shared):
test_a.seen = id(shared)
def test_b(shared):
# SAME object — session scope cached it across both tests
assert id(shared) == test_a.seen # passes
#
# If `shared` were scope="function", each test would get a
# DIFFERENT object() and this assert would FAIL.
So the relationship is precise: scope defines the cache key. pytest caches a fixture's result keyed by (fixture, scope-instance). Two tests in the same module share a module-scoped fixture's value because they map to the same cache key; two tests in different modules do not.
It helps because it explains reuse and shared mutable state: if a session-scoped fixture returns a mutable object and one test mutates it, later tests see the mutation. That's the singleton foot-gun, and it's why "fresh state" fixtures stay function-scoped.
It stops because a fixture is not a language-level singleton pattern (no private constructor, no global access point). It's a caching policy pytest applies, scoped and torn down deterministically — closer to a scoped service in a dependency-injection container than to the Gang-of-Four Singleton.
📄07How are fixtures defined in conftest.py?
A conftest.py file is a special, pytest-recognized module where you put fixtures (and hooks, and plugins) you want available to every test in that directory and below — without importing anything.
You define a fixture in conftest.py exactly the way you would anywhere else. The only difference is location: because the file is named conftest.py, pytest discovers and loads it on its own, and the fixtures inside become visible to sibling and descendant test files automatically.
import pytest
@pytest.fixture(scope="session")
def api_client():
client = ApiClient(base_url="http://localhost:8000")
yield client
client.close()
# notice: NO "from conftest import api_client"
def test_list_users(api_client): # just name it — pytest supplies it
assert api_client.get("/users").status == 200
The name conftest is short for "conftest" as in configuration for tests — a local, per-directory hook point. You can have many of them: one at the project root for truly global fixtures, and more specific ones deeper in the tree for fixtures only relevant to a subset of tests. Fixtures with the same name in a nearer conftest.py override those in a farther one, which is a deliberate and useful feature.
Fixtures shared by more than one test file; pytest hook implementations (§11); plugin registration; and small helpers those need. What does not belong: actual test functions (pytest won't collect tests from conftest.py by design) and heavy application logic.
🧭08How does pytest discover and use conftest.py?
pytest discovers conftest.py files by walking the filesystem, not by importing them from your code. For any test it collects, it loads every conftest.py sitting on the path from the project's rootdir down to the directory that test lives in.
Concretely, given this layout and a run of pytest tests/api/test_auth.py:
The fixtures visible to test_auth.py are the union of all three loaded conftest.py files, plus fixtures defined in test_auth.py itself, plus fixtures from any installed plugins. Nearer files win on name collisions. The tests/ui/conftest.py is never touched because the test being run is not underneath it — this is how pytest keeps unrelated corners of a large suite isolated.
Loaded once, applied broadly
pytest imports each relevant conftest.py a single time at the start of collection and registers its contents into the plugin/fixture system. From that point the fixtures and hooks it defines are simply part of pytest's runtime for the matching scope. There is no re-import per test.
pytest computes a rootdir at startup — the common ancestor of your test paths, usually the directory holding pyproject.toml / pytest.ini / setup.cfg. The rootdir bounds how far up the tree conftest discovery reaches and anchors config resolution. You can see it printed in the header of any run.
🚫09Should conftest.py be imported manually?
No. You should never write import conftest or from conftest import .... pytest loads it for you; importing it by hand fights the framework and causes real bugs.
Why it's actively wrong, not just unnecessary:
- Double loading. pytest already imports
conftest.pyas a plugin. If you also import it, Python may load it a second time under a different module identity, so you end up with two copies of the same fixtures and confusing "fixture not found / defined twice" errors. - It breaks discovery. The entire point of
conftest.pyis importless availability by directory. Manually importing couples files together and defeats the mechanism that makes fixtures flow down the tree. - Fixtures aren't meant to be called. A fixture function decorated with
@pytest.fixtureis not an ordinary function you invoke — calling it directly is an error in modern pytest. You request it by name; you don't import and call it.
Put a fixture in the right conftest.py, name it as a parameter, and stop there. If you're reaching for import conftest, the fixture is either in the wrong directory or you want a plain helper function — and a plain helper belongs in a normal, importable module (e.g. tests/helpers.py), not in conftest.py.
The same holds for the test files themselves: you don't import your test modules either. pytest's collector imports them. Your job is to write functions named test_* and fixtures; pytest's job is the importing.
🤝10What is the correct way to share fixtures across test files?
Put the fixture in a conftest.py at the lowest directory that is a common ancestor of every test that needs it. That is the idiomatic, framework-blessed way to share — no imports, no plugins, no ceremony.
Choose the level by asking "who needs this?":
| Who needs the fixture | Where it goes |
|---|---|
| Only one test file | Define it in that file directly |
| Every file in one subfolder | tests/that_folder/conftest.py |
| Every test in the whole suite | tests/conftest.py (or project-root conftest.py) |
| Multiple separate projects / installable | A pytest plugin (a pip-installable package) |
The three ordered options, from most local to most reusable:
- conftest.py — the default answer for sharing inside one repository. Fixtures placed here are automatically available to all tests at or below that directory. This covers ~95% of real cases.
- A fixture plugin module — for sharing across repos, package a module of fixtures and register it via
pytest_pluginsor as an installable plugin with an entry point. Nowpip install your-fixturesmakes them available anywhere. - The
pytest_pluginsvariable — in a rootconftest.pyyou can writepytest_plugins = ["myapp.testing.fixtures"]to pull a module of fixtures into scope explicitly. Useful when fixtures live inside your application package rather than the test tree.
Do not share fixtures by importing them between test files or from conftest.py (see §09). Do not copy-paste a fixture into every file. Both defeat the discovery model and create duplicate-definition hazards. If two folders need the same fixture, hoist it up to their common-ancestor conftest.py.
🪝11What are pytest hooks?
Hooks are pytest's plugin extension points — named functions that pytest calls at specific moments in its lifecycle (startup, collection, before each test, after each test, at reporting) so that you, or a plugin, can inject custom behavior.
pytest is itself built on a plugin system called pluggy. pytest defines a set of hook specifications (the callable slots, all named pytest_*), and any plugin — including your conftest.py — can supply a hook implementation by defining a function with the matching name. When the moment arrives, pytest calls every registered implementation of that hook.
def pytest_addoption(parser): # add a --slow CLI flag
parser.addoption("--slow", action="store_true", default=False)
def pytest_collection_modifyitems(config, items):
# runs AFTER collection — skip @slow tests unless --slow given
if config.getoption("--slow"):
return
skip = pytest.mark.skip(reason="needs --slow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip)
def pytest_runtest_setup(item): # runs before EACH test
print(f"about to run {item.nodeid}")
A representative slice of the hook lifecycle, in call order:
pytest_configure once, after config is built pytest_collection discover test files & items pytest_collection_modifyitems reorder / filter / mark collected tests for each test item: pytest_runtest_setup fixtures set up pytest_runtest_call the test body runs pytest_runtest_teardown fixtures torn down pytest_runtest_makereport build the pass/fail report pytest_sessionfinish once, at the very end
Fixtures provide values to tests that ask for them (pull, by name). Hooks let you customize pytest's behavior at fixed lifecycle points (push, pytest calls you). Fixtures are for test inputs; hooks are for changing how the framework runs. Every non-trivial pytest plugin — pytest-xdist, pytest-django, pytest-cov — is essentially a bundle of hook implementations.
🔬12How does pytest itself actually work?
A pytest run is two big phases wrapped in a plugin manager: first it collects every test into a tree of nodes, then it executes each one, running fixtures around it and building a report. Almost every step is a hook, which is why pytest is so extensible.
Phase 0 — bootstrap
pytest starts, reads configuration (from pyproject.toml, pytest.ini, the CLI), determines the rootdir, and initializes pluggy, its plugin manager. It registers built-in plugins, installed third-party plugins, and every relevant conftest.py. One quiet but important step here: pytest installs its assertion rewriting import hook (see §13).
Phase 1 — collection
pytest walks the test paths and builds a tree of collector and item nodes: Session → Package → Module → Class → Function. It imports each test module (this is why your test files must be importable), finds functions named test_* and classes named Test*, and expands any @pytest.mark.parametrize into multiple concrete items. Each leaf is a test item with a unique nodeid like tests/test_users.py::test_login[case2].
Phase 2 — execution
For each collected item pytest runs the setup → call → teardown protocol:
Phase 3 — reporting & exit
pytest aggregates all outcomes, prints the summary (18 passed in 0.3s), and sets a process exit code (0 = all passed, 1 = failures, others for usage errors / no tests). CI systems read that exit code to pass or fail a build.
pytest is a plugin manager (pluggy) that collects your test functions into a node tree, then for each one lazily builds and injects a fixture dependency graph, runs the function, captures the assertion outcome, and reports it — with hooks exposed at every seam so plugins can reshape any part of that.
python file.py
🆚13How is pytest different from just running a Python test file?
Running python test_thing.py executes a script top to bottom and stops at the first uncaught error. pytest is a framework that finds your tests, runs them independently, explains failures in detail, injects dependencies, and reports structured results. The gap is large.
| Concern | python test.py | pytest |
|---|---|---|
| Finding tests | You wire up if __name__ == "__main__" and call each function by hand | Auto-collects every test_* across the tree |
| Isolation | First exception halts the whole script | Each test runs independently; one failure doesn't stop the rest |
| Assertions | Bare assert x == y just says AssertionError | Rewrites asserts to show both values: assert 5 == 4 → the actual 5 and 4 |
| Setup/teardown | Manual, repeated in every function | Fixtures with scope & automatic cleanup |
| Parametrization | Hand-rolled loops | @parametrize expands to real, individually-reported cases |
| Selection | Edit the file | -k expr, -m marker, run one nodeid |
| Reporting | print() and eyeballing | Summary, durations, exit code for CI, tracebacks |
| Ecosystem | None | coverage, parallelism, retries, mocking — as plugins |
The signature trick: assertion rewriting
The feature that most feels like magic is assertion introspection. In plain Python, assert a == b raises a bare AssertionError with no context. pytest installs an import hook that rewrites the bytecode of your test modules as they load, replacing plain asserts with instrumented versions that capture the operands. That's why a failing pytest assert shows you exactly what each side evaluated to — no need for self.assertEqual or special assert methods like unittest requires.
# the code you write:
def test_math():
assert add(2, 2) == 5
# python test.py → "AssertionError" (that's all)
# pytest → assert 4 == 5
# + where 4 = add(2, 2) ← the introspected detail
So the difference isn't cosmetic. A plain script can contain asserts, but pytest turns "a file that crashes on the first bad assert" into "a test suite that reports every outcome, tells you precisely why each failure happened, wires up dependencies, and integrates with CI."
📦14What is an object?
In Python, an object is the single, universal kind of thing that all data is. Every value you can name — a number, a string, a list, a function, a class, a module, even a type itself — is an object. There is nothing in Python that is not an object.
The language model says every object has exactly three properties:
- Identity — a fixed, unique tag for its lifetime, revealed by
id(x). Two names pointing at the same object share an identity (a is b). - Type — what kind of object it is and what it can do, revealed by
type(x). The type never changes. - Value — the actual contents. Some objects are mutable (a
listcan change value), some immutable (anintorstrcannot).
x = [1, 2, 3]
id(x) # 140237… — its identity
type(x) # <class 'list'> — its type
x # [1, 2, 3] — its value (mutable)
# functions, classes, modules are objects too:
def f(): pass
f.tag = 99 # attach an attribute to a function — it's an object
type(f) # <class 'function'>
type(int) # <class 'type'> — even the type `int` is an object
An object generally bundles state (its data, the value) with behavior (its methods — functions that act on that data). You reach both through attribute access: obj.data and obj.method(). This is why the pytest concepts in §15 — a "session," a "module," a "function under test" — are all literally objects: they carry data (a nodeid, a parent, markers) and behavior (collect, run, report).
Because functions are objects, pytest can hold your test_login function in a variable, read its parameter names, attach markers to it, and call it later. Because modules are objects, pytest can import your test file and iterate its attributes to find tests. Fixtures, markers, and collection all rely on "everything is a first-class object you can inspect and manipulate."
🌳15What is a session, function, module, code…?
These words carry two related meanings: they're kinds of Python objects, and pytest also uses them as the names of the nodes in its collection tree (and as fixture scopes). Understanding both removes most of the mystery around scope and collection.
As Python objects
| Term | What it is as an object |
|---|---|
| module | A .py file loaded into memory — a module object whose attributes are the names defined at its top level. import x gives you one. |
| function | A callable object created by def. Has a name, a signature, a docstring, and — inside it — a code object. |
| code | The compiled bytecode object (func.__code__) holding the actual instructions, argument names, constants, and line numbers. It's what the interpreter executes. |
| class | A blueprint object produced by class; calling it builds instances. Classes are themselves objects (instances of type). |
As pytest's node tree
pytest models your suite as a tree of nodes, and reuses these very words for the levels. This is the same hierarchy that defines fixture scopes:
So when §05 says a fixture is scope="session" or scope="module", it is naming a level of this tree. A session-scoped fixture is cached on the Session node (one per run); a module-scoped fixture is cached on each Module node (one per file). "Function scope" means cached on the individual Function item — the default, freshest, narrowest level.
The two meanings are not a coincidence. pytest's Module node wraps the Python module object; its Function item wraps your test function object. pytest is an inspection layer over ordinary Python objects — session, module, class, function are just the granularities at which it organizes, scopes, and reports on them.
pyproject.toml is the hub
🧾16What is the purpose of a .toml file?
A .toml file is a configuration file written in TOML — "Tom's Obvious, Minimal Language." Its whole purpose is to hold settings in a format that's easy for humans to read and edit and unambiguous for tools to parse. In modern Python, the canonical one is pyproject.toml, the single place that configures your project and its tools — pytest included.
TOML's data model is simple: key = value pairs, grouped under bracketed tables (sections). Values are typed — strings, numbers, booleans, dates, arrays, and nested tables.
# a table (section) is written in [brackets]
[project]
name = "etch" # string
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pytest", "httpx"] # array
# nested table via dotted name — pytest reads THIS one:
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q" # default CLI flags
testpaths = ["tests"] # where to look for tests
Why the ecosystem standardized on it:
- One file, many tools. Before
pyproject.toml, each tool had its own file (setup.cfg,pytest.ini,.flake8, …). Now build backends, linters, formatters, and pytest all read their own[tool.*]table from one place. - Unambiguous & typed. Unlike ad-hoc INI, TOML has a real spec:
"3"is a string,3is an integer,trueis a boolean. No guessing. - Standardized in Python. Python 3.11+ ships
tomllibin the standard library to read TOML, cementing it as the config format.
pytest looks for its settings under the [tool.pytest.ini_options] table in pyproject.toml (the ini_options name is historical — the same keys used to live in pytest.ini). Here you set default flags, test paths, the minimum pytest version, logging config — and you register your markers, which is exactly what §17 is about.
-m
🏷️17What are tags / markers in TOML and test modules?
A marker is a label you stick on a test with @pytest.mark.something. Markers let you tag tests ("this one is slow," "this needs a network," "skip this on Windows") and then select, skip, or specially treat groups of them. You declare/register the tag names in TOML; you apply them in your test modules.
Applying markers in a test module
import pytest
@pytest.mark.slow # custom tag — "this test is slow"
def test_full_reindex():
...
@pytest.mark.skip(reason="flaky on CI") # built-in marker
def test_known_bad():
...
@pytest.mark.parametrize("n,expected", [(2, 4), (3, 9)])
def test_square(n, expected): # built-in — expands to 2 test items
assert n * n == expected
pytest ships built-in markers — skip, skipif, xfail (expected to fail), parametrize, usefixtures — and lets you invent custom markers like slow or integration for your own selection needs.
Registering custom markers in TOML
Custom markers should be registered so pytest knows they're intentional (unregistered ones trigger a warning, and --strict-markers turns that into an error — a good safety net against typos like @pytest.mark.slwo):
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: touches external services",
]
Selecting by marker on the command line
pytest -m slow # only tests tagged @pytest.mark.slow
pytest -m "not slow" # everything EXCEPT slow ones
pytest -m "integration and not slow" # boolean expressions work
pytest -k reindex # -k selects by NAME substring, not markers
🏷️ Try marker selection
pick a -m expression — see exactly which of these 5 tests pytest would run
5 of 5 selected · pytest runs 5 tests
-m selects by marker (the tags you registered). -k selects by name (a substring/expression over test names). Both narrow a run, but one reads your labels and the other reads your identifiers. TOML is where marker names are declared; the test module is where they're applied; the CLI is where they're used to filter.
🔗18How do all these pieces work together?
Here is the whole system in one story. Picture a small project and a single command — pytest -m "not slow" — and watch every concept from §01–§17 do its job in sequence.
myproject/
├── pyproject.toml # [tool.pytest.ini_options]: markers, testpaths (§16,§17)
├── conftest.py # session fixtures + a hook (§07,§08,§11)
└── tests/
├── conftest.py # function fixtures for this folder (§07)
└── test_orders.py # the actual tests (§01)
1. BOOTSTRAP pytest reads pyproject.toml (§16): loads markers, testpaths, default flags. Starts pluggy, installs assertion rewriting (§13). Finds & loads both conftest.py files by walking the tree (§08) — you never imported them (§09). 2. COLLECTION Builds the node tree: Session → Module → Function (§15). Imports test_orders.py (a module object, §14). Finds test_* functions (function objects, §14). Expands @parametrize into items. A hook, pytest_collection_modifyitems (§11), plus the -m "not slow" filter (§17), deselects @pytest.mark.slow tests. 3. EXECUTION For each surviving item: setup → reads the test's parameter names, resolves the fixture DAG (§02). db_engine (session scope, §05) is built ONCE and cached like a scoped singleton (§06); db_session (function scope) is fresh per test. Each yield fixture runs its setup, pauses at `yield`, hands over its value (§03,§04). call → the test body runs; a bare `assert` gives a rich failure thanks to rewriting (§13). teardown → generators resume PAST their `yield` (§04), in reverse order; function-scoped fixtures finalize now, session-scoped ones wait until the very end. 4. REPORT Outcomes become dots / F / s. Summary printed, exit code set for CI. Session-scoped teardowns (db_engine.dispose) run last.
Read as a single sentence: TOML configures the run and names your markers; conftest.py supplies shared fixtures and hooks without imports; pytest collects your test functions into a session/module/function tree of objects; for each test it lazily builds a scoped fixture graph — using yield to interleave setup and teardown and scope to decide how singleton-like each fixture is — runs the body with introspected assertions, and reports a structured result.
Objects (§14) make functions and modules inspectable → pytest inspects them to collect tests and read fixture names (§02,§15) → fixtures (§01) provide inputs via dependency injection → yield (§03) gives fixtures setup+teardown (§04) → scope (§05) makes them scoped singletons (§06) → conftest.py shares them without imports (§07–§10) → hooks (§11) customize the run (§12) → TOML (§16) and markers (§17) configure and select it. Pull any thread and the rest follows.