5.2. Unit Testing#
Learning goals: By the end of this section you will be able to:
Explain what a unit test is and how it differs from integration and end-to-end tests
Write test functions with plain
assertand run them withpytest, in a notebook and from the command lineCheck that code raises the expected exception with
pytest.raisesWrite class-based tests with
unittest.TestCase, includingsetUpandtearDownEmbed examples in docstrings and run them as doctests
Replace external dependencies in tests with
unittest.mock.patchandmonkeypatchRun one test over many inputs with
@pytest.mark.parametrizeRead a test coverage report and use it to find untested code
In 5.1 you wrote code to handle errors at runtime. Unit tests are how you verify that code actually works.
“Unit” means the smallest testable piece of code, typically a single function or method. The idea is to test each piece in isolation, independent of the rest of the system.
Testing can be seen as a spectrum:
Type |
Scope |
|---|---|
Unit test |
Single function or method |
Integration test |
Multiple units working together |
End-to-end test |
Entire application flow |
As you can see, unit testing is the foundation: if every unit works correctly in isolation, you have much more confidence that the whole system will work when assembled.
Unit testing is pre-ship verification: it runs during development to confirm your code behaves correctly before anyone uses it. It doesn’t run in production at all. Unit testing answers: “does my code actually do what I think it does?”
The whole idea of unit testing is a design and verification technique: you write tests that define expected behavior, then run them to confirm your code is correct. You do this before you submit it, share it, or build more code on top of it.
Test-driven development (TDD) takes this further: you write the tests first, then write the code that makes them pass. Many developers instead write tests alongside the code or right after it, and that works well too.
Key benefits:
Catch bugs before users do
Prevent regressions when you change code
Tests serve as runnable documentation
Python has three main testing tools:
Tool |
Style |
Best for |
|---|---|---|
|
Plain |
New projects; industry standard |
|
Class-based ( |
Legacy codebases; built into the standard library |
|
Examples embedded in docstrings |
Illustrating function behavior in docstrings |
We’ll cover them in that order, starting with the one you’re most likely to use professionally.
5.2.1. pytest#
pytest is the most widely used testing framework in Python. It requires no boilerplate classes. In a typical project structure, to use pytest for testing, you prepare two files:
Your actual code (e.g.,
calc.py)Your test functions (e.g.,
test_calc.py)
You
write plain functions that start with
test_so thatpytestcan discover the function as a conventionuse plain
assertstatements, andrun everything with
pyteston the command line.
Install
The pytest module needs to be installed in the environment. For that, use pip in the terminal with the virtual environment enabled.
pip install pytest
5.2.1.1. Inline Assertion Checks#
Before running pytest, let us take a look at how assert can be used for testing. You should see the similarity between using asserts manually and the pytest framework.
Here we demonstrate the test structure using inline assertion checks:
### functions (would be calc.py in, e.g., the project root later)
def add(a, b):
return a + b
def subtract(a, b):
return a - b
### pytest-style test functions (would go in test_calc.py later)
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_subtract():
assert subtract(10, 4) == 6
# Run them manually (simulating pytest discovery)
for test_fn in [test_add_positive, test_add_negative, test_subtract]:
test_fn()
print(f'{test_fn.__name__} PASSED')
test_add_positive PASSED
test_add_negative PASSED
test_subtract PASSED
5.2.1.2. Test Files and the pytest Command#
In a real project, your code and your tests live in separate files. pytest looks for files named test_*.py (or *_test.py) and runs every function in them whose name starts with test_.
For example, suppose the module calc.py contains these functions:
### the code module file: calc.py
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def divide(a, b):
return a / b
The test file imports the functions it tests from the module. Note that you import calc, without the .py extension.
### the test file: test_calc.py
from calc import add, subtract, divide
def test_add():
assert add(2, 3) == 5
def test_subtract():
assert subtract(10, 4) == 6
def test_divide():
assert divide(10, 2) == 5.0
From the terminal, in the folder that contains both files, run
pytest test_calc.py -v
or, equivalently, python -m pytest test_calc.py -v. The output reports that 3 items were collected and that each test PASSED:
(.venv) [user]@[host]:~/workspace/py/[path]$ pytest test_calc.py -v
====================== test session starts ======================
platform darwin -- Python 3.13.7, pytest-9.0.3, pluggy-1.6.0 -- /Users/[user]/workspace/py/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/[user]/workspace/py/[path]
plugins: anyio-4.11.0
collected 3 items
test_calc.py::test_add PASSED [ 33%]
test_calc.py::test_subtract PASSED [ 66%]
test_calc.py::test_divide PASSED [100%]
======================= 3 passed in 0.01s =======================
(.venv) [user]@[host]:~/workspace/py/[path]$
5.2.1.3. pytest as Subprocess#
You can also run pytest from inside a Jupyter notebook by writing a test file to disk and running pytest as a subprocess:
Write a
.pytest file to a temporary folderRun
pyteston it with Python’ssubprocessmoduleCapture the output and display it in the notebook
One of the two tests below fails on purpose, so you can see how pytest reports a failure.
import subprocess, sys, tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
# 1. Write the test file to a temporary folder (deleted after the with block)
Path(tmp, "test_temp.py").write_text("""
def test_add():
assert 1 + 1 == 2
def test_fail():
assert 1 + 1 == 3
""")
# 2. Run pytest in that folder; --no-header omits machine-specific paths
result = subprocess.run(
[sys.executable, "-m", "pytest", "test_temp.py", "-v", "--no-header"],
cwd=tmp, capture_output=True, text=True,
)
# 3. Display the output in the notebook
print(result.stdout)
============================= test session starts ==============================
collecting ... collected 2 items
test_temp.py::test_add PASSED [ 50%]
test_temp.py::test_fail FAILED [100%]
=================================== FAILURES ===================================
__________________________________ test_fail ___________________________________
def test_fail():
> assert 1 + 1 == 3
E assert (1 + 1) == 3
test_temp.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_temp.py::test_fail - assert (1 + 1) == 3
========================= 1 failed, 1 passed in 0.03s ==========================
5.2.1.4. Jupyter Inline Testing#
In addition to running the pytest testing file in the terminal, you can also run the tests inside the Jupyter notebook using the ipytest plugin.
Note that %%ipytest does the discovering and calling of the test functions automatically within that cell, that’s why in the example below you don’t see the test functions being called. The workflow is:
User runs the cell with %%ipytest
ipytest scans the cell for any function whose name starts with test_
It calls all of them via
pytestunder the hoodReports pass/fail
### install ipytest for in-notebook testing
### live code may require you to install it again: ModuleNotFoundError: No module named 'ipytest'
# %pip install ipytest -q
import ipytest
ipytest.autoconfig()
%%ipytest
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
.
.
[100%]
2 passed in 0.00s
5.2.1.5. Testing Exceptions with pytest.raises#
In Raising Exceptions you wrote functions that raise an exception when they receive bad input. That behavior deserves a test too. Put the call inside a with pytest.raises(...) block: the test passes only if the block raises the expected exception, and fails if it raises nothing. Add match= to also check the error message.
%%ipytest
import pytest
def divide(a, b):
if b == 0:
raise ValueError("b cannot be zero")
return a / b
def test_divide():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
def test_divide_by_zero_message():
with pytest.raises(ValueError, match="cannot be zero"):
divide(10, 0)
.
.
.
[100%]
3 passed in 0.00s
### Exercise: Write pytest-style tests
# Write three test functions for `count_vowels(s)` defined below:
# 1. test_count_vowels_typical: a string with multiple vowels (e.g., 'hello')
# 2. test_count_vowels_none: a string with no vowels (e.g., 'gym')
# 3. test_count_vowels_empty: the empty string should return 0
# Each function should use plain `assert` (no `self`, no class).
# After writing them, call each one to verify they pass.
def count_vowels(s):
"""Return the number of vowels (a, e, i, o, u) in s (case-insensitive)."""
count = 0
for c in s.lower():
if c in 'aeiou':
count += 1
return count
### Your code starts here.
### Your code ends here.
test_count_vowels_typical PASSED
test_count_vowels_none PASSED
test_count_vowels_empty PASSED
5.2.2. unittest#
Python’s standard library includes unittest, a class-based testing framework. You’ll encounter it in older codebases and it’s worth knowing, but for new projects pytest is almost always the better choice.
Note
unittest asks you to put your tests inside a class. Classes are covered in Chapter 9; for now you only need to copy one pattern:
class TestSomething(unittest.TestCase):
def test_one_behavior(self):
self.assertEqual(actual, expected)
class TestSomething(unittest.TestCase):creates a group of tests. The name in parentheses tells Python that your class is a kind ofTestCase, so it gets all theassertX()methods.Each test is a function written inside the class (indented under it). A function that belongs to a class is called a method.
Every method takes
selfas its first parameter.selfis how a test reaches the helpers it inherited, as inself.assertEqual(...). You never passselfyourself;unittestsupplies it when it runs the test.
Chapter 9 explains what classes, methods, and self really are.
In the sample code below, part 2 is how you use unittest. You:
subclass
unittest.TestCase,write
test_*methods, anduse
self.assertX()helper methods for assertions.
The syntax of assertEqual() is self.assertEqual(actual, expected). For example, assertEqual(first, second) checks that first == second. If not, the test fails with a message showing both values.
In part 3, we start with import io because unittest.TextTestRunner writes its output to a file-like stream. By default that goes to stderr, which in some notebook environments doesn’t display inline with the cell output.
io.StringIO() is an in-memory text buffer that acts like a file; so the runner writes into it, and then print(buf.getvalue()) sends the captured text to the notebook’s normal output.
Without io to create buf, you’d pass stream=sys.stderr (the default), which may appear in a different output area or not at all depending on the environment.
import unittest
# Example: Testing a simple function with unit tests
### part 1: The code we want to test
def calculate_grade(score, total_points):
"""Calculate percentage grade from score and total points."""
if total_points <= 0:
raise ValueError("Total points must be positive")
if score < 0:
raise ValueError("Score cannot be negative")
if score > total_points:
raise ValueError("Score cannot exceed total points")
percentage = (score / total_points) * 100
return round(percentage, 2)
def get_letter_grade(percentage):
"""Convert percentage to letter grade."""
if percentage >= 90:
return 'A'
elif percentage >= 80:
return 'B'
elif percentage >= 70:
return 'C'
elif percentage >= 60:
return 'D'
else:
return 'F'
### part 2: Unit tests for our functions
class TestGradeCalculation(unittest.TestCase):
"""Test cases for grade calculation functions."""
def test_calculate_grade_normal_cases(self):
"""Test normal grade calculations."""
self.assertEqual(calculate_grade(85, 100), 85.0)
self.assertEqual(calculate_grade(95, 100), 95.0)
self.assertEqual(calculate_grade(50, 100), 50.0)
self.assertEqual(calculate_grade(0, 100), 0.0)
def test_calculate_grade_edge_cases(self):
"""Test edge cases for grade calculation."""
self.assertEqual(calculate_grade(100, 100), 100.0)
self.assertEqual(calculate_grade(87, 92), 94.57)
def test_calculate_grade_invalid_input(self):
"""Test that invalid inputs raise appropriate exceptions."""
with self.assertRaises(ValueError):
calculate_grade(85, 0) # Zero total points
with self.assertRaises(ValueError):
calculate_grade(-10, 100) # Negative score
with self.assertRaises(ValueError):
calculate_grade(110, 100) # Score exceeds total
def test_letter_grade_conversion(self):
"""Test letter grade assignments."""
self.assertEqual(get_letter_grade(95), 'A')
self.assertEqual(get_letter_grade(85), 'B')
self.assertEqual(get_letter_grade(75), 'C')
self.assertEqual(get_letter_grade(65), 'D')
self.assertEqual(get_letter_grade(55), 'F')
# Test boundary conditions
self.assertEqual(get_letter_grade(90), 'A')
self.assertEqual(get_letter_grade(89.9), 'B')
### part 3: Run the tests and display results
import io
buf = io.StringIO()
runner = unittest.TextTestRunner(stream=buf, verbosity=2)
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestGradeCalculation))
print(buf.getvalue())
if result.wasSuccessful():
print("All tests passed.")
test_calculate_grade_edge_cases (__main__.TestGradeCalculation.test_calculate_grade_edge_cases)
Test edge cases for grade calculation. ... ok
test_calculate_grade_invalid_input (__main__.TestGradeCalculation.test_calculate_grade_invalid_input)
Test that invalid inputs raise appropriate exceptions. ... ok
test_calculate_grade_normal_cases (__main__.TestGradeCalculation.test_calculate_grade_normal_cases)
Test normal grade calculations. ... ok
test_letter_grade_conversion (__main__.TestGradeCalculation.test_letter_grade_conversion)
Test letter grade assignments. ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK
All tests passed.
5.2.2.1. When Tests Fail#
The example above has tests that all pass. But finding failing tests is the whole point. Here’s a function with a deliberate bug. It refuses to raise a negative base to a power, returning a string instead of a number. The test suite will expose it.
import io
def my_power(base, exponent):
"""Return base raised to exponent. Bug: rejects negative bases."""
if base < 0:
return "Error: negative base" # ← bug: should just compute it
return base ** exponent
class TestPower(unittest.TestCase):
def test_positive_base(self):
self.assertEqual(my_power(2, 3), 8)
def test_negative_base_even_exponent(self):
self.assertEqual(my_power(-2, 2), 4) # (-2)² = 4, not an error string
def test_negative_base_odd_exponent(self):
self.assertEqual(my_power(-3, 3), -27)
# Run and print output so the failure is visible in the notebook
buf = io.StringIO()
runner = unittest.TextTestRunner(stream=buf, verbosity=2)
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestPower))
print(buf.getvalue())
if result.wasSuccessful():
print("All tests passed.")
else:
print(f"{len(result.failures)} failure(s), {len(result.errors)} error(s)")
test_negative_base_even_exponent (__main__.TestPower.test_negative_base_even_exponent) ... FAIL
test_negative_base_odd_exponent (__main__.TestPower.test_negative_base_odd_exponent) ... FAIL
test_positive_base (__main__.TestPower.test_positive_base) ... ok
======================================================================
FAIL: test_negative_base_even_exponent (__main__.TestPower.test_negative_base_even_exponent)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/tmp/ipykernel_2479/2752416876.py", line 14, in test_negative_base_even_exponent
self.assertEqual(my_power(-2, 2), 4) # (-2)² = 4, not an error string
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 'Error: negative base' != 4
======================================================================
FAIL: test_negative_base_odd_exponent (__main__.TestPower.test_negative_base_odd_exponent)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/tmp/ipykernel_2479/2752416876.py", line 17, in test_negative_base_odd_exponent
self.assertEqual(my_power(-3, 3), -27)
AssertionError: 'Error: negative base' != -27
----------------------------------------------------------------------
Ran 3 tests in 0.000s
FAILED (failures=2)
2 failure(s), 0 error(s)
5.2.2.2. setUp and tearDown#
When several tests need the same starting data, create it in a method named setUp. unittest calls setUp before each test method, so every test starts with a fresh copy, and a test that changes the data cannot affect the others. tearDown runs after each test, including one that fails. It is the place to release anything setUp created, such as an open file or a database connection.
class TestFruitBasket(unittest.TestCase):
def setUp(self):
# runs before each test: every test gets its own new basket
self.basket = ["apple", "banana"]
def tearDown(self):
# runs after each test: release what setUp created
self.basket = None
def test_add_fruit(self):
self.basket.append("cherry")
self.assertEqual(len(self.basket), 3)
def test_starts_with_two_fruits(self):
# passes even though test_add_fruit (which runs first) appended to its basket
self.assertEqual(len(self.basket), 2)
buf = io.StringIO()
runner = unittest.TextTestRunner(stream=buf, verbosity=2)
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestFruitBasket))
print(buf.getvalue())
test_add_fruit (__main__.TestFruitBasket.test_add_fruit) ... ok
test_starts_with_two_fruits (__main__.TestFruitBasket.test_starts_with_two_fruits) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
5.2.2.3. Comparing Floats with assertAlmostEqual#
assertEqual checks for exact equality, which is a problem for floating-point results: 0.1 + 0.2 is stored as 0.30000000000000004, not 0.3. self.assertAlmostEqual(first, second, places=n) passes when first and second agree to n decimal places (the default is 7). Use it whenever the value you are checking comes from floating-point arithmetic.
class TestFloats(unittest.TestCase):
def test_sum(self):
# assertEqual(0.1 + 0.2, 0.3) would fail; the values differ in the 17th digit
self.assertAlmostEqual(0.1 + 0.2, 0.3)
def test_two_places(self):
# 2 / 3 is 0.6666..., which rounds to 0.67 at two decimal places
self.assertAlmostEqual(2 / 3, 0.67, places=2)
buf = io.StringIO()
runner = unittest.TextTestRunner(stream=buf, verbosity=2)
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestFloats))
print(buf.getvalue())
test_sum (__main__.TestFloats.test_sum) ... ok
test_two_places (__main__.TestFloats.test_two_places) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
5.2.2.4. unittest Conventions#
Name test methods descriptively:
test_divide_by_zero_raises_exceptiontells you exactly what’s being checked;test_divisiondoesn’t.One behavior per test method: instead of one
test_grade_calculationthat checks everything, writetest_grade_returns_percentage,test_grade_raises_on_negative_score, etc. Narrow tests pinpoint exactly what broke.Use
setUpfor shared state: if multiple tests need the same object, create it insetUprather than repeating the construction in every method.
5.2.2.5. pytest vs unittest#
Now that you have seen both frameworks, the table below compares them across the features you’re most likely to care about.
Feature |
|
|
|---|---|---|
Requires class? |
Yes ( |
No |
Assertion style |
|
|
Discovery |
|
|
Output |
Minimal |
Detailed, colored diff |
Fixtures |
|
|
Ecosystem |
Standard library |
Third-party, widely adopted |
Both are valid. unittest is built-in; pytest is preferred in industry
because its plain assert style produces clearer failure messages.
Note
unittest assertions vs. bare assert
unittest’s assertX methods (assertEqual, assertRaises, assertIsNone, etc.) are ordinary method calls, so they cannot be disabled. Bare Python assert statements can be silently disabled by running the interpreter with the -O (optimize) flag, which strips them from the bytecode. In practice you never run your test suite with -O, so this rarely matters; but it is why assert is discouraged for input validation in production code while being perfectly fine inside test functions. pytest still gives detailed failure messages for plain assert through assertion rewriting: when it imports a test module, it rewrites each assert statement so that a failure reports the values involved.
### Exercise: Write a TestCase for to_celsius(f)
# to_celsius(f) converts Fahrenheit to Celsius: (f - 32) * 5 / 9
# Write a class TestToCelsius(unittest.TestCase) with three test methods:
# 1. test_boiling: to_celsius(212) should equal 100.0
# 2. test_freezing: to_celsius(32) should equal 0.0
# 3. test_body_temp: to_celsius(98.6) should be approximately 37.0
# (hint: use assertAlmostEqual with places=1)
# Run using TextTestRunner with io.StringIO and verbosity=2.
### Your code starts here.
### Your code ends here.
test_body_temp (__main__.TestToCelsius.test_body_temp) ... ok
test_boiling (__main__.TestToCelsius.test_boiling) ... ok
test_freezing (__main__.TestToCelsius.test_freezing) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
All tests passed.
5.2.3. Doctests#
A doctest is a test embedded directly in a function’s docstring. You write an example interaction using >>> (the Python interactive prompt), followed by the expected output. Python’s doctest module finds and runs these examples automatically.
Doctests serve two purposes at once: they document how a function works and verify that it actually works that way. They’re best for simple input/output examples, not a replacement for pytest or unittest when you need complex setup or many edge cases.
def uses_any(word, letters):
"""Checks if a word uses any of a list of letters.
>>> uses_any('banana', 'aeiou')
True
>>> uses_any('apple', 'xyz')
False
"""
for letter in word.lower():
if letter in letters.lower():
return True
return False
Each test begins with >>>, which is used as a prompt in some Python environments to indicate where the user can type code.
In a doctest, the prompt is followed by an expression, usually a function call.
The following line indicates the value the expression should have if the function works correctly.
In the first example, 'banana' uses 'a', so the result should be True.
In the second example, 'apple' does not use any of 'xyz', so the result should be False.
Outside a notebook, you would usually run doctests from the command line with python -m doctest file.py -v, or let pytest collect them with pytest --doctest-modules.
In a notebook, we use a function from the doctest module called run_docstring_examples.
To make it easier to use, we can wrap it in a small function that takes a function object as an argument.
from doctest import run_docstring_examples
def run_doctests(func):
run_docstring_examples(func, globals(), name=func.__name__)
What are globals() and __name__?
globals() is a built-in that returns a dictionary of every name defined in the current scope: variables, functions, imports, everything. run_docstring_examples evaluates the doctest expressions inside that dictionary, so any function you’ve already defined (like uses_any) is available by name.
func.__name__ is a string attribute every function carries. It’s just the name you gave it when you wrote def. Passing it as name= makes error messages say uses_any instead of the placeholder NoName.
You don’t need to use either directly; the run_doctests wrapper handles them for you. They’ll reappear later when we cover namespaces and function objects.
Now we can test uses_any like this.
run_doctests(uses_any)
run_doctests finds the expressions in the docstring and evaluates them.
If the result is the expected value, the test passes.
Otherwise it fails.
If all tests pass, run_doctests displays no output. In that case, no news is good news.
To see what happens when a test fails, here’s an incorrect version of uses_any.
def uses_any_incorrect(word, letters):
"""Checks if a word uses any of a list of letters.
>>> uses_any_incorrect('banana', 'aeiou')
True
>>> uses_any_incorrect('apple', 'xyz')
False
"""
for letter in word.lower():
if letter in letters.lower():
return True
else:
return False
And here’s what happens when we test it.
run_doctests(uses_any_incorrect)
**********************************************************************
File "__main__", line 4, in uses_any_incorrect
Failed example:
uses_any_incorrect('banana', 'aeiou')
Expected:
True
Got:
False
The output includes the example that failed, the value the function was expected to produce, and the value the function actually produced.
If you are not sure why this test failed, you’ll have a chance to debug it as an exercise.
### Exercise: Fix the Failing Doctest
# `uses_any_incorrect` has a bug that causes one doctest to fail.
# 1. Trace through what happens when the function is called with ('banana', 'aeiou').
# 2. Write a corrected version called `uses_any_fixed`.
# 3. Include the same two doctest examples and run `run_doctests(uses_any_fixed)`.
### Your code starts here.
### Your code ends here.
5.2.4. More Testing Techniques#
The sections below cover techniques you’ll reach for on real projects: isolating code from external dependencies, running the same test logic across many inputs, and measuring how much of your code your tests actually exercise.
5.2.4.1. Mocking Dependencies#
Tests should run without network calls, database access, or file I/O. Mocking replaces a real object with a controlled stand-in so tests are fast, isolated, and deterministic.
unittest.mock.patch is the standard tool. It temporarily replaces the target for the duration of the with block and restores the original afterward.
The example below patches a simple function (get_price) so the test never makes a real network call:
from unittest.mock import patch
# Imagine this lives in a pricing module and makes a real network call.
def get_price(item):
"""Fetch item price from an external service."""
raise RuntimeError("This would make a real network call")
def apply_discount(item, discount=0.10):
"""Return the discounted price for an item."""
price = get_price(item)
return round(price * (1 - discount), 2)
# Patch get_price so apply_discount never touches the network.
with patch("__main__.get_price", return_value=100.0) as mock_get:
result = apply_discount("widget")
assert result == 90.0
print(f"apply_discount('widget') = {result} ✓")
print(f"get_price was called with: {mock_get.call_args}")
# Outside the 'with' block, get_price is restored to the original.
print("\nget_price is restored; calling it now would raise RuntimeError")
apply_discount('widget') = 90.0 ✓
get_price was called with: call('widget')
get_price is restored; calling it now would raise RuntimeError
The mock that patch hands you (mock_get above) also records every call made to it, so a test can check how the code used its dependency. mock_get.assert_called_once_with(...) passes if the mock was called exactly once with exactly those arguments, and raises AssertionError otherwise:
with patch("__main__.get_price", return_value=100.0) as mock_get:
apply_discount("widget")
mock_get.assert_called_once_with("widget") # passes: one call, argument "widget"
print("get_price was called once with 'widget'")
try:
mock_get.assert_called_once_with("gadget") # wrong argument
except AssertionError as e:
print("AssertionError:", e)
get_price was called once with 'widget'
AssertionError: expected call not found.
Expected: get_price('gadget')
Actual: get_price('widget')
pytest has its own tool for this, the monkeypatch fixture. A fixture is an object that pytest passes to a test function automatically when the function names it as a parameter. monkeypatch.setattr replaces a function for one test, and pytest restores the original when the test ends:
%%ipytest
def fake_get_price(item):
return 100.0
def test_apply_discount(monkeypatch):
monkeypatch.setattr("__main__.get_price", fake_get_price)
assert apply_discount("widget") == 90.0
.
[100%]
1 passed in 0.00s
Note
Patch the name where it is looked up
In this notebook everything lives in one module, __main__, so the target is __main__.get_price. In a project, patch the name in the module that uses it, which is not always the module that defines it. Suppose get_price is defined in pricing.py:
If
shop.pydoesfrom pricing import get_price, thenshophas its own reference to the function. Patchshop.get_price; patchingpricing.get_pricewould leaveshopcalling the original.If
shop.pydoesimport pricingand callspricing.get_price(...), the name is looked up onpricingat call time, sopricing.get_priceis the right target.
The same rule applies to unittest.mock.patch and monkeypatch.setattr.
Patching is not the only way to use a fake. If you can change the function’s design, pass the dependency in as an argument instead. The function then works with whatever lookup it is given: the real one in production, and a fake (a simple stand-in that returns canned data) in a test. This design is called dependency injection. Nothing is replaced, so there is nothing to restore after the test.
def apply_discount_with(item, get_price):
"""Apply a 10% discount to the price that get_price returns."""
return get_price(item) * 0.9
def fake_get_price(item):
return 100.0
assert apply_discount_with("widget", fake_get_price) == 90.0
print(apply_discount_with("widget", fake_get_price)) # 90.0
90.0
Use patch (or monkeypatch) when you cannot change how the code gets its dependency, for example when it calls a library function directly. Use an injected fake when the function takes the dependency as a parameter: the test simply passes the fake in.
Advanced: mocking context managers
When the code under test uses a with statement (e.g., with urllib.request.urlopen(url) as resp:), the mock must also support the context-manager protocol (__enter__ / __exit__). MagicMock does this automatically:
from unittest.mock import patch, MagicMock
import json
def fetch_json(url):
import urllib.request
with urllib.request.urlopen(url) as resp:
return json.loads(resp.read())
with patch("urllib.request.urlopen") as mock_open:
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
mock_resp.read.return_value = b'{"status": "ok"}'
mock_open.return_value = mock_resp
data = fetch_json("https://example.com/api")
assert data == {"status": "ok"}
The key lines are mock_resp.__enter__.return_value = mock_resp (so as resp binds to the mock) and mock_resp.__exit__.return_value = False (so exceptions are not suppressed).
### Exercise: Mock a database lookup
# get_user(user_id) is supposed to look up a user in a database.
# format_greeting(user_id) calls get_user() and returns 'Hello, {name}!'
# Using patch, mock get_user so format_greeting never touches a real database:
# - patch get_user to return {'name': 'Alice'}
# - assert format_greeting(1) == 'Hello, Alice!'
# - assert get_user was called exactly once with argument 1
# (hint: mock_get.assert_called_once_with(1))
### Your code starts here.
### Your code ends here.
format_greeting(1) = 'Hello, Alice!' ✓
get_user called with: call(1)
5.2.4.2. Parametrized Tests#
Writing one test function per input case leads to repetitive code. @pytest.mark.parametrize runs the same test function once for each row of inputs. The @ line written directly above def is a decorator: here it attaches the list of parameter rows to the test function, so pytest knows to run that function once per row. You will learn how decorators work in 10.1 Functional Programming; for now, treat the line as a label you put on a test. Each row becomes a separate test case in the report, and a failing row shows the exact inputs that failed.
In the cell below, the -vv option tells ipytest to list every test by name, and --no-header hides machine-specific details.
%%ipytest -vv --no-header
import pytest
def is_palindrome(s):
return s == s[::-1]
@pytest.mark.parametrize("s, expected", [
("racecar", True),
("madam", True),
("hello", False),
("", True),
("a", True),
])
def test_is_palindrome(s, expected):
assert is_palindrome(s) == expected
======================================= test session starts ========================================
collecting ...
collected 5 items
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_palindrome[racecar-True]
PASSED [ 20%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_palindrome[madam-True]
PASSED [ 40%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_palindrome[hello-False]
PASSED [ 60%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_palindrome[-True]
PASSED [ 80%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_palindrome[a-True]
PASSED [100%]
======================================== 5 passed in 0.01s =========================================
Each row of the table ran as its own test, named after its inputs, such as test_is_palindrome[racecar-True]. The long t_...py name is a temporary file that ipytest creates for the cell.
### Exercise: Parametrize tests for is_leap_year(year)
# A year is a leap year if it is divisible by 4,
# EXCEPT century years (divisible by 100) are not,
# UNLESS they are also divisible by 400.
# Write a parametrized test `test_is_leap_year(year, expected)` with at least
# 5 (year, expected) rows:
# - divisible by 400 (e.g., 2000) → True
# - divisible by 100 but not 400 (e.g., 1900) → False
# - divisible by 4, not 100 (e.g., 2024) → True
# - not divisible by 4 (e.g., 2023) → False
# - one more case of your choice
# This cell can't start with %%ipytest, so it calls ipytest directly:
# ipytest.clean() removes tests left over from earlier cells, and
# ipytest.run() runs the tests defined in this cell.
import ipytest
import pytest
def is_leap_year(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
ipytest.clean()
### Your code starts here.
### Your code ends here.
exit_code = ipytest.run("-vv", "--no-header")
======================================= test session starts ========================================
collecting ...
collected 0 items
====================================== no tests ran in 0.00s =======================================
======================================= test session starts ========================================
collecting ...
collected 5 items
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_leap_year[2000-True]
PASSED [ 20%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_leap_year[1900-False]
PASSED [ 40%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_leap_year[2024-True]
PASSED [ 60%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_leap_year[2023-False]
PASSED [ 80%]
t_a07f0c86c42e410f8a65b488d78fb7f6.py::test_is_leap_year[1600-True]
PASSED [100%]
======================================== 5 passed in 0.01s =========================================
5.2.4.3. Test Coverage Basics#
Test coverage measures what fraction of your source code is exercised by your test suite. A line is “covered” if at least one test causes it to run.
The standard tool is coverage.py. With pytest, you usually run it through pytest-cov, the pytest plugin for coverage.py:
# Install (once)
pip install pytest-cov
# Run tests with a coverage report that lists the missed lines
pytest --cov=my_module --cov-report=term-missing
# Also measure branch coverage: was each side of every if/else taken?
pytest --cov=my_module --cov-branch --cov-report=term-missing
Coverage % |
Interpretation |
|---|---|
< 50% |
Probably missing whole branches or functions |
50–80% |
Reasonable for scripts; low for libraries |
80–90% |
Good target for most production code |
> 90% |
High confidence; watch for diminishing returns |
Key insight: 100% coverage does not mean your code is correct. It means every line ran, not that every case was tested correctly. Focus on covering branches (if/else, try/except), not just lines; --cov-branch reports the branches your tests never took.
# Generate an HTML report to see exactly which lines are missed
pytest --cov=my_module --cov-report=html
open htmlcov/index.html # macOS; use 'start' on Windows or 'xdg-open' on Linux
# Or just open htmlcov/index.html directly in your browser.