Skip to content

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_*.py or *_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:

  1. Looks at the test function signature
  2. Determines which fixtures are required
  3. Recursively resolves their dependencies
  4. Executes fixtures from the bottom of the dependency graph upward
  5. 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:

  1. It sees it needs db_session
  2. To create db_session, it first needs engine
  3. To create engine, it first needs config

So pytest executes:

config → engine → db_session → test

This is what bottom-up means:

  • Leaf nodes (config) first
  • Root node (test) last

What “executes once per scope” means here

FixtureScopeHow often it runs
configsessiononce per test run
enginesessiononce per test run
db_sessionfunctiononce per test

Important:

  • engine is reused across all tests
  • db_session is recreated for every test function

Fixture Caching (Critical Concept)

Pytest caches fixture results using:

(fixture_name, scope, params)

For this example:

  • engine is cached once for the entire session
  • db_session is 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:

  • engine represents expensive, shared infrastructure
  • db_session represents 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.fixture
def 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.param

Key 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_C

Mitigations:

  • 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:

  • db becomes fixture configuration, not test data

Use cases:

  • Backend matrix testing
  • Environment switching

The request Object (Advanced Usage)

request exposes pytest internals.

Useful attributes:

  • request.param
  • request.scope
  • request.node
  • request.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:

  • skip
  • skipif
  • xfail

Custom markers must be registered:

[pytest]
markers =
slow: slow tests
integration: external systems

Rule:

  • 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 assert is 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_modifyitems
  • pytest_runtest_setup
  • pytest_runtest_call
  • pytest_sessionstart

Use cases:

  • Dynamic skipping
  • Test reordering
  • Custom reporting

Rule:

  • Hooks are global
  • Use sparingly and document aggressively

pytest vs unittest

Aspectpytestunittest
StyleFunctionalClass-based
FixturesDI graphSetup/teardown
AssertionsNative assertAssertion methods
PluginsFirst-classLimited
IntrospectionRichMinimal

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 -x deliberately

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].subject

Why 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 = user

Problems:

  • 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

SituationUse
External systemFake
Slow dependencyFake
Pure functionNo mock
Global statemonkeypatch
Third-party callFake or thin mock
Internal helperDon’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.