Python Lesson 27 of 71

Testing Python: unittest, pytest, Fixtures, Mocking & Coverage

Here is a small function. It is fine.

def is_happy_hour() -> bool:
    return 16 <= now().hour <= 18

By the end of this lesson you will have a test suite that reports 100% line coverage and 100% branch coverage on the module containing it — every statement executed, every branch taken both ways, a green wall of dots — and that function will still be wrong. It says 18:30 is happy hour. It is not; the bar closes at 18:00.

Nothing in this lesson matters more than that sentence. Coverage measures which lines ran. It has no opinion about whether your assertions were right, or whether you asserted anything at all. Tests are not proof of correctness, and no tool will give you proof of correctness. What tests actually buy you is narrower, less glamorous, and far more valuable: the ability to change code without fear.


Why this matters

Ask ten programmers why they write tests and eight will say “to catch bugs.” That is the least interesting reason, and it sets you up to be disappointed. Tests catch the bugs you thought of. The bugs that hurt are the ones you did not think of, and a test suite is structurally incapable of catching those — you cannot write an assertion about a scenario that never crossed your mind.

The honest reason is this: tests are a regression harness. Their job is not to prove the code is right today. Their job is to tell you, tomorrow, that it still does what it did today. That distinction changes everything about how you write them.

Think about what you actually do all day. You rename a function. You extract a helper. You swap a dict for a dataclass. You upgrade a dependency. You fix a bug in a function that eleven other functions call. Every one of those is a bet: I believe this change is safe. Without tests, you settle that bet by reading code and hoping, and the cost of being wrong is a 2 a.m. page. With tests, you settle it in 0.05 seconds, and being wrong costs you a red line in a terminal. That is the whole product. Not correctness — confidence under change.

This reframes what a “good” test is. A test that breaks every time you refactor, without the behaviour changing, is not protecting you — it is taxing you. It is a second copy of the implementation, written in a worse language, that you must now maintain. This is why “test behaviour, not implementation” is not aesthetics: a suite that couples to internals makes change more expensive, which is the exact opposite of the point. You will delete such a suite within a year, and then you will have neither tests nor confidence.

The second thing nobody tells beginners: testable code and good code are the same code. A function that reads a global, calls datetime.now(), opens a socket and writes a file is hard to test — and it is hard to test for the same reason it is hard to use: it has four hidden inputs. When you make it testable by passing the clock in, you have not “added test scaffolding.” You have made the dependencies honest. When a test is agony to write, that is information about the code, not about testing.


The test pyramid, and AAA

Two pieces of vocabulary before the code, because both are load-bearing.

The pyramid

Not all tests are the same shape. The test pyramid is a claim about proportions: you should have many small fast tests, fewer medium ones, and very few big slow ones. It is a claim about economics, not purity.

Level Scope Speed Fails when… How many Real cost
Unit One function/class, no I/O < 1 ms That unit’s logic is wrong Hundreds–thousands Cheap to write, cheap to run, pinpoint failure
Integration 2+ real components (code + DB, code + HTTP) 10 ms – 1 s The wiring is wrong: schema, serialisation, config Dozens–hundreds Needs setup; failures are still fairly local
End-to-end (e2e) The whole system, through the real front door 1 s – minutes Anything, anywhere A handful Slow, flaky, failure says “something broke”
Contract The boundary between two services ~ms One side changed the deal Per integration The honest fix for over-mocking across teams

The reason for the shape is failure localisation. When a unit test fails, you know which function is broken — the test names it. When an e2e test fails, you know “checkout is broken,” which is the beginning of an investigation, not the end of one. A thousand unit tests that run in two seconds get run on every save. Forty e2e tests that take eleven minutes get run at 5 p.m., or never.

But do not read the pyramid as “unit tests good, e2e tests bad.” An all-unit suite has a famous failure mode: every component passes, and the system does not work, because every unit test mocked the neighbours and all the mocks agreed with each other and none of them agreed with reality. Unit tests verify that you built the thing right. Integration and e2e verify that you built the right thing. You need some of each; you need far more of the cheap ones.

AAA

Every good test, at every level, has the same three-part shape — Arrange, Act, Assert:

def test_subtotal_sums_lines():
    items = [(2, Decimal("62.00")), (1, Decimal("360.00"))]   # Arrange: set up the world
    result = subtotal(items)                                  # Act:     one action
    assert result == Decimal("484.00")                        # Assert:  one claim
Phase What belongs here Smell if violated
Arrange Build inputs, fixtures, fakes, freeze the clock 40 lines of setup → the unit is too coupled
Act Exactly one call — the thing under test Two acts → you are testing a workflow; split it
Assert The claim(s) about that act’s result No assert → the test only proves “did not raise”

The rule that pays for itself is one act per test. Not one assert — several assertions about one action are fine, and often better than three near-identical tests. But two actions in one test means that when it goes red you do not know which one broke, and you have just given up the localisation that made unit tests worth writing.

The other rule, from the same family: a test should have one reason to fail. If test_checkout can fail because pricing is wrong, or because the receipt format changed, or because the clock moved, it is three tests wearing a trench coat, and its failure tells you almost nothing.


unittest: the stdlib you will meet

unittest ships with Python. No install, no dependency, available in every environment including the locked-down ones. It is a direct descendant of Java’s JUnit, which explains everything about how it looks: tests are methods on a class that inherits TestCase, and assertions are methods with names you must memorise.

import unittest
from decimal import Decimal
from shopcalc.pricing import subtotal, apply_discount


class TestPricing(unittest.TestCase):
    def setUp(self):                      # runs before EVERY test method
        self.items = [(2, Decimal("62.00")), (1, Decimal("360.00"))]

    def test_subtotal(self):
        self.assertEqual(subtotal(self.items), Decimal("484.00"))

    def test_discount_wrong_on_purpose(self):
        self.assertEqual(apply_discount(Decimal("1000.00"), "WELCOME10"), Decimal("950.00"))

    def test_codes_wrong_on_purpose(self):
        self.assertEqual(sorted(["WELCOME10", "BIGSPEND"]), ["BIGSPEND", "WELCOME10", "FREESHIP"])

The lifecycle hooks are the first thing to learn, because they define when your setup runs:

Hook Runs Use for
setUp(self) Before every test method Fresh per-test state
tearDown(self) After every test method, even on failure Undo setUp
setUpClass(cls) Once per class (needs @classmethod) Expensive shared setup
tearDownClass(cls) Once per class (needs @classmethod) Release it
setUpModule() / tearDownModule() Once per module (module-level function) Rarely what you want
self.addCleanup(fn, *a) LIFO, after the test, even if setUp raised Strictly better than tearDown

addCleanup deserves the callout. If setUp acquires two resources and the second acquisition raises, tearDown never runs and the first resource leaks. Registering each cleanup as you acquire it fixes that completely, which is why addCleanup is the modern recommendation over tearDown.

Then there is the assertion vocabulary — the part you must memorise:

Method Checks Notes
assertEqual(a, b) a == b The workhorse; type-aware diffs for list/dict/set/str
assertNotEqual(a, b) a != b
assertTrue(x) / assertFalse(x) Truthiness ⚠️ Not is TrueassertTrue([0]) passes
assertIs(a, b) / assertIsNot(a, b) Identity For None, sentinels, singletons
assertIsNone(x) / assertIsNotNone(x) x is None
assertIn(a, b) / assertNotIn(a, b) a in b
assertIsInstance(a, cls) isinstance
assertRaises(exc, fn, *args) Callable raises Better as a context manager: with self.assertRaises(ValueError):
assertRaisesRegex(exc, regex) Raises and message matches re.search, not equality
assertWarns(w) / assertLogs(logger, level) Warning / log emitted assertNoLogs added in 3.10
assertAlmostEqual(a, b, places=7) Rounds a - b to places decimals The float fix — decimal places, not relative
assertGreater/Less(a, b) (+Equal) > < >= <=
assertCountEqual(a, b) Same elements, any order Badly named; nothing to do with counting
assertMultiLineEqual(a, b) Strings, with a unified diff Automatic for str in assertEqual
assertDictEqual / assertListEqual / assertSetEqual Typed equality + good diffs Automatic via assertEqual — rarely written by hand
self.fail(msg) / self.skipTest(msg) Force fail / skip at runtime

Run it and two tests fail on purpose:

python -m unittest discover -s tests -v
test_codes_wrong_on_purpose (test_ut.TestPricing.test_codes_wrong_on_purpose) ... FAIL
test_discount_wrong_on_purpose (test_ut.TestPricing.test_discount_wrong_on_purpose) ... FAIL
test_subtotal (test_ut.TestPricing.test_subtotal) ... ok

======================================================================
FAIL: test_codes_wrong_on_purpose (test_ut.TestPricing.test_codes_wrong_on_purpose)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/vinod/shopcalc/tests/test_ut.py", line 17, in test_codes_wrong_on_purpose
    self.assertEqual(sorted(["WELCOME10", "BIGSPEND"]), ["BIGSPEND", "WELCOME10", "FREESHIP"])
AssertionError: Lists differ: ['BIGSPEND', 'WELCOME10'] != ['BIGSPEND', 'WELCOME10', 'FREESHIP']

Second list contains 1 additional elements.
First extra element 2:
'FREESHIP'

- ['BIGSPEND', 'WELCOME10']
+ ['BIGSPEND', 'WELCOME10', 'FREESHIP']
?                         ++++++++++++


======================================================================
FAIL: test_discount_wrong_on_purpose (test_ut.TestPricing.test_discount_wrong_on_purpose)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/vinod/shopcalc/tests/test_ut.py", line 14, in test_discount_wrong_on_purpose
    self.assertEqual(apply_discount(Decimal("1000.00"), "WELCOME10"), Decimal("950.00"))
AssertionError: Decimal('900.00') != Decimal('950.00')

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=2)

Be fair to it: that list diff is genuinely good. assertEqual dispatches on type, and for lists it tells you exactly what differs and where.

Now look at the second failure. AssertionError: Decimal('900.00') != Decimal('950.00'). That is all you get. Which discount code? Applied to what amount? The line of source is echoed above it, so you can reconstruct the answer by reading — but the framework did not tell you, because assertEqual received two already-evaluated values and never saw the expressions that produced them. That gap is the entire reason pytest exists.

The honest scorecard on unittest: it is always there, it works, its diffs are decent, and unittest.mock (which we use later) is genuinely excellent and used by pytest people too. Its costs are the boilerplate (self. on everything, a class around every group), the assertion vocabulary you must memorise, the poor failure messages for scalars, and setUp, which gives you one fixed setup per class rather than composable pieces — the limitation fixtures were invented to fix.


pytest: why it won

Same two failures. Same module. Plain assert:

from decimal import Decimal
from shopcalc.pricing import subtotal, apply_discount

ITEMS = [(2, Decimal("62.00")), (1, Decimal("360.00"))]


def test_subtotal():
    assert subtotal(ITEMS) == Decimal("484.00")


def test_discount_wrong_on_purpose():
    assert apply_discount(Decimal("1000.00"), "WELCOME10") == Decimal("950.00")


def test_codes_wrong_on_purpose():
    assert sorted(["WELCOME10", "BIGSPEND"]) == ["BIGSPEND", "WELCOME10", "FREESHIP"]

No class. No self. No import unittest. No assertion vocabulary — just Python’s own assert keyword. And the failure output:

=================================== FAILURES ===================================
________________________ test_discount_wrong_on_purpose ________________________

    def test_discount_wrong_on_purpose():
>       assert apply_discount(Decimal("1000.00"), "WELCOME10") == Decimal("950.00")
E       AssertionError: assert Decimal('900.00') == Decimal('950.00')
E        +  where Decimal('900.00') = apply_discount(Decimal('1000.00'), 'WELCOME10')
E        +    where Decimal('1000.00') = Decimal('1000.00')
E        +  and   Decimal('950.00') = Decimal('950.00')

ptdemo/test_pt.py:12: AssertionError
_________________________ test_codes_wrong_on_purpose __________________________

    def test_codes_wrong_on_purpose():
>       assert sorted(["WELCOME10", "BIGSPEND"]) == ["BIGSPEND", "WELCOME10", "FREESHIP"]
E       AssertionError: assert ['BIGSPEND', 'WELCOME10'] == ['BIGSPEND', ...', 'FREESHIP']
E         
E         Right contains one more item: 'FREESHIP'
E         Use -v to get more diff
E       
ptdemo/test_pt.py:16: AssertionError
=========================== short test summary info ============================
FAILED ptdemo/test_pt.py::test_discount_wrong_on_purpose - AssertionError: as...
FAILED ptdemo/test_pt.py::test_codes_wrong_on_purpose - AssertionError: asser...
========================= 2 failed, 1 passed in 0.03s ==========================

Read the where lines. That is assertion introspection, and it is the feature that ended the argument:

E       AssertionError: assert Decimal('900.00') == Decimal('950.00')
E        +  where Decimal('900.00') = apply_discount(Decimal('1000.00'), 'WELCOME10')

pytest did not just report that two values differed. It decomposed the expression and showed you the call that produced the left-hand side, with its actual arguments. unittest said Decimal('900.00') != Decimal('950.00') and left you to work out where those came from. pytest says: apply_discount was called with Decimal('1000.00') and 'WELCOME10', and it returned Decimal('900.00'). You can often fix the bug from the failure output alone, without opening the test.

How? A plain assert compiles to “raise AssertionError if falsy” and throws away everything else. So pytest installs an import hook and rewrites the AST of your test modules at import time, replacing each assert with code that stores every sub-expression before comparing, and builds that report if the result is falsy. The rewritten module is cached in __pycache__, so the cost is roughly zero.

Two consequences worth knowing. First, rewriting only happens for test modules and conftest.py — an assert inside a helper module in your package is not rewritten (call pytest.register_assert_rewrite("mylib.helpers") before import if you want it). Second, if you run Python with -O, assert statements are stripped by the interpreter entirely and your tests all pass vacuously. Never run a suite under -O.

unittest pytest
Install Stdlib, always there pip install pytest (third-party)
A test is A method on a TestCase subclass Any function named test_*
Assertions ~30 self.assertX methods to memorise The assert keyword
Failure detail Two values; type-aware diffs for containers Full expression decomposition
Setup setUp/tearDown — one per class, inherited Fixtures — named, composable, injected
Setup scope class / module function / class / module / package / session
Parametrisation subTest (clunky) or a third-party lib @pytest.mark.parametrize
Expected exceptions assertRaises(...) pytest.raises(..., match=...)
Floats assertAlmostEqual(a, b, places=7) pytest.approx (relative by default)
Selecting tests Module/class/method path Node IDs, -k expressions, -m marks
Rerun failures --lf, --ff
Plugins Few ~1,500 (-cov, -xdist, -mock, -django, …)
Runs the other’s tests? No Yes — pytest runs TestCase classes fine
Best for Zero-dependency environments, legacy suites Everything else

That second-to-last row is the migration path, and it is why this is not a hard choice. pytest runs your existing unittest tests unmodified. You can adopt pytest as a runner on a legacy suite today, write new tests as plain functions, and never rewrite the old ones. There is no big-bang migration and no reason to delay.

The honest caveats: pytest is a dependency, its fixture magic has a real learning curve (implicit injection by argument name is genuinely confusing at first), and deep conftest.py hierarchies can become their own kind of spaghetti. All true. It is still the right default for essentially every new Python project, and has been for years.


Discovery, rootdir, and the ImportError that bites everyone

Before pytest runs a single test it has to find them. The rules are simple and worth knowing exactly, because “my test didn’t run” is almost always a naming mistake:

pytest looks for Default rule Config key
Files test_*.py or *_test.py python_files
Functions test* prefix, at module level python_functions
Classes Test* prefix, and no __init__ python_classes
Methods test* prefix inside a Test* class python_functions
Where testpaths, else the args, else rootdir down testpaths
Ignores .venv, .git, node_modules, norecursedirs norecursedirs

Two of those rows are traps. A class with an __init__ is silently skipped — pytest cannot instantiate it, so it warns and moves on:

class TestWithInit:              # NOT collected
    def __init__(self):
        self.x = 1

    def test_never_runs(self):
        assert False             # would fail loudly... if it ever ran

class TestOrders:                # collected
    def test_runs(self):
        assert True
.F                                                                       [100%]
=============================== warnings summary ===============================
discdemo/test_disc.py:4
  /Users/vinod/shopcalc/discdemo/test_disc.py:4: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: discdemo/test_disc.py)
    class TestWithInit:                      # will NOT be collected

A warning, not an error. test_never_runs contains assert False and your suite is green. If you want per-test state on a class, that is what fixtures are for — never __init__.

Check what pytest thinks it found with --collect-only:

pytest --collect-only -q
tests/test_pricing.py::test_line_total_multiplies_qty_by_price
tests/test_pricing.py::test_subtotal_sums_lines
tests/test_pricing.py::test_apply_discount[no-code]
tests/test_pricing.py::test_apply_discount[welcome10]
tests/test_pricing.py::test_apply_discount[bigspend-eligible]
tests/test_pricing.py::test_apply_discount[bigspend-at-threshold]
tests/test_pricing.py::test_unknown_code_is_rejected
tests/test_pricing.py::test_bad_qty_is_rejected[0]

Each line is a node IDpath::class::function[param-id] — and it is also a valid argument, so you can rerun exactly one case by pasting it back.

rootdir, conftest, and ModuleNotFoundError

rootdir is the directory pytest treats as the project root. It finds it by walking up from the test paths looking for pyproject.toml (with a [tool.pytest.ini_options] table), pytest.ini, tox.ini or setup.cfg. rootdir is printed in the header of every run — read it when things are strange. Crucially, rootdir does not change sys.path. It is for config resolution, not imports. This is the single most misunderstood thing about pytest.

conftest.py is the other half. It is a file pytest imports automatically — you never import it yourself — and every fixture, hook and plugin in it is visible to all tests in that directory and below. That is why a fixture you never imported still resolves by name.

Now the failure that has eaten more beginner hours than any other. Fresh project, tests written, nothing exotic:

============================= test session starts ==============================
platform darwin -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/vinod/shopcalc
collected 0 items / 1 error

==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/Users/vinod/shopcalc/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests/test_pricing.py:3: in <module>
    from shopcalc.pricing import line_total
E   ModuleNotFoundError: No module named 'shopcalc'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.06s ===============================

Zero tests ran. This is not a test failure — it is an import failure at collection, and pytest correctly refuses to continue.

The cause is that sys.path does not contain your package. Notice the near-miss that makes this so confusing: if your code lived in ./shopcalc/ at the repo root, then running python -m pytest from that directory would add the cwd to sys.path and it would work — by accident. Then CI runs pytest from elsewhere, or you install the package, and it breaks. The “it works on my machine” is literally the current working directory.

The permanent fix is the src/ layout plus an editable install:

shopcalc/
├── pyproject.toml
├── src/
│   └── shopcalc/
│       ├── __init__.py
│       └── pricing.py
└── tests/
    ├── conftest.py
    └── test_pricing.py
[project]
name = "shopcalc"
version = "0.1.0"
requires-python = ">=3.12"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: takes over a second", "net: needs the network"]
pip install -e .        # editable install: the package is importable from anywhere
============================= test session starts ==============================
platform darwin -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/vinod/shopcalc
configfile: pyproject.toml
testpaths: tests
plugins: cov-7.1.0
collected 1 item

tests/test_pricing.py .                                                  [100%]

============================== 1 passed in 0.02s ===============================

Why src/ and not just an editable install of a flat layout? Because src/ makes the accident impossible. With code at the repo root, sys.path manipulation can always silently rescue you, so you never learn that your packaging is broken — until a user pip-installs your package and finds a module missing from the wheel. With src/, there is no cwd trick available: the only way import shopcalc works is if shopcalc is genuinely installed. Your tests then exercise the same import path your users get. That is the real argument, and it is a packaging argument, not a testing one — project structure and packaging covers the rest of it.

⚠️ Do this inside a virtual environment (python3 -m venv .venv && source .venv/bin/activate; Windows: .venv\Scripts\activate). pip install -e . writes into whatever environment is active, and you do not want that to be your system Python — see pip and virtual environments.

Running it

Command Does
pytest Run everything under testpaths/rootdir
pytest -q Quiet: one char per test (. F E s x X)
pytest -v Verbose: one node ID per line + outcome
pytest -x Stop at the first failure
pytest --maxfail=3 Stop after 3
pytest -k "discount and not bigspend" Select by name substring expression
pytest -m slow / -m "not slow" Select by mark
pytest tests/test_pricing.py::test_subtotal_sums_lines One test by node ID
pytest "tests/test_pricing.py::test_happy_hour_boundaries[18-closes]" One param case (quote it — brackets are shell globs)
pytest --lf Last failed only — the tightest fix loop there is
pytest --ff Failed first, then the rest
pytest --sw Stepwise: stop at a failure, resume there next run
pytest -s Do not capture stdout (see your prints live)
pytest --tb=short / =line / =no Traceback verbosity
pytest --collect-only List tests without running
pytest -p no:randomly Disable a plugin for one run
pytest --durations=10 The 10 slowest tests — find the pyramid inversion
pytest -n auto Parallel across cores (needs pytest-xdist)

The one to build a habit around is --lf. Break something and watch:

$ pytest -q
.................F...                                                    [100%]
1 failed, 20 passed in 0.02s

$ pytest --lf -q
E        +  where True = is_happy_hour()

tests/test_pricing.py:74: AssertionError
=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_happy_hour_boundaries[18-closes] - assert ...
1 failed in 0.02s

1 failed in 0.02s — it ran one test, not twenty-one. Edit, --lf, edit, --lf, and when it goes green run the full suite once. On a large suite this is the difference between a two-second loop and a two-minute one.

And -x stops the bleeding when a shared change breaks everything:

$ pytest -x -q --tb=no
.................F
=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_happy_hour_boundaries[18-closes] - assert ...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 17 passed in 0.02s

Those progress characters and exit codes are worth memorising, because CI speaks in exit codes:

Char Meaning Exit code Meaning
. passed 0 All passed
F failed (an assert in a test) 1 Tests failed
E error (an exception in a fixture) 2 Interrupted (Ctrl-C)
s skipped 3 Internal error
x xfailed (expected failure) 4 Usage error (bad flag)
X XPASS (expected to fail — but passed) 5 No tests collected

Exit code 5 is the sneaky one: “no tests collected” is not success, but a naive CI script that only checks if [ $? -ne 0 ] after a typo’d path will be perfectly happy with a suite that ran nothing.


Fixtures: dependency injection, not setUp

This is the idea that makes pytest more than a nicer assert.

setUp answers “what should happen before each test in this class?” — one blob, same for every test, inherited through a class hierarchy when you want to share. A fixture answers a different question: “what does this test need?” The test declares its needs as parameters; pytest supplies them by name. That is dependency injection, and it is the whole model.

import pytest
from decimal import Decimal


@pytest.fixture
def items():
    """A two-line basket. Function-scoped: every test gets a fresh list."""
    return [(2, Decimal("62.00")), (1, Decimal("360.00"))]


def test_subtotal_sums_lines(items):          # "I need `items`" -> pytest builds it
    assert subtotal(items) == Decimal("484.00")

The parameter name items is not a coincidence or a type hint — it is the lookup key. pytest sees a parameter it recognises as a fixture name, calls that fixture, and passes the return value in. Nothing is imported and nothing is inherited.

That indirection is exactly what beginners find unnerving, and it is worth being blunt: yes, it is magic, and pytest --fixtures (list everything available here) and pytest --setup-show (show setup/teardown around each test) are how you make it un-magic when you are lost.

What you buy for that magic is composition. Fixtures request other fixtures, so setup becomes a dependency graph rather than a blob, and each test pulls only the subgraph it needs.

yield fixtures: setup and teardown in one place

A fixture that yields gives its value to the test and resumes afterwards — the code after yield is the teardown, and it runs even if the test fails:

@pytest.fixture
def db_conn():
    conn = connect("sqlite://")     # setup
    yield conn                      # <- the test runs here, gets `conn`
    conn.close()                    # teardown: runs even if the test failed

Setup and teardown sit five lines apart instead of in two methods forty lines apart. ⚠️ One sharp edge: if the code before yield raises, the code after it never runs. For multi-step setup, prefer request.addfinalizer(fn) per step, or nest fixtures so each owns exactly one resource.

Scope

Scope controls how often a fixture is built. It is the biggest performance lever in a suite and the biggest source of spooky failures:

Scope Built Use for Risk
function (default) Once per test Almost everything None — this is the safe default
class Once per Test* class Shared setup for a cohesive group State leaks between methods
module Once per .py file An expensive parse/load State leaks across the file
package Once per package dir Rare Leaks widely
session Once per pytest run Docker container, DB server, big model Leaks everywhere; ordering dependencies

Setup runs outside-in and teardown inside-out. Watch it happen:

@pytest.fixture(scope="session")
def db():
    print("\n  [session] connect")
    yield "conn"
    print("\n  [session] close")


@pytest.fixture(scope="module")
def schema(db):                      # a module fixture may request a session fixture
    print("  [module]  create schema")
    yield "schema"
    print("  [module]  drop schema")


@pytest.fixture                      # function scope (the default)
def row(schema):
    print("  [function] insert row")
    yield {"id": 1}
    print("  [function] delete row")


def test_a(row):
    print("  -> test_a sees", row)


def test_b(row):
    print("  -> test_b sees", row)
$ pytest -s -q
  [session] connect
  [module]  create schema
  [function] insert row
  -> test_a sees {'id': 1}
.  [function] delete row
  [function] insert row
  -> test_b sees {'id': 1}
.  [function] delete row
  [module]  drop schema

  [session] close

2 passed in 0.00s

Two tests; connect ran once, create schema once, insert row twice. Teardown unwound in exactly reverse order. That is the model in nine lines of output.

There is one hard rule: a fixture may only request fixtures of the same or wider scope. A session fixture cannot request a function fixture — the function one would be gone before the session one finished. Break it and you get ScopeMismatch, which is pytest saving you from a lifetime bug.

And the reason function is the default: widening scope trades isolation for speed, and isolation is what makes a failure mean something. Widen only when you have measured that it matters, and only for something genuinely read-only or reset between tests.

conftest.py

Put a fixture in conftest.py and every test in that directory and below can use it — no import:

tests/
├── conftest.py            # fixtures for ALL tests
├── unit/
│   ├── conftest.py        # fixtures for unit tests only
│   └── test_pricing.py
└── integration/
    ├── conftest.py        # fixtures for integration tests only
    └── test_api.py

Four rules cover it. You never import conftest.py — pytest imports it for you, and importing it yourself is a bug. Every conftest.py from rootdir down to the test file loads, and when two define the same fixture name the closest one wins. Plugins and hooks belong in the rootdir conftest. And if a fixture is “not found,” it is almost always in a sibling directory rather than an ancestor — conftest applies downward only.

The discipline: a fixture in the top conftest.py is visible to every test in the project. Put it there only if it genuinely belongs to everything. Otherwise push it down.

autouse

An autouse=True fixture runs for every test in its scope without being requested. Use it sparingly — invisible setup is exactly the spookiness people accuse pytest of — but it is perfect for guardrails:

# tests/conftest.py
import pytest


@pytest.fixture(autouse=True)
def no_real_network(monkeypatch):
    """Runs for EVERY test in this dir. A unit test must never open a socket."""
    def boom(*a, **k):
        raise RuntimeError("a unit test tried to open a socket")
    monkeypatch.setattr("socket.socket", boom)
$ pytest autodemo -q -s
blocked: a unit test tried to open a socket
.
1 passed in 0.00s

That fixture makes an entire category of mistake — the accidental real HTTP call — impossible to commit. It converts a flaky test into a loud, immediate, local failure. (You will see the exact mistake it prevents in the mocking section.)

The built-ins you get for free

Fixture Gives you Notes
tmp_path A fresh pathlib.Path dir, per test The way to test file I/O
tmp_path_factory Session-scoped temp dirs For wider scopes
capsys .readouterr() → captured .out / .err For print output
capfd Same, at file-descriptor level Catches C-extension/subprocess output
caplog .text, .records, .at_level(...) For logging
monkeypatch .setattr, .setitem, .setenv, .delenv, .chdir, .syspath_prepend Auto-undone after the test
request Test metadata; .param, .addfinalizer, .node For fixture introspection
recwarn Recorded warnings Or pytest.warns(...)
pytestconfig The parsed config / CLI options For custom --flags
cache Cross-run key/value store What --lf uses

tmp_path and monkeypatch are the two that change how you write code.

tmp_path is a real directory, unique per test, created before and cleaned up later (pytest keeps the last three runs’ directories, which is a gift when a test fails and you want to see what it wrote):

def test_write_receipt_creates_file(tmp_path, items, at_hour):
    at_hour(10)
    path = write_receipt(tmp_path, "ORD-1001", items)

    assert path.exists()
    assert path.parent == tmp_path       # never the cwd
    lines = path.read_text(encoding="utf-8").splitlines()
    assert lines[0] == "RECEIPT ORD-1001"
    assert lines[-1] == "TOTAL 484.00"

The design rule it teaches: a function that writes files should take the directory as a parameter. write_receipt(outdir, ...) is trivially testable; a write_receipt() that hardcodes ./receipts/ is not testable without monkeypatch.chdir and leaves litter in your repo when a test fails.

monkeypatch sets an attribute, dict item or env var and automatically restores it when the test ends — pass or fail. That automatic undo is the entire value; a hand-rolled setattr in a test that later fails leaves the patch in place and poisons every test after it.

def test_reads_config_from_env(monkeypatch):
    monkeypatch.setenv("SHOPCALC_CURRENCY", "USD")
    assert load_config().currency == "USD"
    # env var is restored automatically -- no tearDown, no leak

Parametrize, raises, approx, marks

@pytest.mark.parametrize

The single highest-value-per-keystroke feature in pytest. One test, many cases:

@pytest.mark.parametrize(
    "amount, code, expected",
    [
        ("1000.00", None,        "1000.00"),
        ("1000.00", "WELCOME10", "900.00"),
        ("3000.00", "BIGSPEND",  "2400.00"),
        ("2000.00", "BIGSPEND",  "1600.00"),
    ],
    ids=["no-code", "welcome10", "bigspend-eligible", "bigspend-at-threshold"],
)
def test_apply_discount(amount, code, expected):
    assert apply_discount(Decimal(amount), code) == Decimal(expected)

That is four independent tests, not one test with a loop, and the difference matters: a loop stops at the first failure and reports one line number for four cases. Parametrize gives each case its own node ID, its own pass/fail, and its own place in the report:

tests/test_pricing.py::test_apply_discount[no-code] PASSED               [ 14%]
tests/test_pricing.py::test_apply_discount[welcome10] PASSED             [ 19%]
tests/test_pricing.py::test_apply_discount[bigspend-eligible] PASSED     [ 23%]
tests/test_pricing.py::test_apply_discount[bigspend-at-threshold] PASSED [ 28%]

Those ids are why you bother. Without them pytest generates [1000.00-WELCOME10-900.00], which is honest but unreadable, and for object arguments you get [amount0], which is useless. A hand-written id turns a failure into a sentence: test_apply_discount[bigspend-at-threshold] tells you the boundary broke before you read a single line of code.

Form Effect
parametrize("a", [1, 2]) One argument, two runs
parametrize("a, b", [(1, 2), (3, 4)]) Two arguments per case (comma string)
ids=["low", "high"] Readable node IDs
pytest.param(1, 2, id="low") Per-case id inline
pytest.param(1, 2, marks=pytest.mark.xfail) Mark one case, not the whole test
Stacked decorators Cartesian product — 3 × 2 = 6 tests
indirect=True Pass the param to a fixture via request.param
parametrize(..., scope="module") Group cases to reuse expensive setup

Stacking is a loaded gun. Two decorators of 3 and 4 cases is 12 tests; add a third with 5 and it is 60. That is fine when the product is meaningful and a waste when it is not — prefer an explicit list of tuples when only some combinations make sense.

pytest.raises

Testing the error path is as important as testing the happy path — an exception is part of your API, and custom exceptions are part of your contract:

def test_unknown_code_is_rejected():
    with pytest.raises(ValueError, match=r"unknown discount code: 'FREESHIP'"):
        apply_discount(Decimal("1000.00"), "FREESHIP")


def test_excinfo_gives_the_exception():
    with pytest.raises(ValueError) as excinfo:
        line_total(0, Decimal("1.00"))
    assert excinfo.value.args[0] == "qty must be positive, got 0"
    assert excinfo.type is ValueError

Three things to internalise. match= is re.search on the string of the exception, not equality — so it is a substring match by default, and regex metacharacters are live. match="qty (must)" will not do what you expect; use re.escape(...) when the message contains (, ), [, ., $ or *:

def test_match_special_chars_need_escaping():
    with pytest.raises(ValueError, match=re.escape("unknown discount code: 'FREESHIP'")):
        apply_discount(Decimal("1.00"), "FREESHIP")

Second, if the block does not raise, pytest fails it explicitly — no false green:

E   Failed: DID NOT RAISE ValueError

Third, keep the with block to one line — the call you expect to raise. Wrap three statements and you cannot tell which one raised, and a ValueError from your setup will happily pass a test that was supposed to check your function.

Form Use
with pytest.raises(ValueError): It raises the right type
..., match=r"qty must be positive" …and the message is right (regex search)
as excinfoexcinfo.value Inspect attributes: .args, custom fields
excinfo.type, excinfo.traceback The class; the traceback
pytest.raises((KeyError, ValueError)) Any of several types
pytest.warns(DeprecationWarning) The same idea for warnings
pytest.raises(ExceptionGroup) + excinfo.group_contains(...) 3.11+ exception groups

pytest.approx

Floats are not real numbers and == on them is a lie:

    def test_naive_float():
>       assert 0.1 + 0.2 == 0.3
E       assert (0.1 + 0.2) == 0.3

discdemo/test_disc.py:22: AssertionError

Note how unhelpful even pytest’s introspection is here — assert (0.1 + 0.2) == 0.3 looks like it should obviously be true. (The values are 0.30000000000000004 and 0.3; binary floating point cannot represent either exactly.) The fix:

def test_float_equality_is_a_lie():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert 0.1 + 0.2 != 0.3                       # the naive version really fails


def test_approx_on_collections():
    assert [0.1 + 0.2, 1 / 3] == pytest.approx([0.3, 0.3333333333333333])
    assert 2.0 == pytest.approx(2.01, rel=0.01)   # within 1%
    assert 2.0 == pytest.approx(2.005, abs=0.01)  # within 0.01 absolute
Form Tolerance
approx(0.3) Relative 1e-6, or absolute 1e-12 near zero
approx(0.3, rel=0.01) Within 1%
approx(0.3, abs=0.01) Within 0.01 absolute
approx([...]) / approx({...}) Element-wise / value-wise on lists and dicts
approx(np.array([...])) numpy arrays
assertAlmostEqual(a, b, places=7) unittest’s version — absolute decimal places, so it silently misleads on large numbers

The default is relative, which is what you want: approx scales its tolerance with magnitude, so it behaves sensibly for 1e-9 and 1e9. assertAlmostEqual’s fixed 7 decimal places does not — comparing two values around 1e12 it is effectively an exact comparison.

And the real lesson: ⚠️ for money, do not use floats at all. Use Decimal, as shopcalc does throughout, and then == is exact and correct and you never need approx.

Marks

Marks are metadata you attach to tests, and -m selects on them:

@pytest.mark.skip(reason="pricing engine v2 not merged yet")
def test_v2_engine():
    assert False


@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs 3.12 error messages")
def test_needs_312():
    assert sys.version_info >= (3, 12)


@pytest.mark.xfail(reason="known: rounding drifts on 3-way splits", strict=True)
def test_known_broken():
    assert round(10 / 3, 2) == 3.34


@pytest.mark.xfail(reason="this one actually passes -> XPASS", strict=False)
def test_secretly_fixed():
    assert 1 + 1 == 2


@pytest.mark.slow
def test_tagged_slow():
    assert True
markdemo/test_marks.py::test_v2_engine SKIPPED (pricing engine v2 no...) [ 30%]
markdemo/test_marks.py::test_needs_312 PASSED                            [ 40%]
markdemo/test_marks.py::test_known_broken XFAIL (known: rounding dri...) [ 50%]
markdemo/test_marks.py::test_secretly_fixed XPASS (this one actually...) [ 60%]
markdemo/test_marks.py::test_tagged_slow PASSED                          [ 70%]

============== 7 passed, 1 skipped, 1 xfailed, 1 xpassed in 0.02s ==============
$ pytest -m "not slow and not net" -q
..s.xX..                                                                 [100%]
5 passed, 1 skipped, 2 deselected, 1 xfailed, 1 xpassed in 0.01s
Mark Meaning Reported
@pytest.mark.skip(reason=...) Never run it s / SKIPPED
@pytest.mark.skipif(cond, reason=...) Skip if cond — evaluated at collection s / SKIPPED
pytest.skip(reason) (inside a test) Skip at runtime, once you know s
pytest.importorskip("numpy") Skip if a dependency is absent s
@pytest.mark.xfail(reason=...) Expected to fail — a known bug x XFAIL / X XPASS
@pytest.mark.xfail(strict=True) …and XPASS is a failure x XFAIL / F
@pytest.mark.xfail(raises=ValueError) …only if it fails this way
@pytest.mark.parametrize Table-driven cases
@pytest.mark.usefixtures("x") Use a fixture for its side effect
@pytest.mark.slow (custom) Your own tag; select with -m slow

xfail vs skip is a real decision. skip does not run the test at all. xfail runs it and expects a failure — so when someone accidentally fixes the bug, pytest reports XPASS and you find out. That makes xfail the right choice for a known bug and skip the right choice for “cannot run here” (wrong OS, missing dependency).

⚠️ But note the default: xfail is non-strict, so XPASS is not a failure. A test marked xfail that starts passing sits there as an X forever and nobody notices. Worse, an xfail on a test that is genuinely broken silently absorbs real regressions — the code breaks, the test fails, pytest says “expected,” CI stays green. Set xfail_strict = true in your config and make XPASS a failure.

Custom marks must be registered, or you get a warning:

markdemo2/test_typo.py:4: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo?
  You can register custom marks to avoid this warning
    @pytest.mark.slwo          # typo for "slow"
1 passed, 1 warning in 0.00s

@pytest.mark.slwo is a typo for slow. It does nothing — the test is not tagged, so -m "not slow" runs it anyway and your “fast” suite quietly includes the slow test. Registering marks in pyproject.toml (as we did above) plus --strict-markers turns that warning into an error:

==================================== ERRORS ====================================
___________________ ERROR collecting markdemo2/test_typo.py ____________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR markdemo2/test_typo.py - Failed: 'slwo' not found in `markers` configur...
1 error in 0.05s

Turn on --strict-markers on day one. It costs nothing and catches a class of silent bug.


Mocking: seams, and the rule everyone gets wrong

Some code cannot be unit tested as written. Not because it is bad, but because it has hidden inputs: the wall clock, the network, the filesystem, randomness, the current user. A test of such code is not deterministic, and a non-deterministic test is worse than no test.

def is_happy_hour() -> bool:
    return 16 <= now().hour < 18

You cannot assert anything about this. It returns a different answer depending on when CI runs. To test it you need a seam — a point where you can substitute a fake for the real thing.

The vocabulary

The words get used interchangeably and it costs clarity:

Double What it is Verifies Example
Dummy Filler to satisfy a signature; never used Nothing None for an unused logger=
Stub Returns canned answers Nothing — it feeds state lambda cur: Decimal("0.012")
Fake A real, working, simpler implementation Nothing In-memory dict “database”; SQLite for Postgres
Spy A real object that records calls Calls, after the fact Wrapping the real thing to count invocations
Mock Pre-programmed with expectations Behaviour — that you called it right mock.assert_called_once_with("USD")

The distinction that matters: a stub lets you assert on the result (state verification); a mock lets you assert on the interaction (behaviour verification). Prefer stubs and fakes — interaction assertions couple to implementation, so assert_called_once_with breaks when you refactor the call even if the behaviour is identical. Reach for a mock when the interaction is the behaviour: “did we charge the card exactly once?” is worth a mock; “did we call the internal helper?” is not.

monkeypatch and unittest.mock

Two tools, overlapping:

monkeypatch unittest.mock.patch
Comes from pytest (a fixture) The stdlib
Undo Automatic at test end Automatic on decorator/with exit
Style monkeypatch.setattr("mod.name", value) @patch("mod.name") / with patch(...)
Gives you Whatever you pass An auto-created MagicMock
Also does setenv, setitem, chdir, syspath_prepend, delattr autospec, side_effect, call assertions
Best for Simple substitution: a clock, an env var When you need to assert on calls

Use monkeypatch for a stub, patch when you want a mock’s call-recording. They compose fine.

THE rule: patch where it is used

This is the one that breaks everyone, so let us make it mechanical.

pricing.py starts like this:

from shopcalc.clock import now          # imported INTO this module's namespace
from shopcalc.rates import fetch_rate   # ...so patch "shopcalc.pricing.fetch_rate"

from X import Y does not create a link to X. It copies the reference into the importing module’s namespace. After that line runs, shopcalc.pricing.now and shopcalc.clock.now are two separate names that happen to point at one function object:

import shopcalc.clock, shopcalc.pricing
print(shopcalc.clock.now is shopcalc.pricing.now)   # => True   -- same object
shopcalc.clock.now = lambda: "PATCHED"              # rebind ONE name
print(shopcalc.pricing.now)                         # => <function now at 0x104e0cfe0>
print(shopcalc.pricing.now())                       # => 2026-07-15 19:40:08.900153  -- still real!

Rebinding shopcalc.clock.now points that name at the lambda. shopcalc.pricing.now still points at the original function, because it was never looking at clock’s namespace — it got its own copy at import time. So:

def test_patch_where_defined_DOES_NOT_WORK(monkeypatch):
    # WRONG: pricing did `from shopcalc.clock import now` at import time,
    # so it holds its own reference. Rebinding clock.now is invisible to it.
    monkeypatch.setattr("shopcalc.clock.now", lambda: FROZEN)
    assert order_total(ITEMS) == Decimal("459.80")


def test_patch_where_used_WORKS(monkeypatch):
    # RIGHT: rebind the name in the namespace that looks it up.
    monkeypatch.setattr("shopcalc.pricing.now", lambda: FROZEN)
    assert order_total(ITEMS) == Decimal("459.80")
patchdemo/test_wrongpatch.py F.                                          [100%]

=================================== FAILURES ===================================
____________________ test_patch_where_defined_DOES_NOT_WORK ____________________

    def test_patch_where_defined_DOES_NOT_WORK(monkeypatch):
        monkeypatch.setattr("shopcalc.clock.now", lambda: FROZEN)
>       assert order_total(ITEMS) == Decimal("459.80")
E       AssertionError: assert Decimal('484.00') == Decimal('459.80')
E        +  where Decimal('484.00') = order_total([(2, Decimal('62.00')), (1, Decimal('360.00'))])

patchdemo/test_wrongpatch.py:16: AssertionError
=========================== short test summary info ============================
FAILED patchdemo/test_wrongpatch.py::test_patch_where_defined_DOES_NOT_WORK
========================= 1 failed, 1 passed in 0.03s ==========================

The mock was created, installed, and never consulted. The real clock ran.

Now the part that should make you sit up: that test fails because it is 19:40 right now. Run the identical broken test between 16:00 and 17:59 and it passes — the real clock happens to agree with the frozen one. A wrong patch does not reliably fail; it produces a test that passes on your laptop in the morning and fails in CI at teatime, and you will blame everything except the patch target.

And it gets worse than a wrong answer. The same mistake against the network, using unittest.mock.patch:

@patch("shopcalc.rates.fetch_rate")             # WRONG target: never used
def test_patch_wrong_target_hits_the_network(mock_fetch):
    with patch("shopcalc.pricing.is_happy_hour", return_value=False):
        order_total_in(ITEMS, "USD")
    mock_fetch.assert_called_once_with("USD")
During handling of the above exception, another exception occurred:
E   urllib.error.URLError: <urlopen error [Errno 8] nodename nor servname provided, or not known>
/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py:1347: urllib.error.URLError
=========================== short test summary info ============================
FAILED mockdemo/test_patch.py::test_patch_wrong_target_hits_the_network - url...
1 failed, 2 passed in 0.03s

Read that traceback. A unit test just did a DNS lookup and tried to open an HTTPS connection to api.example.com. It only failed because the host does not resolve. Point that at a real API and your unit suite is now hammering a live service — slowly, flakily, and possibly with side effects. This is exactly the mistake the no_real_network autouse fixture makes impossible.

So, the rule, mechanically:

pricing.py contains Patch target Why
from shopcalc.clock import now shopcalc.pricing.now The name lives in pricing’s namespace
import shopcalc.clockshopcalc.clock.now() shopcalc.clock.now The lookup happens in clock at call time
from shopcalc import clockclock.now() shopcalc.clock.now Same — the attribute is resolved at call time
obj.method() where obj: Thing shopcalc.pricing.Thing.method (or inject a fake obj) Patch the class the module uses
Anything, and you are unsure print(shopcalc.pricing.now) after patching If it is not a Mock, you patched the wrong name

The one-sentence version: patch the name in the namespace where the code under test looks it up, not where the function was defined. And the corollary that explains the import shopcalc.clock rows: from X import Y binds at import time (so patch the importer), while X.Y() looks up at call time (so patch X). This is also a decent argument for import module over from module import name in code you expect to test.

Mock, MagicMock, and autospec

A Mock will agree with anything you say to it. That is its power and its danger:

m = Mock()
m.anything.at.all           # => <Mock name='mock.anything.at.all' id='4380851952'>
m.fetch_rate("USD")         # => <Mock name='mock.fetch_rate()' id='4384837552'>
bool(m)                     # => True     -- always truthy
len(Mock())                 # TypeError: object of type 'Mock' has no len()
len(MagicMock())            # => 0        -- MagicMock configures the dunders

Mock auto-creates any attribute you touch; MagicMock also configures the magic methods (__len__, __iter__, __enter__, …), which is why patch() gives you a MagicMock by default.

The danger: Mock never says no. Typo an attribute and you get a truthy Mock, not an error. Call it with a nonsense signature and it accepts:

bare = Mock()
bare("USD", "extra", nonsense=1)     # accepted happily
# call_args = call('USD', 'extra', nonsense=1)

Your test passes. Production explodes with TypeError: fetch_rate() takes 1 positional argument but 3 were given. The mock agreed with a call the real function would reject. That is the deepest problem with mocking, and autospec is the answer — it builds the double from the real object’s signature:

spec = create_autospec(fetch_rate, return_value=Decimal("0.012"))
spec("USD")                          # => Decimal('0.012')
spec("USD", "extra", nonsense=1)     # TypeError: too many positional arguments
spec.nonexistent_attr                # AttributeError: 'function' object has no attribute 'nonexistent_attr'

Now the double is bound to the real API, so changing fetch_rate’s signature breaks the tests that mock it — which is exactly what you want. Use autospec=True (or create_autospec) by default. One keyword, and the entire “mock drifted from reality” category is gone.

One piece of folklore deserves correcting, because it is now half wrong. The story goes: typo an assertion on a Mock and it silently passes. Modern unittest.mock fixed the common cases — any attribute starting with assert, plus a deny-list of near-misses, raises:

  assert_called_onse_with      -> AttributeError (caught)
  called_once_with             -> AttributeError (caught)
  not_called                   -> AttributeError (caught)
  verify_called                -> SILENT: <Mock name='mock.verify_called' id='4374720064'>
  should_have_been_called      -> SILENT: <Mock name='mock.should_have_been_called' id='4374720448'>

deny list: ['any_call', 'called', 'called_once', 'called_once_with', 'called_with', 'has_calls', 'not_called']

So m.assert_called_onse_with(...) and m.called_once_with(...) now raise. But an assertion name mock has never heard of — m.verify_called(), m.should_have_been_called() — is still a silently truthy Mock. The protection is a blocklist, not a principle. autospec remains the real fix: a spec’d mock rejects every attribute the original does not have.

Tool Gives Catches a bad signature? Use when
Mock() Anything you touch No Quick stub where the API is trivial
MagicMock() Same + dunders configured No Need len/iter/with on the double
Mock(spec=Thing) Only Thing’s attributes Attributes yes, signatures no Cheap guard
create_autospec(Thing) Attributes and signatures Yes The default you should reach for
patch(..., autospec=True) Same, as a decorator/CM Yes Patching + specs together
A hand-written fake Exactly what you wrote Yes (it is real code) The API is small and used everywhere

Asserting calls

When the interaction is the behaviour:

Assertion Checks
m.assert_called() Called at least once
m.assert_called_once() Called exactly once
m.assert_called_with(*a, **kw) The most recent call matched
m.assert_called_once_with(*a, **kw) Called once, and with these args
m.assert_any_call(*a, **kw) Some call matched
m.assert_has_calls([call(1), call(2)]) These calls appear, in order
m.assert_not_called() Never called
m.call_count How many times
m.call_args.args, .kwargs The last call’s arguments
m.call_args_list Every call
m.return_value = x What it returns
m.side_effect = ValueError("boom") Raise when called
m.side_effect = [1, 2, 3] Return these in sequence
m.side_effect = fn Call fn instead

side_effect is how you test error handling — the only sane way to make a network call fail on demand:

def test_retries_on_timeout(monkeypatch):
    calls = Mock(side_effect=[TimeoutError, TimeoutError, Decimal("0.012")])
    monkeypatch.setattr("shopcalc.pricing.fetch_rate", calls)
    assert order_total_in(ITEMS, "USD") == Decimal("5.81")
    assert calls.call_count == 3

And patch as a decorator, where the argument order surprises people:

@patch("shopcalc.pricing.is_happy_hour", return_value=False)
@patch("shopcalc.pricing.fetch_rate")           # bottom decorator = FIRST arg
def test_patch_decorator(mock_fetch, mock_happy):
    mock_fetch.return_value = Decimal("0.012")
    assert order_total_in(ITEMS, "USD") == Decimal("5.81")
    mock_fetch.assert_called_once_with("USD")

Stacked @patch decorators apply bottom-up, so the bottom one is the first parameter. Get it backwards and you configure the wrong mock, which usually produces a baffling failure rather than an obvious one. Decorators also inject parameters before pytest’s fixtures, so def test(mock_a, mock_b, tmp_path) — mocks first.

“Don’t mock what you don’t own”

The most important mocking rule, and the least followed. When you patch("requests.get"), you are asserting that you know how requests behaves — its retries, its exceptions, its redirects, its response object. You are testing your code against your beliefs about a library, not against the library. When those beliefs are wrong, or v3 changes them, your tests stay green and production breaks: you have built a suite that verifies your misunderstanding.

The discipline: wrap third-party APIs in a thin adapter that you own, and mock the adapter.

# shopcalc/rates.py -- the ONLY place that knows about HTTP
def fetch_rate(currency: str) -> Decimal:
    with urlopen(f"{API}/{currency}", timeout=5) as resp:
        return Decimal(str(json.loads(resp.read())["rate"]))

Everything else calls fetch_rate(currency) — a function you own, with a signature you designed, returning a Decimal. Your unit tests stub fetch_rate, which is honest: it is your contract. Then one integration test exercises the real fetch_rate against the real API (or a recorded response), and that is the single place a requests change can break you. One test to fix instead of two hundred.

This is exactly what shopcalc does, and it is why rates.py shows up as uncovered in every report below — by design.

So: mock your own adapters, the clock, randomness, and slow or paid external calls. Do not mock requests, boto3, the DB driver, pure functions (just call them), your own dataclasses, or anything a fake would model better.

The other half of the same rule: over-mocking is a design smell. If a test needs six mocks, the unit has six collaborators, and no amount of mocking will fix that. A test where every collaborator is mocked asserts only that your function calls the functions you told it to call — it is a mirror, and it will pass forever regardless of whether the code works.

The diagram below is the whole run, end to end. Read it left to right: pytest resolves rootdir and auto-imports every conftest.py; collection finds test_* items (or dies with ModuleNotFoundError before a single test runs); for each item, fixtures are resolved by name and built outside-in from the widest scope down; the test arranges, acts and asserts; and the report sums it up with an optional coverage table.

The real pytest run as a left-to-right pipeline: the pytest command resolves rootdir from pyproject.toml and auto-imports conftest.py, collection gathers test_ items into node IDs or fails with ModuleNotFoundError, fixtures are injected by name and built outside-in from session through module to function scope with monkeypatch marked as the patch-where-used trap, each test runs arrange-act-assert producing a dot or an F with assertion introspection, and the report ends in a coverage summary annotated with the warning that 100 percent coverage still misses an off-by-one bug

The six badges mark where the time goes: conftest.py is imported for you, which is why an un-imported fixture resolves and why a misplaced conftest leaks fixtures (1); a collection-time ModuleNotFoundError is a packaging problem, not a test problem, and src/ layout plus pip install -e . is the fix (2); scope decides how often setup runs, and widening it is how tests that pass alone start failing together (3); monkeypatch must target the namespace that uses the name, or the mock is silently ignored and the real clock or real network runs (4); assertion introspection decomposes a plain assert into the call and its arguments (5); and the coverage summary measures execution, never correctness (6).


Coverage: what the percentage actually means

Coverage measures which lines of your code executed while the tests ran. That is all it measures. It is a tool for finding untested code, and it is worthless as a goal.

pip install pytest-cov
pytest --cov=shopcalc --cov-report=term-missing
================================ tests coverage ================================
_______________ coverage: platform darwin, python 3.12.3-final-0 _______________

Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
src/shopcalc/__init__.py       0      0   100%
src/shopcalc/clock.py          3      1    67%   6
src/shopcalc/pricing.py       33      2    94%   19, 33
src/shopcalc/rates.py          7      2    71%   10-11
src/shopcalc/receipt.py        9      0   100%
--------------------------------------------------------
TOTAL                         52      5    90%
14 passed in 0.05s
Column Means
Stmts Executable statements (blank lines, comments and def signatures are not counted)
Miss Statements never executed
Cover (Stmts - Miss) / Stmts
Missing The line numbers. The only column that helps you
Branch / BrPart Branch points / partially-taken branches (with --cov-branch)

90%. Now do the thing almost nobody does: read the Missing column instead of the TOTAL.

Two of the four gaps are by design and two are bugs waiting. A single number could never have told you that. So we write the two missing tests:

def test_negative_price_is_rejected():
    with pytest.raises(ValueError, match=r"unit_price must not be negative, got -1"):
        line_total(1, Decimal("-1.00"))


def test_bigspend_below_threshold_is_not_applied():
    assert apply_discount(Decimal("1999.99"), "BIGSPEND") == Decimal("1999.99")
Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
src/shopcalc/pricing.py       33      0   100%
--------------------------------------------------------
TOTAL                         52      3    94%
16 passed in 0.06s

pricing.py is at 100%. That is coverage used correctly: as a checklist of paths you forgot, not as a score.

Line vs branch coverage

Line coverage has a hole. Consider:

if code == "BIGSPEND" and amount < MIN_FOR_BIGSPEND:
    return amount
return _money(amount * (Decimal(1) - DISCOUNTS[code]))

If every test makes that condition True, both lines execute and line coverage says 100% — while the False path has never run. Branch coverage tracks each arc out of each decision:

pytest --cov=shopcalc --cov-branch --cov-report=term-missing
Name                       Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------------
src/shopcalc/__init__.py       0      0      0      0   100%
src/shopcalc/clock.py          3      1      0      0    67%   6
src/shopcalc/pricing.py       33      0     12      0   100%
src/shopcalc/rates.py          7      2      0      0    71%   10-11
src/shopcalc/receipt.py        9      0      0      0   100%
----------------------------------------------------------------------
TOTAL                         52      3     12      0    95%
16 passed in 0.08s

pricing.py: 33 statements, 0 missed, 12 branches, 0 partial. One hundred percent line coverage and one hundred percent branch coverage. Always run --cov-branch; the extra cost is negligible and plain line coverage systematically overstates how well tested you are.

And now the point

pricing.py is at 100% line and 100% branch coverage. Here is is_happy_hour again — fully covered, every branch both ways:

def is_happy_hour() -> bool:
    return 16 <= now().hour <= 18

Happy hour is 16:00 to 18:00. Ask a boundary table:

@pytest.mark.parametrize(
    "hour, expected",
    [(15, False), (16, True), (17, True), (18, False), (19, False)],
    ids=["15-before", "16-opens", "17-inside", "18-closes", "19-after"],
)
def test_happy_hour_boundaries(at_hour, hour, expected):
    at_hour(hour)
    assert is_happy_hour() is expected
.................F...                                                    [100%]
=================================== FAILURES ===================================
____________________ test_happy_hour_boundaries[18-closes] _____________________

at_hour = <function at_hour.<locals>._set at 0x103efc5e0>, hour = 18
expected = False

    def test_happy_hour_boundaries(at_hour, hour, expected):
        at_hour(hour)
>       assert is_happy_hour() is expected
E       assert True is False
E        +  where True = is_happy_hour()

tests/test_pricing.py:74: AssertionError
=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_happy_hour_boundaries[18-closes] - assert ...
1 failed, 20 passed in 0.04s

<= 18 means every minute from 18:00 to 18:59 is happy hour. The bar has been giving away a 5% discount for an hour a day, and coverage was 100% the whole time. The fix is one character:

return 16 <= now().hour < 18

Say it plainly: 100% coverage does not mean your code is correct. It means every line ran. A test that calls a function and asserts nothing gives you 100% coverage of that function. Coverage cannot see missing test cases, wrong expected values, unasserted return values, or an off-by-one on a boundary it happily executed. What found this bug was not a tool — it was thinking about boundaries and writing them down as a table.

Metric Measures Blind to
Line Statements executed Untaken branches; everything below
Branch Each arc out of each decision Condition combinations; everything below
Condition/MC-DC Each sub-condition independently Aviation-grade; overkill for you
Mutation (mutmut, cosmic-ray) Whether your tests detect an introduced bug The closest thing to “are my assertions any good”
None of them Whether the expected value is right Boundaries, missing cases, business logic

If you want to know whether your assertions are worth anything, mutation testing is the honest answer: it changes < to <= in your source and checks whether a test goes red. Our suite would have caught that mutant — but only after we wrote the boundary table.

So: turn on --cov-branch always; read the Missing column, not the total; expect less than 100% and know why each gap is there (# pragma: no cover marks a deliberate one). A floor in CI (--cov-fail-under=85) stops rot, while a target of 100% produces tests written to touch lines, which are the worst tests there are. Coverage is a smoke detector: silence does not mean there is no fire.

TDD, briefly

Red → Green → Refactor. Write a failing test first. Write the least code that passes it. Then clean up, with the test holding the behaviour still.

The mechanical benefit is underrated: a test you have never seen fail is not a test. Writing it first guarantees you watched it go red for the right reason. Plenty of “passing” tests pass because of a typo’d patch target, an assert on a Mock, or a name that never got collected — and you would never know, because you only ever saw them green. TDD is the cheapest possible way to test your tests.

The design benefit is bigger: writing the test first forces you to use your API from the outside before you build it. That is when you notice write_receipt() should take a directory and is_happy_hour() needs the clock passed in — design concerns that testing surfaced.

You need not be dogmatic. TDD shines for pure logic, bug fixes and well-understood requirements; it gets in the way when you are exploring or spiking. But always write the failing test for a bug fix. That is the highest-value test in any suite: it documents a mistake you have already proven you can make.

What makes a test good

A good test A bad test
Fast — milliseconds, so it runs on save Slow — so it runs at 5 p.m., or never
Isolated — any order, alone or together Depends on another test having run first
Deterministic — same answer every time Uses the real clock, network, or randomness
One reason to fail — the name says which Fails for six reasons; the name says “checkout”
Tests behaviour through the public API Tests implementation: private methods, internals
Readable — AAA visible at a glance 40 lines of setup and a mock forest
Asserts the outcome Asserts that a mock was called
Fails with a useful message assert result → “assert None”
Uses real objects where it can Mocks everything, including value objects
Named for the behaviour test_1, test_it_works, test_function

The two that catch the most people: flaky and over-mocked. A flaky test is worse than no test, because a suite that fails 3% of the time teaches the team to re-run CI without reading the failure — and then the real failure gets re-run too. Fix it or delete it; there is no third option. And an over-mocked test passes forever no matter what the code does, which is a very expensive way to write assert True.


Hands-on lab

Build the whole thing from scratch: a package with a real seam for the clock and the network, a test suite that uses every tool above, and a coverage-driven hunt for a bug that coverage cannot see. About 20 minutes.

Step 1 — Project skeleton and a virtual environment.

mkdir -p ~/shopcalc/src/shopcalc ~/shopcalc/tests && cd ~/shopcalc
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip
pip install pytest pytest-cov
pytest --version
pytest 9.1.1

What just happened: an isolated environment. Everything below installs here, not into your system Python. (Outputs in this lab are from pytest 9.1.1 / coverage 7.15.1 on Python 3.12; small formatting details vary between versions, the substance does not.)

Step 2 — The module under test. Three files, and the split is the point: clock.py and rates.py exist only to be seams.

# src/shopcalc/clock.py
from datetime import datetime


def now() -> datetime:
    """Wall clock. The seam tests replace so time stops being an input."""
    return datetime.now()
# src/shopcalc/rates.py
import json
from decimal import Decimal
from urllib.request import urlopen

API = "https://api.example.com/rates"


def fetch_rate(currency: str) -> Decimal:
    """Live FX lookup. Real network I/O -- tests must never call this."""
    with urlopen(f"{API}/{currency}", timeout=5) as resp:
        return Decimal(str(json.loads(resp.read())["rate"]))
# src/shopcalc/pricing.py
from decimal import Decimal, ROUND_HALF_UP

from shopcalc.clock import now          # imported INTO this module's namespace
from shopcalc.rates import fetch_rate   # ...so patch "shopcalc.pricing.fetch_rate"

CENTS = Decimal("0.01")
DISCOUNTS = {"WELCOME10": Decimal("0.10"), "BIGSPEND": Decimal("0.20")}
MIN_FOR_BIGSPEND = Decimal("2000.00")


def _money(d: Decimal) -> Decimal:
    return d.quantize(CENTS, rounding=ROUND_HALF_UP)


def line_total(qty: int, unit_price: Decimal) -> Decimal:
    if qty <= 0:
        raise ValueError(f"qty must be positive, got {qty}")
    if unit_price < 0:
        raise ValueError(f"unit_price must not be negative, got {unit_price}")
    return _money(Decimal(qty) * unit_price)


def subtotal(items) -> Decimal:
    return _money(sum((line_total(q, p) for q, p in items), start=Decimal("0")))


def apply_discount(amount: Decimal, code: str | None) -> Decimal:
    if code is None:
        return amount
    if code not in DISCOUNTS:
        raise ValueError(f"unknown discount code: {code!r}")
    if code == "BIGSPEND" and amount < MIN_FOR_BIGSPEND:
        return amount
    return _money(amount * (Decimal(1) - DISCOUNTS[code]))


def is_happy_hour() -> bool:
    return 16 <= now().hour <= 18


def order_total(items, code: str | None = None) -> Decimal:
    total = apply_discount(subtotal(items), code)
    if is_happy_hour():
        total = _money(total * Decimal("0.95"))
    return total


def order_total_in(items, currency: str, code: str | None = None) -> Decimal:
    return _money(order_total(items, code) * fetch_rate(currency))
# src/shopcalc/receipt.py
from pathlib import Path

from shopcalc.pricing import order_total


def write_receipt(outdir: Path, order_id: str, items, code: str | None = None) -> Path:
    """Write a receipt into outdir and return the path written."""
    path = outdir / f"{order_id}.txt"
    lines = [f"RECEIPT {order_id}"]
    lines += [f"  {qty} x {price}" for qty, price in items]
    lines.append(f"TOTAL {order_total(items, code)}")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return path
touch src/shopcalc/__init__.py

What just happened: Decimal everywhere (money is never a float — see domain models); write_receipt takes its output directory as a parameter, which is what makes tmp_path work; and the two hidden inputs live behind functions you own. is_happy_hour contains a deliberate bug. Do not fix it yet.

Step 3 — Watch it fail to import.

# tests/test_pricing.py
from decimal import Decimal

from shopcalc.pricing import line_total


def test_line_total_multiplies_qty_by_price():
    assert line_total(2, Decimal("62.00")) == Decimal("124.00")
pytest
collected 0 items / 1 error

==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/Users/vinod/shopcalc/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
tests/test_pricing.py:3: in <module>
    from shopcalc.pricing import line_total
E   ModuleNotFoundError: No module named 'shopcalc'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.06s ===============================

What just happened: the error every beginner hits, on purpose. Zero tests ran — this is an import failure at collection, not a test failure. src/shopcalc is not on sys.path and no amount of cd will fix it properly.

Step 4 — Fix it the permanent way.

# pyproject.toml
[project]
name = "shopcalc"
version = "0.1.0"
requires-python = ">=3.12"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: takes over a second", "net: needs the network"]
pip install -e . && pytest
============================= test session starts ==============================
platform darwin -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/vinod/shopcalc
configfile: pyproject.toml
testpaths: tests
plugins: cov-7.1.0
collected 1 item

tests/test_pricing.py .                                                  [100%]

============================== 1 passed in 0.02s ===============================

What just happened: the editable install put shopcalc on the path properly, so your tests import it exactly the way your users will. Note the header now shows configfile and testpaths — pytest found the config, so rootdir is settled.

Step 5 — Fixtures in conftest.py.

# tests/conftest.py
from decimal import Decimal

import pytest


@pytest.fixture
def items():
    """A two-line basket. Function-scoped: every test gets a fresh list."""
    return [(2, Decimal("62.00")), (1, Decimal("360.00"))]


@pytest.fixture
def at_hour(monkeypatch):
    """Freeze the clock at a given hour, patched where pricing USES it."""
    from datetime import datetime

    def _set(hour: int):
        stamp = datetime(2026, 7, 15, hour, 30)
        monkeypatch.setattr("shopcalc.pricing.now", lambda: stamp)
        return stamp

    return _set

What just happened: items returns a fresh list per test — the whole point of function scope. at_hour is a factory fixture: it returns a function, so each test picks its own hour. It requests monkeypatch, so every patch it makes is undone automatically. And note the target — shopcalc.pricing.now, not shopcalc.clock.now.

Step 6 — The suite: plain asserts, parametrize, raises, monkeypatch.

# tests/test_pricing.py
from decimal import Decimal

import pytest

from shopcalc.pricing import (apply_discount, is_happy_hour, line_total,
                              order_total, order_total_in, subtotal)


def test_line_total_multiplies_qty_by_price():
    assert line_total(2, Decimal("62.00")) == Decimal("124.00")


def test_subtotal_sums_lines(items):
    assert subtotal(items) == Decimal("484.00")


@pytest.mark.parametrize(
    "amount, code, expected",
    [
        ("1000.00", None,        "1000.00"),
        ("1000.00", "WELCOME10", "900.00"),
        ("3000.00", "BIGSPEND",  "2400.00"),
        ("2000.00", "BIGSPEND",  "1600.00"),
    ],
    ids=["no-code", "welcome10", "bigspend-eligible", "bigspend-at-threshold"],
)
def test_apply_discount(amount, code, expected):
    assert apply_discount(Decimal(amount), code) == Decimal(expected)


def test_unknown_code_is_rejected():
    with pytest.raises(ValueError, match=r"unknown discount code: 'FREESHIP'"):
        apply_discount(Decimal("1000.00"), "FREESHIP")


@pytest.mark.parametrize("qty", [0, -5])
def test_bad_qty_is_rejected(qty):
    with pytest.raises(ValueError, match=r"qty must be positive"):
        line_total(qty, Decimal("62.00"))


def test_happy_hour_discount_applies(items, at_hour):
    at_hour(17)
    assert order_total(items) == Decimal("459.80")


def test_no_happy_hour_discount_at_ten(items, at_hour):
    at_hour(10)
    assert order_total(items) == Decimal("484.00")


def test_order_total_in_usd(items, at_hour, monkeypatch):
    at_hour(10)
    monkeypatch.setattr("shopcalc.pricing.fetch_rate", lambda cur: Decimal("0.012"))
    assert order_total_in(items, "USD") == Decimal("5.81")
pytest -q
............                                                             [100%]
12 passed in 0.02s

What just happened: twelve tests, no classes, no self, no assertion vocabulary. at_hour(17) made time an input484.00 × 0.95 = 459.80 is now deterministic on any machine at any hour. The last test stubbed the FX call at shopcalc.pricing.fetch_rate and never touched a socket: 484.00 × 0.012 = 5.808, rounded half-up to 5.81.

Step 7 — tmp_path for file I/O.

# tests/test_receipt.py
from shopcalc.receipt import write_receipt


def test_write_receipt_creates_file(tmp_path, items, at_hour):
    at_hour(10)                                   # no happy hour -> 484.00
    path = write_receipt(tmp_path, "ORD-1001", items)

    assert path.exists()
    assert path.name == "ORD-1001.txt"
    assert path.parent == tmp_path                # never the cwd
    lines = path.read_text(encoding="utf-8").splitlines()
    assert lines[0] == "RECEIPT ORD-1001"
    assert lines[-1] == "TOTAL 484.00"
    assert len(lines) == 4


def test_receipt_dir_starts_empty(tmp_path):
    assert list(tmp_path.iterdir()) == []         # a brand-new dir per test
..............                                                           [100%]
14 passed in 0.02s

What just happened: real files, written to a real directory, cleaned up for you, and nothing landed in your repo. test_receipt_dir_starts_empty proves the isolation: tmp_path is a fresh directory per test, so these two tests cannot see each other’s files no matter what order they run in.

Step 8 — Prove the patch-where-used rule. Create tests/test_patching.py:

from datetime import datetime
from decimal import Decimal

from shopcalc.pricing import order_total

FROZEN = datetime(2026, 7, 15, 17, 30)   # 17:30 -> happy hour
ITEMS = [(2, Decimal("62.00")), (1, Decimal("360.00"))]


def test_patch_where_defined_DOES_NOT_WORK(monkeypatch):
    monkeypatch.setattr("shopcalc.clock.now", lambda: FROZEN)     # WRONG
    assert order_total(ITEMS) == Decimal("459.80")


def test_patch_where_used_WORKS(monkeypatch):
    monkeypatch.setattr("shopcalc.pricing.now", lambda: FROZEN)   # RIGHT
    assert order_total(ITEMS) == Decimal("459.80")
tests/test_patching.py F.                                                [100%]

=================================== FAILURES ===================================
____________________ test_patch_where_defined_DOES_NOT_WORK ____________________

    def test_patch_where_defined_DOES_NOT_WORK(monkeypatch):
        monkeypatch.setattr("shopcalc.clock.now", lambda: FROZEN)     # WRONG
>       assert order_total(ITEMS) == Decimal("459.80")
E       AssertionError: assert Decimal('484.00') == Decimal('459.80')
E        +  where Decimal('484.00') = order_total([(2, Decimal('62.00')), (1, Decimal('360.00'))])

tests/test_patching.py:16: AssertionError
========================= 1 failed, 1 passed in 0.03s ==========================

What just happened: identical tests, one character of target apart. The first patched clock.now; pricing.py did from shopcalc.clock import now at import time and holds its own reference, so it never saw the lambda and the real clock ran. ⚠️ Run this file between 16:00 and 17:59 and the broken test passes — a wrong patch target does not fail reliably, it fails sometimes, which is how it survives code review. Delete this file when you have seen it.

Step 9 — Coverage, and reading it properly.

pytest --cov=shopcalc --cov-report=term-missing
Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
src/shopcalc/__init__.py       0      0   100%
src/shopcalc/clock.py          3      1    67%   6
src/shopcalc/pricing.py       33      2    94%   19, 33
src/shopcalc/rates.py          7      2    71%   10-11
src/shopcalc/receipt.py        9      0   100%
--------------------------------------------------------
TOTAL                         52      5    90%
14 passed in 0.05s

What just happened: 90%, and the total is the least useful thing on screen. Open pricing.py at lines 19 and 33: the negative-price guard and the BIGSPEND-below-threshold return. Neither has ever run. Meanwhile clock.py:6 and rates.py:10-11 are the real clock and the real HTTP call — uncovered by design, because unit tests must never execute them.

Step 10 — Close the real gaps. Append to tests/test_pricing.py:

def test_negative_price_is_rejected():
    with pytest.raises(ValueError, match=r"unit_price must not be negative, got -1"):
        line_total(1, Decimal("-1.00"))


def test_bigspend_below_threshold_is_not_applied():
    assert apply_discount(Decimal("1999.99"), "BIGSPEND") == Decimal("1999.99")
pytest --cov=shopcalc --cov-branch --cov-report=term-missing
Name                       Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------------
src/shopcalc/__init__.py       0      0      0      0   100%
src/shopcalc/clock.py          3      1      0      0    67%   6
src/shopcalc/pricing.py       33      0     12      0   100%
src/shopcalc/rates.py          7      2      0      0    71%   10-11
src/shopcalc/receipt.py        9      0      0      0   100%
----------------------------------------------------------------------
TOTAL                         52      3     12      0    95%
16 passed in 0.06s

What just happened: pricing.py33 statements, 0 missed, 12 branches, 0 partial. 100% line and 100% branch coverage. Coverage has nothing left to tell you about this module. Hold that thought for exactly one step.

Step 11 — Find the bug coverage cannot see. Append:

@pytest.mark.parametrize(
    "hour, expected",
    [(15, False), (16, True), (17, True), (18, False), (19, False)],
    ids=["15-before", "16-opens", "17-inside", "18-closes", "19-after"],
)
def test_happy_hour_boundaries(at_hour, hour, expected):
    at_hour(hour)
    assert is_happy_hour() is expected
.................F...                                                    [100%]
=================================== FAILURES ===================================
____________________ test_happy_hour_boundaries[18-closes] _____________________

at_hour = <function at_hour.<locals>._set at 0x103efc5e0>, hour = 18
expected = False

    def test_happy_hour_boundaries(at_hour, hour, expected):
        at_hour(hour)
>       assert is_happy_hour() is expected
E       assert True is False
E        +  where True = is_happy_hour()

tests/test_pricing.py:74: AssertionError
=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_happy_hour_boundaries[18-closes] - assert ...
1 failed, 20 passed in 0.04s

What just happened: the lesson. A module at 100% line and 100% branch coverage just failed a boundary test. 16 <= hour <= 18 gives away a discount for the whole 18:00 hour. Coverage never had a chance — it measures execution, not truth. The node ID test_happy_hour_boundaries[18-closes] names the broken boundary before you read any code, which is what good ids buy you.

Step 12 — Red to green.

pytest --lf -q         # rerun ONLY the failure
1 failed in 0.02s

Fix the off-by-one in src/shopcalc/pricing.py:

def is_happy_hour() -> bool:
    return 16 <= now().hour < 18       # was: <= 18
pytest -q
.....................                                                    [100%]
21 passed in 0.02s

What just happened: --lf ran one test instead of twenty-one — the tight loop you should live in. Then the full suite confirmed the fix broke nothing else, which is the entire promise of a regression suite: you changed a comparison operator in pricing code and knew, in 0.02 seconds, that nothing downstream cared.

You now have a suite where a bug that survived 100% branch coverage is permanently impossible to reintroduce — because a boundary table is watching it, forever, in twenty milliseconds.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
ModuleNotFoundError: No module named 'shopcalc' at collection; 0 tests ran Your package is not on sys.path. rootdir does not add it src/ layout + pyproject.toml + pip install -e . in the active venv. Never sys.path.append in a test
ImportError while importing test module ... Hint: make sure your test modules have valid Python names Two test_*.py with the same basename and no __init__.py Give them unique names, or add __init__.py to test dirs, or set consider_namespace_packages
Tests pass alone, fail together Shared mutable state via a module/session/class fixture, or a global Use function scope; if you must widen, return an immutable value or reset in a yield teardown
Tests pass together, fail with -p no:randomly / a new order Order dependency — one test relies on another’s side effect Same fix. Run pytest -p no:cacheprovider and shuffle to smoke these out
A mock is never used; the real clock/network runs Patched where the function was defined, not where it is used from X import Y → patch mymod.Y. Verify: print(mymod.Y) should show a Mock
A “unit” test raises urllib.error.URLError / hangs Same as above — the patch missed and you hit the real network Fix the target; add an autouse fixture that blocks socket.socket suite-wide
Test result changes by time of day Real clock in the code path (often because a patch missed) Patch the clock at the seam; use a freeze fixture, never datetime.now() in logic
E (error) not F (failure) in the report An exception in a fixture — setup never completed Read ERROR at setup of test_x. The scaffolding is broken, not the code
assert 0.1 + 0.2 == 0.3 fails Binary floats cannot represent decimals exactly == pytest.approx(0.3). For money use Decimal and drop approx entirely
fixture 'items' not found Fixture is in a sibling dir, misspelled, or the file is not a conftest.py pytest --fixtures lists what is visible here. conftest applies downward only
ScopeMismatch: You tried to access the function scoped fixture with a session scoped request A wide fixture requested a narrower one Widen the inner fixture, or narrow the outer one. Never invert
PytestCollectionWarning: cannot collect test class 'TestX' because it has a __init__ constructor A Test* class with __init__ is silently skipped Delete __init__; use fixtures for per-test state
A test file/function never runs, no error Name does not match test_*.py / test* Check pytest --collect-only. tests_pricing.py and check_total() are invisible
PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? Unregistered/typo’d mark; the tag does nothing Register in [tool.pytest.ini_options] markers = [...] and run --strict-markers
Failed: DID NOT RAISE ValueError Code under pytest.raises did not raise The behaviour changed, or you are calling the wrong thing. Keep the block to one line
pytest.raises(match=...) never matches match is re.search; (, ), [, ., $ are metacharacters match=re.escape("unknown code: 'X'")
100% coverage, function still broken Coverage measures execution, not correctness Boundary/parametrize tables; --cov-branch; mutation testing
A test passes but asserts nothing Assertion forgotten, or assert on a truthy Mock Watch it fail first (TDD). Use autospec. Ban bare assert mock.something
X (XPASS) in the report and nobody notices xfail is non-strict by default xfail_strict = true. An xfail can silently absorb a real regression
AttributeError: 'called_once_with' is not a valid assertion Mock’s typo guard fired Use assert_called_once_with. Note m.verify_called() is still silent — use autospec
Mock accepts a call the real function rejects Bare Mock() has no signature create_autospec(fn) / patch(..., autospec=True)
Stacked @patch mocks are swapped Decorators apply bottom-up Bottom @patch = first parameter. Mocks come before pytest fixtures
tmp_path test fails only in CI Code assumed the cwd, not the given dir Pass the directory in as a parameter. Use monkeypatch.chdir(tmp_path) only as a last resort
Every instance shares a fixture’s list Fixture returns a module-level/mutable default object Build a new object inside the fixture body, function-scoped
Suite is green under python -O -O strips assert statements entirely Never run tests with -O/PYTHONOPTIMIZE
CI green but nothing ran Exit code 5 = “no tests collected” Assert a minimum: `–collect-only -q

Four of these will cost you real hours.

1. ModuleNotFoundError — a packaging bug wearing a testing costume. The instinct is to “fix the test,” and there are four bad fixes on the internet: sys.path.append at the top of the test, a conftest.py that mutates sys.path, PYTHONPATH=src pytest, and adding __init__.py until something works. All of them make the symptom go away and leave you with a project whose importability depends on where you stood when you typed the command. The only real fix is to make the package genuinely installedsrc/ layout, a pyproject.toml, pip install -e . — so that your tests import it the same way your users will. If import shopcalc works in a fresh shell in your venv, pytest will find it too.

2. Pass alone, fail together. Watch the mechanism:

@pytest.fixture(scope="module")
def basket():
    return [(2, Decimal("62.00"))]

def test_add_rice(basket):
    basket.append((1, Decimal("360.00")))
    assert len(basket) == 2

def test_basket_starts_with_one_line(basket):
    assert len(basket) == 1          # passes ALONE, fails after test_add_rice
$ pytest "statedemo/test_shared.py::test_basket_starts_with_one_line" -q
1 passed in 0.00s

$ pytest statedemo/test_shared.py -q
E   AssertionError: assert 2 == 1
     +  where 2 = len([(2, Decimal('62.00')), (1, Decimal('360.00'))])
1 failed, 1 passed in 0.00s

Identical test, two outcomes, depending only on what ran before it. scope="module" built the list once and handed the same object to both tests; the first mutated it. This is the mutable-default bug from def f(x=[]), relocated into a fixture. The tell is a test that fails in the suite and passes alone — and the fix is almost always to stop widening scope for speed you never measured. If a wide scope is genuinely needed, hand out something immutable, or reset the state in the fixture’s teardown.

3. E is not F, and it changes where you look. pytest distinguishes them and it is telling you something:

__________________ ERROR at setup of test_uses_broken_fixture __________________

    @pytest.fixture
    def broken_setup():
>       assert 1 == 2, "the fixture itself is wrong"
E       AssertionError: the fixture itself is wrong

=========================== short test summary info ============================
ERROR statedemo/test_fixture_assert.py::test_uses_broken_fixture - AssertionE...
1 error in 0.01s

1 error, not 1 failed. An assert in a test is a failure — your code is wrong. An assert (or any exception) in a fixture is an error — your scaffolding is wrong and the test never even ran. When a run reports errors, fix those first: the tests behind them have told you nothing yet, and a wall of Es is usually one broken fixture, not fifty broken functions. (Note the corollary: assert belongs in tests. A fixture should raise or pytest.skip(), not assert — it is not making a claim about your code, it is building the world.)

4. The wrong patch target is a flaky test, not a failing one. This is why it survives. monkeypatch.setattr("shopcalc.clock.now", ...) fails at 19:40 and passes at 17:00, because the real clock sometimes agrees with your frozen one. The same mistake against requests gives you a test that passes fast when the API is up and mysteriously times out when it is down — and reviewers will approve it, because it was green when they looked. The habit that kills the whole class: after patching, assert that the patch took. assert isinstance(shopcalc.pricing.now, Mock), or simply make the mock’s return value impossible for the real thing to produce, so a missed patch fails loudly, every time, instead of occasionally.


Cheat-sheet

Syntax / command What it does
pip install pytest pytest-cov The two you always want (in a venv)
pytest / -q / -v Run all / one char per test / one node ID per line
pytest -x · --maxfail=3 Stop at first / third failure
pytest --lf · --ff · --sw Last-failed only · failed first · stepwise
pytest -k "discount and not big" Select by name expression
pytest -m slow · -m "not slow" Select by mark
pytest path/test_x.py::test_y One test by node ID
pytest "path::test_y[case-id]" One param case (quote it)
pytest -s · --tb=short|line|no Don’t capture stdout · traceback style
pytest --collect-only · --fixtures · --setup-show What runs · what’s available · fixture order
pytest --durations=10 The 10 slowest tests
pytest -n auto Parallel (needs pytest-xdist)
Exit 0 / 1 / 4 / 5 pass / tests failed / usage error / nothing collected
. F E s x X pass · fail · error (fixture) · skip · xfail · XPASS
def test_x(): in test_*.py A test. No class, no self, no import
class TestX: with no __init__ A test class (an __init__ = silently skipped)
assert a == b The only assertion you need — introspected on failure
@pytest.fixture Define a fixture; request it by parameter name
@pytest.fixture(scope="session") function (default) · class · module · package · session
yield in a fixture Value to the test; code after yield = teardown
@pytest.fixture(autouse=True) Runs for every test in scope, unrequested
conftest.py Auto-imported fixtures for this dir and below. Never import it
request.param + indirect=True Parametrize a fixture
tmp_path A fresh Path dir per test — the file-I/O fixture
capsys / capfd / caplog Capture stdout · fd-level · logging
monkeypatch.setattr("mod.name", v) Patch, auto-undone after the test
monkeypatch.setenv/setitem/chdir/syspath_prepend The rest of the family
@pytest.mark.parametrize("a, b", [(1, 2)], ids=[...]) One test, many cases, readable node IDs
pytest.param(1, id="x", marks=pytest.mark.xfail) Mark one case
with pytest.raises(ValueError, match=r"..."): Expect an exception (match = re.search)
re.escape("literal (msg)") When the message has regex metacharacters
as excinfoexcinfo.value / .type Inspect the raised exception
== pytest.approx(0.3) · rel= · abs= Float comparison (relative 1e-6 by default)
@pytest.mark.skip(reason=) / skipif(cond, reason=) Never run / run only if
@pytest.mark.xfail(reason=, strict=True) Known bug; strict makes XPASS a failure
pytest.importorskip("numpy") Skip if a dependency is missing
markers = [...] + --strict-markers Register custom marks; typos become errors
xfail_strict = true Stop XPASS from hiding a fix
from unittest.mock import Mock, MagicMock, patch Stdlib doubles
Mock() vs MagicMock() Auto-attrs vs auto-attrs + dunders
create_autospec(fn) · patch(..., autospec=True) Bind the double to the real signature — do this
@patch("mod.name") (bottom = first arg) Decorator form; mocks precede fixtures
m.return_value · m.side_effect = Exc / [..] / fn Return · raise · sequence · delegate
m.assert_called_once_with(...) · .call_args · .call_count Behaviour verification
Patch where it is USED, not defined from X import Y in mod → patch mod.Y
pytest --cov=pkg --cov-branch --cov-report=term-missing The coverage command you actually want
--cov-fail-under=85 A floor in CI (never a 100% target)
# pragma: no cover Exclude a deliberate gap — deliberately
[tool.pytest.ini_options] in pyproject.toml testpaths, markers, addopts, xfail_strict
src/ layout + pip install -e . The permanent cure for ModuleNotFoundError
⚠️ python -O Strips every assert — never test under it

Interview and exam questions

Q: Why do we write tests? “To catch bugs” is not the answer I’m looking for. A: To change code without fear. Tests are a regression harness: their job is not to prove the code is correct today — they cannot, since you can only assert about scenarios you thought of — but to tell you tomorrow that it still behaves as it did today. That is what turns refactoring, dependency upgrades and fixing a function with eleven callers into routine work instead of a gamble. Two corollaries: a test that breaks on every refactor without a behaviour change is a cost, not an asset, which makes “test behaviour, not implementation” economics rather than taste; and testable code and well-designed code coincide, because both require honest dependencies. When a test is agonising to write, that is information about the code.

Q: What is assertion introspection and how does pytest do it? A: pytest reports the sub-expressions of a failed assert, not just the boolean. assert apply_discount(Decimal("1000.00"), "WELCOME10") == Decimal("950.00") fails with assert Decimal('900.00') == Decimal('950.00') plus where Decimal('900.00') = apply_discount(Decimal('1000.00'), 'WELCOME10') — the call and its arguments. unittest can only say Decimal('900.00') != Decimal('950.00'), because assertEqual receives two already-evaluated values. Mechanically, pytest installs an import hook and rewrites the AST of test modules and conftest.py at import time, caching the result in __pycache__. Two consequences: asserts in non-test helper modules are not rewritten unless you call pytest.register_assert_rewrite, and running under -O strips asserts entirely, making the suite pass vacuously.

Q: Explain pytest fixtures versus setUp. Why is scope a footgun? A: setUp answers “what happens before every test in this class?” — one blob, shared by inheritance. A fixture answers “what does this test need?”: the test declares needs as parameters and pytest injects them by name. That is dependency injection, and it makes setup composable — fixtures request fixtures, so setup is a graph and each test pulls only its subgraph. yield puts teardown five lines from setup and runs it even on failure. Scope (function default → classmodulepackagesession) controls how often it is built; fixtures set up outside-in, tear down inside-out, and may only request equal-or-wider scope (else ScopeMismatch). The footgun: widening scope trades isolation for speed. A module-scoped fixture returning a list hands the same object to every test, so the first test’s mutation breaks the second — the classic “passes alone, fails together.”

Q: I get ModuleNotFoundError: No module named 'myapp' under pytest, but python -c "import myapp" works from the project root. What is happening? A: It is an import/packaging problem, not a test problem — note zero tests ran; collection failed. python (and python -m pytest) adds the cwd to sys.path; bare pytest does not, and rootdir does not affect sys.path at all — it is for config resolution only. Your import was working by accident of where you stood. Bad fixes: sys.path.append in a test, a path-mangling conftest.py, PYTHONPATH=src. The right fix is src/ layout + pyproject.toml + pip install -e . in an active venv. src/ matters because it makes the accident impossible: with code at the repo root, the cwd can always silently rescue you and you never learn your packaging is broken — until a user pip-installs a wheel with a module missing.

Q: Where do you patch, and why? Give the rule and the mechanism. A: Patch where the name is used, not where it is defined. If pricing.py does from shopcalc.clock import now, patch shopcalc.pricing.now. The mechanism: from X import Y copies the reference into the importer’s namespace at import time. Afterwards shopcalc.pricing.now is shopcalc.clock.now is True (same object) but they are two separate names; rebinding one leaves the other pointing at the original, so the mock is installed and silently never consulted — the real clock or real network runs. The corollary gives the alternative: import shopcalc.clock + clock.now() resolves the attribute at call time, so there you patch shopcalc.clock.now. The nastiest part is that a wrong target is flaky, not failing — the wrong clock patch fails at 19:40 and passes at 17:00, so it survives review. Defend with assert isinstance(mod.name, Mock) after patching, and an autouse fixture that blocks sockets.

Q: Compare Mock, MagicMock, spec and autospec — and what does “don’t mock what you don’t own” mean? A: Mock() auto-creates any attribute you touch and accepts any call — always truthy, never says no. MagicMock() adds configured dunders (__len__, __iter__, __enter__), which is why patch() returns one. Mock(spec=Thing) restricts attributes but still ignores signatures. create_autospec(Thing) / patch(..., autospec=True) builds from the real signature, so spec("USD", "extra", nonsense=1) raises TypeError: too many positional arguments. It matters because the deepest mocking failure is a double that agrees with a call the real function would reject: green test, TypeError in production. (Folklore update: modern mock does catch assert_called_onse_with and a deny-list like called_once_with, but m.verify_called() is still a silently truthy Mock — it is a blocklist, not a principle.) “Don’t mock what you don’t own”: patching requests.get encodes your beliefs about a library into your suite; when they are wrong, tests stay green and production breaks. Wrap third-party APIs in a thin adapter you own, mock the adapter, and let exactly one integration test touch the real thing.

Q: What does 90% coverage tell you? What does 100% tell you? A: 90% tells you which lines never executed — genuinely useful, and the Missing column is the only part worth reading. 100% tells you every line ran. Neither tells you the code is correct. Coverage cannot see missing cases, wrong expected values, unasserted returns, or an off-by-one on a boundary it happily executed — a test that calls a function and asserts nothing yields 100% coverage of it. In this lesson pricing.py hits 100% line and branch while is_happy_hour still says 18:30 is happy hour; a boundary table found it, not a tool. Read gaps with judgement: clock.py and rates.py are uncovered by design, while the two pricing.py gaps were real. Use --cov-branch always, set a floor in CI (--cov-fail-under=85) and never a 100% target — that produces tests written to touch lines. To judge your assertions, use mutation testing.

Q (coding): This test is green and worthless. Find every reason.

@patch("shopcalc.rates.fetch_rate")
def test_conversion(mock_fetch):
    mock_fetch.return_value = 0.012
    result = order_total_in([(2, 62.00)], "USD")
    assert result

A: Five faults. (1) Wrong patch targetpricing.py does from shopcalc.rates import fetch_rate, so patch shopcalc.pricing.fetch_rate; as written the mock is unused and the test makes a real HTTP call. (2) assert result asserts only truthiness — any non-zero Decimal passes, so a totally wrong total is green; assert the value. (3) Floats for money0.012 and 62.00 should be Decimal; this is a pricing engine. (4) The clock is unpatched, so is_happy_hour() reads the wall clock and the result changes by time of day — flaky. (5) No autospec, so the mock would accept any signature. Fixed:

def test_order_total_in_usd(items, at_hour, monkeypatch):
    at_hour(10)                                                    # deterministic
    monkeypatch.setattr("shopcalc.pricing.fetch_rate",             # patch where USED
                        lambda cur: Decimal("0.012"))
    assert order_total_in(items, "USD") == Decimal("5.81")         # assert the value

Q (coding): Turn this into a parametrized test, and say why it is better.

def test_discounts():
    assert apply_discount(Decimal("1000.00"), None) == Decimal("1000.00")
    assert apply_discount(Decimal("1000.00"), "WELCOME10") == Decimal("900.00")
    assert apply_discount(Decimal("3000.00"), "BIGSPEND") == Decimal("2400.00")

A:

@pytest.mark.parametrize(
    "amount, code, expected",
    [
        ("1000.00", None,        "1000.00"),
        ("1000.00", "WELCOME10", "900.00"),
        ("3000.00", "BIGSPEND",  "2400.00"),
        ("2000.00", "BIGSPEND",  "1600.00"),   # the boundary
    ],
    ids=["no-code", "welcome10", "bigspend-eligible", "bigspend-at-threshold"],
)
def test_apply_discount(amount, code, expected):
    assert apply_discount(Decimal(amount), code) == Decimal(expected)

Better for three reasons. Four independent tests, not one: the original stops at the first failing assert, so if None breaks you never learn whether WELCOME10 also broke. Each gets a node IDtest_apply_discount[bigspend-at-threshold] names the broken case in the summary and can be rerun alone by pasting it back. Adding a case is one line, which is what makes you actually add the boundary cases where the bugs live. Use ids= or you get [1000.00-WELCOME10-900.00], or worse [amount0] for objects.

Q (coding): Two tests pass individually and fail when run together. Diagnose without seeing them. A: Shared mutable state — one test mutated something the other assumed was pristine. Usual sources, in order of likelihood: (1) a module/session/class-scoped fixture returning a mutable object built once and handed to both — the mutable-default bug relocated into a fixture; (2) a module-level global or class attribute in the code under test; (3) a patch applied with bare setattr instead of monkeypatch, so it was never undone; (4) real files written to the cwd instead of tmp_path; (5) a cache or singleton (functools.lru_cache) surviving between tests. Confirm by running each alone, then together, then in reverse order. Fixes: function scope by default; if a wide scope is genuinely required, return an immutable value or reset it in the yield teardown; always monkeypatch over setattr; always tmp_path over the cwd; and add pytest-randomly so order dependencies fail loudly and early.


Key takeaways


Testing is where the other lessons cash out. Exceptions become a contract you can assert on with pytest.raises(..., match=...); the frozen dataclasses from domain models are trivially testable precisely because they have no hidden state; virtual environments are what make a green suite mean the same thing on your laptop and in CI; and project structure is why import shopcalc works at all. The suite you just built is not long — twenty-one tests, twenty milliseconds — and it is the difference between changing that pricing engine on a Friday afternoon and not.

pythontestingpytestunittestfixturesmockingmonkeypatchcoverageparametrizetddtest-doublesconftestassertionsquality
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments