Advanced Testing

Updated

September 8, 2026

Advanced Testing

Some code talks to the environment: env vars, a printer, a clock, stdout. The boring default is still a real function with an argument you can pass in tests. When the seam is already os.environ or print, pytest’s monkeypatch and capsys, and unittest.mock.patch, are the stdlib-shaped tools. Use them to replace one name. Do not mock the function you are trying to test.

Mental model

monkeypatch changes an attribute, env var, or dict for the length of one test, then puts it back.

capsys captures print. Read .out and .err. Prefer returning a string from the function; capture stdout when the function’s job is to print a label.

unittest.mock.patch("module.name") replaces name where it is used, not where it is defined. patch("desk.send") if desk.py called send. The mock records calls. That is the whole trick.

Worked examples

Case 1: monkeypatch an env prefix

Save both files in the same directory.

# desk.py
import os


def ticket_id(n):
    prefix = os.environ.get("DESK_PREFIX", "T")
    return f"{prefix}-{n}"


def format_pence(pence):
    return f"{pence / 100:.2f}"
# test_desk.py
from desk import ticket_id


def test_ticket_id_default(monkeypatch):
    monkeypatch.delenv("DESK_PREFIX", raising=False)
    assert ticket_id(11) == "T-11"


def test_ticket_id_override(monkeypatch):
    monkeypatch.setenv("DESK_PREFIX", "X")
    assert ticket_id(11) == "X-11"

Run:

uv run --with pytest pytest test_desk.py -q

Output (duration varies):

..                                                                       [100%]
2 passed in 0.01s

raising=False means “delete if present.” The test does not care whether you exported DESK_PREFIX in the shell.

Case 2: capsys for a printed label

# label.py
def print_label(ticket):
    print(f"ticket {ticket['id']} → table {ticket['table']}")
# test_label.py
from label import print_label


def test_print_label(capsys):
    print_label({"id": "T-11", "table": 4})
    captured = capsys.readouterr()
    assert captured.out == "ticket T-11 → table 4\n"
    assert captured.err == ""

Run:

uv run --with pytest pytest test_label.py -q

Output (duration varies):

.                                                                        [100%]
1 passed in 0.01s

The newline is part of print. Assert it. If you start adding file= and colours, return a string instead and print in main.

Case 3: unittest.mock replaces a sender

page should not hit a real radio in a unit test. Patch send where page looks it up: kitchen.send.

# kitchen.py
def send(message):
    raise RuntimeError(f"no radio for {message!r}")


def page(ticket):
    send(f"fire {ticket}")
    return ticket
# test_kitchen.py
from unittest.mock import patch

import kitchen


def test_page_sends_once():
    with patch("kitchen.send") as send:
        result = kitchen.page("T-11")
        send.assert_called_once_with("fire T-11")
        assert result == "T-11"


def test_page_without_patch_blows_up():
    try:
        kitchen.page("T-11")
    except RuntimeError as exc:
        assert "no radio" in str(exc)
    else:
        raise AssertionError("expected RuntimeError")

Run:

uv run --with pytest pytest test_kitchen.py -q

Output (duration varies):

..                                                                       [100%]
2 passed in 0.02s

assert_called_once_with is the contract. The second test proves the real send is still dangerous — the patch did not leak.

A more boring page would take send as an argument with a default. Patch is for seams you do not want to change today.

The trap

Save as test_overmock.py. This test mocks ticket_id, then asserts the mock. It never ran the formatting logic.

# test_overmock.py
from unittest.mock import patch

import desk


def test_ticket_id_is_a_lie():
    with patch("desk.ticket_id", return_value="nope"):
        assert desk.ticket_id(11) == "nope"

Run:

uv run --with pytest pytest test_overmock.py -q

Output (duration varies):

.                                                                        [100%]
1 passed in 0.01s

Green, and DESK_PREFIX could be on fire. Mock the neighbour (send, time.time, path.write_text). Call the function you claim to test.

The boring rule

  • Prefer an extra argument (send=..., clock=...) over a patch.
  • monkeypatch for env and attributes. It rolls back by itself.
  • capsys when the function’s job is printing. Otherwise return a string.
  • patch("module.name") at the use site. Assert calls. Leave the function under test real.
  • If a test would pass with the production code deleted, you over-mocked.

Try this

  1. Add monkeypatch.setenv("DESK_PREFIX", "") and decide whether ticket_id(11) should be "-11" or rejected. Test the decision.
  2. Change print_label to also print the pence on a second line. Update the capsys assert.
  3. Rewrite page(ticket, send=send) so the test passes a fake send list-append. Drop patch.
  4. In test_page_sends_once, assert send.call_count == 1 as well as assert_called_once_with.