pyTest
Mental Model
Pytest is not just a test runner. It is:
- A dependency injection system (fixtures)
- A test discovery engine
- A plugin framework
- A failure introspection tool
If you understand pytest as “functions + magic”, you will plateau early. If you understand it as “a graph of resources resolved by scope and hooks”, you can scale test suites confidently.
Test Collection & Execution Pipeline
High-level flow:
- Discover test files
- Collect test items
- Build fixture dependency graph
- Execute tests
- Apply hooks at every stage
Collection Rules
Pytest collects:
- Files:
test_*.pyor*_test.py - Functions:
test_* - Classes:
Test*(no__init__) - Methods:
test_*
Important subtleties:
- Import side effects run at collection time
- Syntax errors fail collection, not execution
- Conditional skips at module import time affect discovery
Common trap:
- Heavy imports in test modules slow collection, not test execution
Mitigation:
- Defer expensive setup into fixtures
- Use lazy imports inside fixtures
Fixture System as Dependency Graph
Fixtures form a DAG, not a stack.
What “resolution order” actually means in pytest
In pytest, tests do not create fixtures. Instead, pytest:
- Looks at the test function signature
- Determines which fixtures are required
- Recursively resolves their dependencies
- Executes fixtures from the bottom of the dependency graph upward
- Injects the final objects into the test
This is dependency resolution, not execution order chosen by you.
Concrete Example with Realistic Fixtures
@pytest.fixture(scope="session")def config(): return {"db_url": "postgresql://..."}
@pytest.fixture(scope="session")def engine(config): return create_engine(config["db_url"])
@pytest.fixture(scope="function")def db_session(engine): session = Session(engine) yield session session.close()
def test_user_creation(db_session): ...Dependency Graph (What pytest builds internally)
test_user_creation └── db_session (function scope) └── engine (session scope) └── config (session scope)Key point:
- This graph is inferred automatically from function arguments
Bottom-Up Resolution (Why this order exists)
When pytest wants to run test_user_creation:
- It sees it needs
db_session - To create
db_session, it first needsengine - To create
engine, it first needsconfig
So pytest executes:
config → engine → db_session → testThis is what bottom-up means:
- Leaf nodes (
config) first - Root node (
test) last
What “executes once per scope” means here
| Fixture | Scope | How often it runs |
|---|---|---|
| config | session | once per test run |
| engine | session | once per test run |
| db_session | function | once per test |
Important:
engineis reused across all testsdb_sessionis recreated for every test function
Fixture Caching (Critical Concept)
Pytest caches fixture results using:
(fixture_name, scope, params)For this example:
engineis cached once for the entire sessiondb_sessionis cached only for the duration of one test
This is why:
- Session fixtures must be stateless or immutable
- Function fixtures are safe for mutation
Teardown Order (Reverse Resolution)
Because setup is bottom-up, teardown is top-down:
test finishes→ db_session teardown (session.close)→ engine teardown (if any)→ config teardown (if any)This guarantees:
- Short-lived resources are cleaned up first
- Long-lived shared resources remain valid
Why db_session Is the “Boundary Fixture”
In pytest design:
enginerepresents expensive, shared infrastructuredb_sessionrepresents isolated, per-test state
This pattern is intentional and idiomatic pytest.
Rule of thumb:
- Long-lived fixtures = infrastructure
- Short-lived fixtures = state
Common Misunderstanding
❌ Thinking pytest runs fixtures “top to bottom”
❌ Thinking fixtures are created when defined
❌ Thinking order in the file matters
✅ Pytest resolves dependencies, not lines of code
One-Sentence Mental Model
A pytest fixture graph is a dependency tree where:
- pytest creates shared infrastructure once
- layers isolated state on top
- and tears everything down in reverse order
Fixture Scopes (Beyond the Basics)
Available scopes:
- function
- class
- module
- package
- session
Scope Rules
- A fixture may only depend on fixtures with the same or broader scope
- Violations raise
ScopeMismatch
Why:
- Prevents short-lived objects from depending on longer-lived resources
Invalid graph:
session_fixture └── function_fixture ❌Yield Fixtures & Teardown Semantics
Yield fixtures are syntactic sugar over try/finally.
Example:
@pytest.fixturedef resource(): acquire() yield r release()Properties:
- Teardown always runs, even on failure
- Teardown order is reverse dependency order
If a dependent fixture fails during setup:
- Already-created fixtures still tear down correctly
This makes pytest reliable for integration testing.
Parametrization Internals
Function-Level Parametrization
@pytest.mark.parametrize("x", [1, 2, 3])def test_x(x): ...Internals:
- Pytest generates multiple test items
- Each item has a unique node id
Fixture Parametrization
@pytest.fixture(params=[1, 2, 3])def value(request): return request.paramKey difference:
- Fixture parametrization expands the dependency graph
- Function parametrization duplicates the test function
Cartesian Explosion Warning
Fixture parametrization multiplies:
tests × fixture_A × fixture_B × fixture_CMitigations:
- Parametrize high in the dependency graph
- Avoid session-scoped parametrized fixtures unless intentional
Indirect Parametrization
Used to feed parameters into fixtures:
@pytest.mark.parametrize("db", ["sqlite", "postgres"], indirect=True)def test_query(db): ...Key insight:
dbbecomes fixture configuration, not test data
Use cases:
- Backend matrix testing
- Environment switching
The request Object (Advanced Usage)
request exposes pytest internals.
Useful attributes:
request.paramrequest.scoperequest.noderequest.config
Example:
if request.node.get_closest_marker("slow"): ...Use cases:
- Conditional skipping
- Marker-driven fixture behavior
- Dynamic fixture logic
Markers as Metadata
Markers describe tests; they should not implement logic.
Built-ins:
skipskipifxfail
Custom markers must be registered:
[pytest]markers = slow: slow tests integration: external systemsRule:
- Markers classify
- Fixtures implement behavior
Skip vs XFail (Important Distinction)
- skip: test is not executed
- xfail: test is executed, failure is expected
Advanced:
xfail(strict=True)fails the suite if the test unexpectedly passes
Use case:
- Catch silent bug fixes or regressions
Assertion Rewriting Internals
Pytest rewrites assert statements at import time.
This enables:
- Value introspection
- Structural diffs
- Rich error messages
Implications:
- Only plain
assertis rewritten - Assertions inside helper libraries lose introspection
Guideline:
- Keep assertions in test code
Monkeypatching Correctly
Prefer monkeypatch over unittest.mock.patch.
Capabilities:
- Attribute replacement
- Environment variable injection
- Path modification
Advantages:
- Automatic teardown
- Scope safety
Anti-pattern:
- Monkeypatching global state in session-scoped fixtures
Plugin System & Hooks
Pytest is hook-driven.
Common hooks:
pytest_collection_modifyitemspytest_runtest_setuppytest_runtest_callpytest_sessionstart
Use cases:
- Dynamic skipping
- Test reordering
- Custom reporting
Rule:
- Hooks are global
- Use sparingly and document aggressively
pytest vs unittest
| Aspect | pytest | unittest |
|---|---|---|
| Style | Functional | Class-based |
| Fixtures | DI graph | Setup/teardown |
| Assertions | Native assert | Assertion methods |
| Plugins | First-class | Limited |
| Introspection | Rich | Minimal |
Key insight:
- pytest optimizes for developer feedback
- unittest optimizes for framework compatibility
Scaling Test Suites
Common problems:
- Slow collection
- Flaky tests
- Shared mutable state
- Over-parametrization
Mitigations:
- Narrow fixture scopes
- Isolate I/O
- Separate unit and integration suites
- Use
-k,-m, and-xdeliberately
Senior-Level Pitfalls
- Session-scoped fixtures with hidden state
- Excessive
autouse=True - Parametrizing behavior instead of modeling it
- Over-mocking instead of using fakes
- Treating pytest as “just a runner”
What “over-mocking” actually means
Over-mocking happens when you:
- Replace internal behavior instead of external boundaries
- Mock implementation details instead of outcomes
- Assert that mocks were called instead of asserting behavior
This produces tests that:
- Pass while real code is broken
- Break during refactors
- Encode implementation details instead of contracts
Bad Example: Over-Mocking Internal Behavior
def send_welcome_email(user): email = build_email(user) smtp_client.send(email)Over-mocked test
def test_send_welcome_email(mocker): mock_build = mocker.patch("app.email.build_email") mock_smtp = mocker.patch("app.email.smtp_client.send")
send_welcome_email(user)
mock_build.assert_called_once() mock_smtp.assert_called_once()Why this test is weak
- Does not validate the email content
- Does not validate side effects
- Breaks if implementation changes (even if behavior is correct)
- Tests how the function works, not what it does
This is testing wiring, not behavior.
Better Approach: Use a Fake
A fake is:
- A real implementation
- Simplified or in-memory
- Deterministic
- Cheap to run
Fake Example (Preferred)
class FakeSMTP: def __init__(self): self.sent = []
def send(self, email): self.sent.append(email)def test_send_welcome_email(monkeypatch): fake_smtp = FakeSMTP()
monkeypatch.setattr("app.email.smtp_client", fake_smtp)
send_welcome_email(user)
assert len(fake_smtp.sent) == 1 assert "welcome" in fake_smtp.sent[0].subjectWhy this is better
- Tests observable behavior
- Preserves control flow
- Survives refactors
- Encodes expectations, not implementation
Rule:
Mock boundaries, fake collaborators, never mock the function under test.
Over-Mocking Warning Signs
You are over-mocking if:
- You assert
mock.assert_called_*everywhere - Tests fail when you rename a function
- You mock objects defined in the same module as the test subject
- You need to read the test to understand the implementation
Why monkeypatch Is Better (in pytest)
What monkeypatch actually does
monkeypatch:
- Temporarily replaces attributes
- Automatically restores state after the test
- Is scoped to the fixture or test
- Works with modules, env vars, paths
It is state-safe by default.
monkeypatch vs unittest.mock.patch
unittest.mock.patch (Common Pitfalls)
@patch("app.service.get_user")def test_something(mock_get_user): mock_get_user.return_value = userProblems:
- Patch path errors are silent
- Cleanup depends on correct usage
- Easy to leak global state
- Decorator order matters
- Harder to reason about scope
monkeypatch Example (Clearer & Safer)
def test_get_profile(monkeypatch): def fake_get_user(user_id): return User(id=user_id, name="Test")
monkeypatch.setattr("app.service.get_user", fake_get_user)
profile = get_profile(1)
assert profile.name == "Test"Advantages:
- Explicit replacement
- Automatic rollback
- No decorator magic
- Scope is visually obvious
Critical Advantage: Scope Safety
@pytest.fixture(scope="session")def bad_patch(monkeypatch): monkeypatch.setattr("app.config.DEBUG", True)Pytest will error or warn:
- You are mutating global state in a long-lived scope
This is a feature:
- It prevents subtle cross-test contamination
Environment Isolation (Where monkeypatch Shines)
def test_env_behavior(monkeypatch): monkeypatch.setenv("API_KEY", "test-key")
assert load_key() == "test-key"No cleanup code required. No leaking state. No test ordering bugs.
Behavioral Testing Rule of Thumb
| Situation | Use |
|---|---|
| External system | Fake |
| Slow dependency | Fake |
| Pure function | No mock |
| Global state | monkeypatch |
| Third-party call | Fake or thin mock |
| Internal helper | Don’t mock |
One-Sentence Mental Model
- Mocks verify calls
- Fakes verify behavior
- monkeypatch enforces isolation
Final Mental Model
Think of pytest as:
- A graph resolver
- With scoped caching
- And hook-based extensibility
- Optimized for readable failures
Mastery comes from understanding fixture lifetimes, graph expansion, and collection behavior — not from memorizing decorators.