Unit Testing with pytest & Mocking

This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

pytest is the de facto standard test runner for Python: tests are plain functions using the built-in assert statement, with no special assertion API to learn. This page is grounded primarily in the official pytest documentation rather than the general Python docs, since pytest itself is a third-party project.

Installing pytest and Writing a First Test

Install pytest with pip:

pip install pytest

pytest discovers tests on its own: by default it recurses into the current directory and collects any file matching test_*.py or test.py, then within each file any function named test (and any method named test_* on a class named Test*). No test registry or base class is required. Discovery conventions are documented under Conventions for Python test discovery.

# test_math_utils.py
from math_utils import add

def test_add_returns_sum():
    assert add(2, 3) == 5

def test_add_handles_negatives():
    assert add(-1, 1) == 0

pytest rewrites assert at import time so a failure shows the actual values of every sub-expression — unlike the standard library’s unittest, there is no assertEqual, assertTrue, or similar method to remember.

Running Tests

Run the whole suite from the project root, add -v for one line per test, and narrow the run to a single file, a single test, or a substring/expression match with -k:

pytest                                    # run every discovered test
pytest -v                                 # verbose: one line per test
pytest test_math_utils.py                 # only this file
pytest test_math_utils.py::test_add_returns_sum   # only this test
pytest -k "add and not negative"          # only tests whose name matches the expression

A failure report shows the assertion, with the values pytest substituted in:

$ pytest -v
test_math_utils.py::test_add_returns_sum PASSED
test_math_utils.py::test_add_handles_negatives FAILED

================================== FAILURES ===================================
______________________________ test_add_handles_negatives ______________________________

    def test_add_handles_negatives():
>       assert add(-1, 1) == 2
E       assert 0 == 2
E        +  where 0 = add(-1, 1)

test_math_utils.py:6: AssertionError
========================== 1 failed, 1 passed in 0.01s ==========================

Full command-line usage is documented under How to invoke pytest.

Fixtures

A fixture is a function decorated with @pytest.fixture that provides setup data or a resource to any test that names it as a parameter. By default a fixture runs once per test (scope="function"); scope="module" or scope="session" share one instance across many tests:

import pytest

@pytest.fixture
def sample_data():
    return {"id": 1, "name": "widget"}

def test_name_is_widget(sample_data):
    assert sample_data["name"] == "widget"

A fixture written with yield splits into setup (before the yield) and teardown (after it), which runs even if the test fails:

import pytest

@pytest.fixture(scope="module")
def db_connection():
    conn = connect_to_test_db()   # setup
    yield conn
    conn.close()                  # teardown, always runs

def test_query_returns_rows(db_connection):
    assert db_connection.execute("SELECT 1").fetchone() is not None

Fixtures can depend on other fixtures simply by naming them as parameters — pytest resolves the whole chain before the test runs:

import pytest

@pytest.fixture
def db_connection():
    conn = connect_to_test_db()
    yield conn
    conn.close()

@pytest.fixture
def seeded_db(db_connection):
    db_connection.execute("INSERT INTO widgets VALUES (1, 'widget')")
    return db_connection

def test_seeded_row_exists(seeded_db):
    assert seeded_db.execute("SELECT * FROM widgets").fetchone() is not None

Fixtures, their scopes, and composition are covered in full under How to use fixtures.

Parametrization and Markers

@pytest.mark.parametrize runs the same test body once per set of inputs, reported as separate test IDs:

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

Markers also flag a test’s status directly: skip never runs it, and xfail runs it but does not count a failure against the suite (an unexpected pass is reported separately, as "XPASS"):

import pytest

@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    assert compute_future() == 42

@pytest.mark.xfail(reason="2.675 has no exact binary representation, so round() gives 2.67")
def test_rounding():
    assert round(2.675, 2) == 2.68

Parametrization is documented under How to parametrize fixtures and test functions, and skip/xfail under How to use skip and xfail.

Mocking

Mocking replaces a real dependency — a network call, a database, the clock — with a stand-in so a test can run fast and deterministically. Neither the introductory books consulted for this section nor the official Python docs cover pytest itself; the Python docs only document the standard library’s own unittest/unittest.mock, which is what pytest projects typically reach for, so this section is grounded in the unittest.mock reference and pytest’s own monkeypatch documentation instead.

Mock, MagicMock, and call assertions

A plain Mock records every call made to it; MagicMock additionally implements the "magic" dunder methods (_len_, _iter_, and so on) so it can stand in for more than a plain attribute:

from unittest.mock import Mock

def send_welcome_email(mailer, address):
    mailer.send(address, subject="Welcome")

def test_send_welcome_email_calls_mailer():
    mailer = Mock()
    send_welcome_email(mailer, "user@example.com")
    mailer.send.assert_called_once_with("user@example.com", subject="Welcome")
    print(mailer.send.call_args)   # call('user@example.com', subject='Welcome')

assert_called_once_with(…​) fails loudly if the call happened zero times, more than once, or with different arguments; .call_args exposes the most recent call’s positional and keyword arguments directly for custom checks.

patch() as a decorator and a context manager

patch() temporarily replaces an attribute (typically an imported name) with a MagicMock, restoring the original afterwards. As a decorator it injects the mock as a test argument; as a context manager it scopes the replacement to a with block:

from unittest.mock import patch

# billing.py: `def charge(amount): return payment_gateway.charge(amount)`

@patch("billing.payment_gateway")
def test_charge_calls_gateway(mock_gateway):
    from billing import charge
    charge(1000)
    mock_gateway.charge.assert_called_once_with(1000)

def test_charge_calls_gateway_context_manager():
    with patch("billing.payment_gateway") as mock_gateway:
        from billing import charge
        charge(1000)
        mock_gateway.charge.assert_called_once_with(1000)

pytest’s monkeypatch fixture

monkeypatch is pytest’s own built-in fixture for patching attributes, dictionary items, and environment variables for the duration of a single test — it undoes every change automatically, with no with block or decorator needed:

import os

import pytest

def get_api_url():
    return os.environ["API_URL"]

def test_get_api_url(monkeypatch):
    monkeypatch.setenv("API_URL", "https://staging.example.com")
    assert get_api_url() == "https://staging.example.com"

def test_get_api_url_missing(monkeypatch):
    monkeypatch.delenv("API_URL", raising=False)
    with pytest.raises(KeyError):
        get_api_url()

Mock vs. stub vs. spy

The three terms describe how a test double is used, not a different class in unittest.mock:

  • A mock asserts on how it was called (assert_called_once_with) — use it to verify an interaction happened, such as an email actually being sent.

  • A stub just returns canned data (mock.return_value = …​) so the test can exercise the code around it — use it when you only need a dependency to answer with something, and don’t care whether or how it was called.

  • A spy wraps a real object and records calls without replacing its behaviour (Mock(wraps=real_object)) — use it to confirm a real method ran while still observing what was called.

Reach for a stub first when only a return value matters, a mock when the interaction itself is the thing under test, and a spy when the real implementation must still run.

Organizing a Test Suite

Mirror the source layout under a tests/ directory, and factor fixtures shared across files into a conftest.py — pytest loads it automatically, with no import needed:

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py
│       └── billing.py
└── tests/
    ├── conftest.py
    ├── test_billing.py
    └── test_math_utils.py
# tests/conftest.py
import pytest

@pytest.fixture
def sample_data():
    return {"id": 1, "name": "widget"}

Configure pytest itself in pyproject.toml (or an equivalent pytest.ini) rather than passing flags by hand every time:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --strict-markers"
markers = [
    "slow: marks tests as slow-running",
]

conftest.py sharing is covered alongside fixtures at fixture scope and sharing, and full configuration file options are under Configuration.

The pytest Test Lifecycle

For each test, pytest resolves its fixtures, runs their setup up to the yield, runs the test body, then runs teardown in reverse order — independently of whether the test passed:

flowchart TD A[Discover test files: test_*.py, *_test.py] --> B[Collect test functions: test_*] B --> C[For each collected test] C --> D["Run fixture setup (up to yield)"] D --> E[Run the test function] E --> F["Run fixture teardown (after yield)"] F --> G{Outcome} G -->|assert held, no exception| H[Report: pass] G -->|AssertionError| I[Report: fail] G -->|other exception during setup/call| J[Report: error] H --> C I --> C J --> C

The full pytest documentation entry point, covering everything on this page in more depth, is docs.pytest.org.

See Also

  • Exceptions — the try/except/assert machinery pytest builds on.

  • Debugging and Tooling — debuggers and other tooling to use alongside a failing test.