5.2. Unit Testing#

Hide code cell source

import sys
from pathlib import Path

current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent  # ← Add project root, not chapters
        break
else:
    project_root = Path.cwd().parent.parent

sys.path.insert(0, str(project_root))

from shared import thinkpython, diagram, jupyturtle
from shared.download import download

# Register as top-level modules so direct imports work in subsequent cells
sys.modules['thinkpython'] = thinkpython
sys.modules['diagram'] = diagram
sys.modules['jupyturtle'] = jupyturtle

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 assert and run them with pytest, in a notebook and from the command line

  • Check that code raises the expected exception with pytest.raises

  • Write class-based tests with unittest.TestCase, including setUp and tearDown

  • Embed examples in docstrings and run them as doctests

  • Replace external dependencies in tests with unittest.mock.patch and monkeypatch

  • Run one test over many inputs with @pytest.mark.parametrize

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

pytest

Plain assert, no class needed

New projects; industry standard

unittest

Class-based (TestCase)

Legacy codebases; built into the standard library

doctest

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:

  1. Your actual code (e.g., calc.py)

  2. Your test functions (e.g., test_calc.py)

You

  • write plain functions that start with test_ so that pytest can discover the function as a convention

  • use plain assert statements, and

  • run everything with pytest on 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:

  1. Write a .py test file to a temporary folder

  2. Run pytest on it with Python’s subprocess module

  3. Capture 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:

  1. User runs the cell with %%ipytest

  2. ipytest scans the cell for any function whose name starts with test_

  3. It calls all of them via pytest under the hood

  4. Reports 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.

Hide code cell source

### Solution

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

def test_count_vowels_typical():
    assert count_vowels('hello') == 2
    assert count_vowels('AEIOU') == 5

def test_count_vowels_none():
    assert count_vowels('gym') == 0
    assert count_vowels('rhythm') == 0

def test_count_vowels_empty():
    assert count_vowels('') == 0

for fn in [test_count_vowels_typical, test_count_vowels_none, test_count_vowels_empty]:
    fn()
    print(f'{fn.__name__} PASSED')
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 of TestCase, so it gets all the assertX() 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 self as its first parameter. self is how a test reaches the helpers it inherited, as in self.assertEqual(...). You never pass self yourself; unittest supplies 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, and

  • use 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_exception tells you exactly what’s being checked; test_division doesn’t.

  • One behavior per test method: instead of one test_grade_calculation that checks everything, write test_grade_returns_percentage, test_grade_raises_on_negative_score, etc. Narrow tests pinpoint exactly what broke.

  • Use setUp for shared state: if multiple tests need the same object, create it in setUp rather 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

unittest

pytest

Requires class?

Yes (TestCase)

No

Assertion style

self.assertEqual(a, b)

assert a == b

Discovery

python -m unittest finds test*.py files automatically

pytest finds test_*.py and *_test.py files automatically

Output

Minimal

Detailed, colored diff

Fixtures

setUp/tearDown

@pytest.fixture

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.

Hide code cell source

### Solution
import io

def to_celsius(f):
    return (f - 32) * 5 / 9

class TestToCelsius(unittest.TestCase):
    def test_boiling(self):
        self.assertEqual(to_celsius(212), 100.0)

    def test_freezing(self):
        self.assertEqual(to_celsius(32), 0.0)

    def test_body_temp(self):
        self.assertAlmostEqual(to_celsius(98.6), 37.0, places=1)

buf = io.StringIO()
runner = unittest.TextTestRunner(stream=buf, verbosity=2)
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestToCelsius))
print(buf.getvalue())
if result.wasSuccessful():
    print('All tests passed.')
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__)

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.

Hide code cell source

### Solution
def uses_any_fixed(word, letters):
    """Checks if a word uses any of a list of letters.

    >>> uses_any_fixed('banana', 'aeiou')
    True
    >>> uses_any_fixed('apple', 'xyz')
    False
    """
    for letter in word.lower():
        if letter in letters.lower():
            return True
    return False  # moved outside the loop: this was the bug in uses_any_incorrect

run_doctests(uses_any_fixed)

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.py does from pricing import get_price, then shop has its own reference to the function. Patch shop.get_price; patching pricing.get_price would leave shop calling the original.

  • If shop.py does import pricing and calls pricing.get_price(...), the name is looked up on pricing at call time, so pricing.get_price is 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.

### 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.

Hide code cell source

### Solution
from unittest.mock import patch

def get_user(user_id):
    raise RuntimeError('This would query a real database')

def format_greeting(user_id):
    user = get_user(user_id)
    return f"Hello, {user['name']}!"

with patch('__main__.get_user', return_value={'name': 'Alice'}) as mock_get:
    result = format_greeting(1)
    assert result == 'Hello, Alice!', f'Got: {result}'
    mock_get.assert_called_once_with(1)
    print(f'format_greeting(1) = {result!r}  ✓')
    print(f'get_user called with: {mock_get.call_args}')
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 =======================================

Hide code cell source

### Solution
import ipytest
import pytest

def is_leap_year(year):
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

ipytest.clean()

@pytest.mark.parametrize("year, expected", [
    (2000, True),   # divisible by 400: leap
    (1900, False),  # divisible by 100 but not 400: not leap
    (2024, True),   # divisible by 4, not 100: leap
    (2023, False),  # not divisible by 4: not leap
    (1600, True),   # divisible by 400: leap
])
def test_is_leap_year(year, expected):
    assert is_leap_year(year) == expected

exit_code = ipytest.run("-vv", "--no-header")
======================================= 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.