Advanced

Testing in Python 3

This module covers the concepts and vocabulary of unit testing in Python, illustrated with the unittest module included in the standard Python distribution.

Main tool: Python 3 + pytest


Table of Contents

  1. Course Overview
  2. Unit test vocabulary and design
  1. Using pytest
  1. Developer testing: why and when
  1. Using test doubles
  1. Improve test coverage and maintainability
  1. Difficult-to-test code

1. Course Overview

Emily Bache is a technical coach at Bache Consulting. This course introduces favorite testing techniques, including:

  • test-driven development (TDD)
  • approval testing
  • Strategies for testing code with hard-to-isolate dependencies

What you will learn

  • How to design unit tests that are useful and maintainable in the long term
  • How to use pytest, a popular and flexible testing tool
  • When and how to use mocks and other types of test doubles
  • Useful techniques like parameterized testing and monkey patching

Prerequisites

You should be familiar with:

  • Writing Python programs using standard library modules
  • Designing your own classes and functions

2. Unit test vocabulary and design

2.1 Introduction and versions used

This module covers the concepts and vocabulary of unit testing in Python, illustrated with the unittest module included in the standard Python distribution.

Topics covered:

  • Basic syntax and execution of test cases
  • Automated Unit Testing Vocabulary
  • Test case design (code structure)
  • The unittest module of the standard library

Definition of unit test: Strictly speaking, a unit test should not use the file system, a database, the network, or any other slow external resource. By testing small units that run in memory, we get simple, very fast tests that still prove something useful.


2.2 Demo: Write a test with unittest

Background — The Phonebook problem:

Given a list of names and phone numbers, create a phone book (a catalog for looking up a person’s number by name). The tricky part is determining if a directory is consistent: a directory is consistent if no number is the prefix of any other number.

Example of inconsistency:

  • Bob: 911
  • Emergency: 911212

Here, 911 is a prefix of 911212, so the directory is inconsistent.

Naming convention

The test framework is looking for files with the test_ prefix. Test classes inherit from unittest.TestCase, and each test method starts with test_.

Production file: phonebook.py

class Phonebook:
    def __init__(self):
        self.numbers = {}

    def add(self, name, number):
        self.numbers[name] = number

    def lookup(self, name):
        return self.numbers[name]

    def is_consistent(self):
        for name1, number1 in self.numbers.items():
            for name2, number2 in self.numbers.items():
                if name1 == name2:
                    continue
                if number1.startswith(number2):
                    return False
        return True

Running with the command line runner

python -m unittest

The runner automatically discovers all test cases. It displays a succinct summary: Ran 1 test in 0.000s — OK.

First test (placeholder)

import unittest

class PhonebookTest(unittest.TestCase):
    def test_placeholder(self):
        pass

Name search test

def test_lookup_by_name(self):
    phonebook = Phonebook()
    phonebook.add("Bob", "12345")
    number = phonebook.lookup("Bob")
    self.assertEqual(number, "12345")

Interpretation of a failure: When the test fails (before implementation), the framework clearly indicates:

  • The name of the test
  • A stack trace pointing to the failed assertion
  • The error message: “AssertionError: None != ‘12345’”

Missing name test

def test_missing_name(self):
    phonebook = Phonebook()
    with self.assertRaises(KeyError):
        phonebook.lookup("missing")

Using assertRaises as a context manager (with) ensures that lines inside the with block raise a KeyError. If not, the test fails.

Hint: If the lookup implementation uses dict.get(), it does not raise a KeyError. You must use direct access self.numbers[name].


2.3 Test suites and test runners

Key vocabulary:

TermDefinition
Test case (test case)An individual test that checks a unit of code. Must be independent, repeatable, and clearly report success or failure.
Test continuedA set of test cases executed together by a test runner.
Test runnerA program that runs the test cases and reports the results.

Two types of test runners:

  1. Command line: python -m unittest — automatic discovery of all tests
  2. Integrated IDE (e.g. PyCharm): graphical interface with results tree, useful during interactive development

Separation of concerns is important: a test case is designed independently of the test runner that will execute it.


2.4 Demo: Test Fixtures — setUp and tearDown

Observation: Each test starts with phonebook = Phonebook(). This duplication can be extracted in a test fixture.

Using @unittest.skip to mark a job in progress

@unittest.skip("Work In Process")
def test_empty_phonebook_is_consistent(self):
    is_consistent = self.phonebook.is_consistent()
    self.assertTrue(is_consistent)

setUp and tearDown

class PhonebookTest(unittest.TestCase):

    def setUp(self) -> None:
        self.phonebook = Phonebook()

    def tearDown(self) -> None:
        pass
  • setUp: called before each test method to initialize resources
  • tearDown: called after each test method to free resources (DB connections, temporary files, etc.)

2.5 Fixture execution order

A test fixture is support code that creates, configures, and cleans up the resources needed for test cases.

Execution order for each test:

setUp → méthode_de_test → tearDown

Failure behavior:

  • If an assertion fails or an exception is thrown in the testtearDown is still called (to free the resources allocated in setUp)
  • If an exception is thrown in setUp → the test fails, the test method and tearDown are not called (because setUp could not allocate resources)

Vocabulary summary:

TermDefinition
Unit under testThe unit of code tested (class, method, function)
Test fixtureSupport code to initialize/clean resources
Test caseAn individual test with arrange, act, assert
Test continuedSet of tests run together
Test runnerProgram that runs the tests and reports the results

2.6 Demo: Test design — naming

Poor design: A single large test that covers all is_consistent scenarios:

def test_is_consistent(self):
    self.phonebook.add("Bob", "12345")
    self.assertTrue(self.phonebook.is_consistent())
    self.phonebook.add("Anna", "01234")
    self.assertTrue(self.phonebook.is_consistent())
    self.phonebook.add("Sue", "12345")  # identique à Bob
    self.assertFalse(self.phonebook.is_consistent())
    self.phonebook.add("Sue", "123")    # préfixe de Bob
    self.assertFalse(self.phonebook.is_consistent())

Problem: If this test fails in the future, insufficient information was provided. It is not immediately clear which scenario failed. The test name (test_is_consistent) is not descriptive enough.


2.7 Demo: Arrange – Act – Assert

Good design: Replace the large test with several smaller tests, each covering a single scenario.

Structure Arrange – Act – Assert:

  • Arrange: Set up the context (create objects, initialize data)
  • Act: Execute the action to test
  • Assert: Check expected result
def test_is_consistent_with_different_entries(self):
    self.phonebook.add("Bob", "12345")
    self.phonebook.add("Anna", "01234")
    self.assertTrue(self.phonebook.is_consistent())

def test_inconsistent_with_duplicate_entries(self):
    self.phonebook.add("Bob", "12345")
    self.phonebook.add("Sue", "12345")  # identique à Bob
    self.assertFalse(self.phonebook.is_consistent())

def test_inconsistent_with_duplicate_prefix(self):
    self.phonebook.add("Bob", "12345")
    self.phonebook.add("Sue", "123")    # préfixe de Bob
    self.assertFalse(self.phonebook.is_consistent())

Advantages:

  • The name of each test reflects the scenario tested
  • In case of failure, we immediately know which scenario is problematic
  • Each test is isolated and independent

Complete test file: test_phonebook.py (module 2)

import unittest

from phonebook import Phonebook

class PhonebookTest(unittest.TestCase):

    def setUp(self) -> None:
        self.phonebook = Phonebook()

    def tearDown(self) -> None:
        pass

    def test_lookup_by_name(self):
        self.phonebook.add("Bob", "12345")
        number = self.phonebook.lookup("Bob")
        self.assertEqual(number, "12345")

    def test_missing_name(self):
        with self.assertRaises(KeyError):
            self.phonebook.lookup("missing")

    def test_empty_phonebook_is_consistent(self):
        is_consistent = self.phonebook.is_consistent()
        self.assertTrue(is_consistent)

    def test_is_consistent_with_different_entries(self):
        self.phonebook.add("Bob", "12345")
        self.phonebook.add("Anna", "012345")
        self.assertTrue(self.phonebook.is_consistent())

    def test_inconsistent_with_duplicate_entries(self):
        self.phonebook.add("Bob", "12345")
        self.phonebook.add("Sue", "12345")
        self.assertFalse(self.phonebook.is_consistent())

    def test_inconsistent_with_duplicate_prefix(self):
        self.phonebook.add("Bob", "12345")
        self.phonebook.add("Sue", "123")
        self.assertFalse(self.phonebook.is_consistent())

2.8 Module summary

  • We went through the basic concepts and vocabulary of testing with the unittest module
  • We designed test cases, executed with two different runners, and grouped tests into a test suite
  • We designed a test fixture for shared setup code
  • All of these design elements aim for separation of concerns, for flexible and reliable testing

Rules for good test design:

  1. The name of each test must reflect the scenario tested
  2. Each test follows the structure arrange – act – assert
  3. Prefer several small tests rather than one large test

3. Using pytest

3.1 Introduction to pytest

Historical context:

  • The unittest module is a port of JUnit (Java), created by Kent Beck and Erich Gamma in 1997
  • unittest was included in Python 2.1 in 2001
  • These ports are known as xUnit frameworks
  • Since then, Python has evolved, and pytest was created as a more native alternative to Python, notably by Holger Kregel

pytest: – Powerful and Popular Testing Framework

  • Does not belong to xUnit family
  • Not included in standard Python distribution
  • Installation:
pip install pytest

3.2 Demo: Writing tests with pytest

Syntactic differences compared to unittest:

Appearanceunittestpytest
Required classYes (class PhonebookTest(TestCase))No
Required inheritanceYes (unittest.TestCase)No
AssertionsSpecific methods (assertEqual, assertTrue)Standard Python keyword assert
Naming conventiontest_ (method in a class)test_ (function or method)

Minimum pytest example:

from phonebook import Phonebook

def test_lookup_by_name():
    phonebook = Phonebook()
    phonebook.add("Bob", "12345")
    assert "12345" == phonebook.lookup("Bob")

Execution:

python -m pytest

pytest failure message:

FAILED test_phonebook.py::test_lookup_by_name
AssertionError: assert None == '12345'

pytest displays:

  • The name of the test that failed
  • Expected and actual values
  • All test source code for context
  • Clear analysis indicating that the thing to the left of the == was None

3.3 Demo: Other types of assertions in pytest

Test on a set:

def test_phonebook_contains_all_names(phonebook):
    phonebook.add("Bob", "1234")
    assert "Bob" in phonebook.names()

Exception testing with pytest.raises:

import pytest

def test_missing_name_raises_error(phonebook):
    with pytest.raises(KeyError):
        phonebook.lookup("Bob")

Note: pytest.raises is similar to assertRaises from unittest, but uses standard Python context manager syntax.


3.4 Demo: Fixtures in pytest

Principle: In pytest, fixtures work by dependency injection. A test specifies the resource it needs in its argument list. pytest finds the function decorated as a fixture with the same name and injects it at runtime.

Creating a fixture:

import pytest

@pytest.fixture
def phonebook(tmpdir):
    """Provides an empty Phonebook"""
    return Phonebook(tmpdir)

Using a fixture:

def test_lookup_by_name(phonebook):
    phonebook.add("Bob", "1234")
    assert "1234" == phonebook.lookup("Bob")

Cleaning up resources with yield:

For the equivalent of tearDown, we use yield in the fixture:

@pytest.fixture
def phonebook(tmpdir):
    phonebook = Phonebook(tmpdir)
    yield phonebook
    phonebook.clear()  # code de nettoyage exécuté après le test

Advantage: pytest guarantees that the code after yield is executed even if the test fails (equivalent to tearDown).

Built-in tmpdir fix: pytest provides the tmpdir fixture which creates a unique temporary directory for each test, avoiding conflicts between tests.

Typo error in fixture name:

If the name in the test argument does not match the fixture name, pytest displays a clear error:

ERRORS
fixture 'phonbbok' not found
Available fixtures: phonebook, tmpdir, ...

3.5 Demo: Parameterized tests in pytest

Problem: All three scenarios of is_consistent have the same workflow (add two entries, check consistency). Duplication can be eliminated with parameterized tests.

Using @pytest.mark.parametrize:

@pytest.mark.parametrize("entry1,entry2,is_consistent", [
    (("Bob", "12345"), ("Anna", "01234"), True),
    (("Bob", "12345"), ("Sue", "12345"), False),
    (("Bob", "12345"), ("Sue", "123"), False),
])
def test_is_consistent(phonebook, entry1, entry2, is_consistent):
    phonebook.add(*entry1)
    phonebook.add(*entry2)
    assert phonebook.is_consistent() == is_consistent

Explanation:

  • The first argument to @pytest.mark.parametrize is a string describing the additional parameters
  • The second argument is a list of data tuples to use as parameters
  • pytest will run the test once for each data tuple

Advantages:

  • Tests remain isolated (each tuple is an independent test)
  • Less code duplication
  • Pytest displays each combination separately in the results

Test with skip for a job in progress:

@pytest.mark.skip("Work in process on new feature")
def test_some_new_feature():
    assert False  # test should fail!

3.6 Demo: Organizing test code into a larger project

Recommended project structure:

phonebook_pytest2/
├── pytest.ini
├── setup.py
├── src/
│   └── phonebook/
│       ├── __init__.py
│       ├── __main__.py
│       ├── cli.py
│       ├── data_processing.py
│       └── phonenumbers.py
└── test/
    ├── __init__.py
    ├── conftest.py
    ├── test_data_processing.py
    ├── test_large_books.py
    └── test_phonenumbers.py

pytest.ini file:

[pytest]
addopts = --strict-markers
markers =
    slow: Run tests that use sample data from file (deselect with '-m "not slow"')

conftest.py file:

This is a special file that pytest knows about. All tests in this folder and its subfolders will search for fixtures in this file. This is a good centralized location for fixtures.

"""Shared fixtures."""

import pytest

from phonebook.phonenumbers import Phonebook

@pytest.fixture
def phonebook(tmpdir):
    """Provides an empty Phonebook"""
    return Phonebook(tmpdir)

Tests with @pytest.mark.slow marker:

"""
Load tests with large collections of phone numbers
"""
import csv
import pytest

@pytest.mark.slow
def test_large_file(phonebook):
    with open("sample_data/sample1.csv") as f:
        csv_reader = csv.DictReader(f)
        for row in csv_reader:
            name = row["Name"]
            number = row["Phone Number"]
            phonebook.add(name, number)
    assert phonebook.is_consistent()

Control of tests to be executed:

# Exécuter tous les tests
python -m pytest

# Exclure les tests marqués comme "slow"
python -m pytest -m "not slow"

3.7 Module Summary

  • Definition of tests with pytest: simple syntax based on the test_ naming convention
  • Failure interpretation: pytest provides maximum information with minimum syntax
  • Avoid duplication:
  • Parameterized tests for different datasets
  • Fixtures for shared resources
  • Organization in a larger project: conftest.py, pytest.ini, custom markers

4. Developer testing: why and when

4.1 Module Introduction

This module answers the question: how to integrate test writing into your personal development process.

Topics covered:

  • Why developers should write tests
  • How to integrate testing into the development process
  • TDD vs testing after code
  • Continuous Integration (CI)

The two main reasons to write automated tests:

  1. Make sure the code does what you think — don’t ship code that doesn’t work
  2. Protect against future regressions — detect when things that used to work stop working

4.2 Demo: The Developer Feedback Loop

Context: Development of a program to score the Yatzi dice game.

Expanded functions

def dice_counts(dice):
    """
    Make a dictionary of how many of each value are in the dice
    """
    return {x: dice.count(x) for x in range(1, 6)}  # Bug : devrait être range(1, 7)

def full_house(dice):
    """
    Score the given roll in the 'Full House' category
    """
    counts = dice_counts(dice)
    if 2 in counts.values() and 3 in counts.values():
        return sum(dice)
    return 0

Different feedback approaches:

  1. Python Console: Import functions and call them manually — fast but not reproducible
  2. Debugger: Create a main program with a breakpoint — visual feedback but still not reproducible
  3. Unit testing: The best approach — reproducible, automatable, shareable

Bug discovered: dice_counts stops at 5 instead of 6 (bounds error in range).

Unit tests with parameterization

import pytest

from yatzi_engine import full_house

@pytest.mark.parametrize(
    "dice,expected_score",
    [
        ([1, 1, 2, 2, 2], 8),
        ([5, 5, 6, 6, 6], 28),
    ]
)
def test_full_house(dice, expected_score):
    assert full_house(dice) == expected_score

Note: doctest — if you like the Python console, look at the doctest module. It allows you to copy and paste from the Python interpreter into a function docstring and then run those examples in unit tests.


4.3 Demo: Regression Testing

A regression is when something that used to work no longer works. This happens when you, a colleague, or someone else changes the design or implements a new feature.

Properties of a good regression test suite:

  • Fail when there is a problem — report when something is broken
  • Stay quiet and green when everything is fine — do not make false alarms
  • Help you understand what went wrong — the failure message should point to the responsible unit

Example: On a complete Yatzi engine with all categories, when wanting to simplify the all_dice_values method by using range() instead of hardcoded values, two tests fail. They reveal that when there are two pairs available in a toss, the program now chooses the combination with the lowest score instead of the highest. This is exactly the kind of unanticipated impact that a good regression test suite should detect.


4.4 Post Testing vs Test-Driven Development

Test After process:

You write some code, maybe do some manual tests in the console or debugger until you’re happy, then you write the tests.

Advantages:

  • We only invest in testing once the design is relatively stable, thus avoiding unnecessary work

Risks:

  • The design may be poorly testable, requiring late refactoring
  • Bugs may be discovered late when writing tests
  • This process can degenerate into a frustrating spiral of debugging and refactoring when you thought you were done
  • If tests are left until last, there may not be enough time to write them well; important cases can be missed under the pressure of a deadline

Test-Driven Development (TDD):

TDD is a way to integrate test development into your personal way of working.

Note: Emily Bache is not talking here about the broader development process like Behavior-Driven Development (BDD) or Acceptance Test-Driven Development (ATDD), which involve other collaborators.

TDD cycle (Red – Green – Refactor):

  1. Red: Write a test that fails (for a feature that does not yet exist)
  2. Green: Write just enough production code to pass the test
  3. Refactor: Improve code design (tests remain green)

This iterative cycle forces you to think first about what the code should do (the interface), then how to do it (the implementation).


4.5 Demo: TDD in practice — FizzBuzz

FizzBuzz kata description: Write a program that displays numbers from 1 to 100. But for multiples of 3, print Fizz. For multiples of 5, display Buzz. For multiples of 3 and 5, display FizzBuzz.

Output examples: 1, 2, Fizz, 4, Buzz, Fizz, 7, ...

Step 1: Analysis and list of test cases to write

Before coding, write a list of test cases to implement:

  • fizzbuzz(1)"1"
  • fizzbuzz(2)"2"
  • fizzbuzz(3)"Fizz"
  • fizzbuzz(5)"Buzz"
  • fizzbuzz(15)"FizzBuzz"
  • print_fizzbuzz(100) → show up to 100

Illustrated TDD Cycle

1. Write the first test (which fails):

def test_fizzbuzz_normal_number():
    assert fizzbuzz(1) == "1"

2. Write the minimum amount of code to get it across:

def fizzbuzz(number):
    return str(number)

3. Continue with the following test:

def test_fizzbuzz_three_is_fizz():
    assert fizzbuzz(3) == "Fizz"

4. Implement to pass:

def fizzbuzz(number):
    if number % 3 == 0:
        return "Fizz"
    return str(number)

Final code of module fizzbuzz.py

def fizzbuzz(number):
    if number % 15 == 0:
        return "FizzBuzz"
    if number % 5 == 0:
        return "Buzz"
    if number % 3 == 0:
        return "Fizz"
    return str(number)

def print_fizzbuzz(highest_number):
    fizzbuzz_numbers = (fizzbuzz(i) for i in range(1, highest_number + 1))
    for n in fizzbuzz_numbers:
        print(n)

if __name__ == "__main__":
    print_fizzbuzz(100)

Complete tests

from fizzbuzz import fizzbuzz, print_fizzbuzz

def test_fizzbuzz_normal_number():
    assert fizzbuzz(1) == "1"
    assert fizzbuzz(2) == "2"

def test_fizzbuzz_three_is_fizz():
    assert fizzbuzz(3) == "Fizz"

def test_fizzbuzz_five_is_buzz():
    assert fizzbuzz(5) == "Buzz"

def test_fizzbuzz_fifteen_is_fizzbuzz():
    assert fizzbuzz(15) == "FizzBuzz"

def test_print_fizzbuzz(capsys):
    print_fizzbuzz(3)
    captured_stdout = capsys.readouterr().out
    assert captured_stdout == "1\n2\nFizz\n"

pytest capsys fix: This integrated fixture allows you to spy on the contents of standard output (stdout) and error output (stderr). We use it to test code that uses print().


4.6 TDD influences your design

Advantages of TDD on design:

  1. Forces thinking about testable units: The first step in TDD is to think about the units to test. Small, loosely coupled, high cohesion units are easier to test → this is also a sign of better design.

  2. Interface before implementation: Tests exercise the unit interface (method calls, parameters, return values). This forces you to think about the interface first, then the implementation → best interface.

  3. Frequent refactoring opportunities: Every time the tests are green (every few minutes), you can refactor, improve the code, reorganize, improve readability. It’s an incredible freedom.

TDD vs non-TDD comparison for FizzBuzz:

ApproachResult
With TDDTwo separate functions: fizzbuzz() (logic) + print_fizzbuzz() (display)
Without TDDA single large unit combining logic and display

Both work, but the TDD version has smaller, testable units. The conversion logic is separate from the display logic.

Emily Bache: “I do test-driven development, and I wouldn’t want to work any other way.”


4.7 Continuous integration

Tests are a shared resource for all developers working on the same code. When you have to modify unfamiliar code and you find tests:

  • They help understand what the code is doing (living documentation)
  • They allow you to see if the new code breaks something (protection against regressions)

Version controlled workflow:

pull (récupérer les derniers changements)
  ↓
run tests (vérifier que tout passe)
  ↓
développer (code + tests)
  ↓
run tests (vérifier avant de partager)
  ↓
push (partager les changements)

CI Serverless Issue: Developers sometimes forget to run tests before pushing, which introduces broken tests into the shared repository. If no one notices or takes action to correct the problem, testing becomes worse than useless.

Build Automation Server (CI Server):

  • Monitors changes in the team’s version control repository
  • When found, grab the most recent code and run all automated tests
  • In case of failure → alerts the team (emails, lamp, web page)
  • If successful → can deploy to manual testing environment

To do real continuous integration:

  • A CI server alone is not enough
  • An effective team makes small, frequent commits shared often
  • If everyone shares their changes at least daily → it’s continuous integration

4.8 Module Summary

  • Unit tests provide concrete benefits: rapid feedback, regression detection
  • TDD improves design and favors small, loosely coupled units
  • Continuous integration (CI) is a good collaboration strategy made possible by good testing
  • Whichever method you choose, the main thing is to write automated tests with the code, share them with other developers, and make sure they stay green

5. Using test doubles

5.1 Module Introduction

A test double replaces a collaborator of the class, method or function being tested. The term comes from cinema: a stunt double replaces the main actor for dangerous scenes. The stunt double should look like the actor but beneath the surface it is different — completely configurable.

When to use a double test: When the unit to be tested has a dependency on other code and for some reason we do not want to use the real collaborator (too slow, difficult to build, network access, etc.).

Double test types (according to Gerard Meszaros, xUnit Test Patterns):

TypeFeatures
DummyUsually None or empty collection. Passed but never actually used.
StubResponds with fixed pre-programmed responses. No proper logic.
FakeHas an implementation with logic, but not suitable for production.
SpyLike a stub, but logs received calls. May cause the test to fail after the fact.
MockLike a spy, but checks expectations at the time of the call. May cause the test to fail immediately.

Note: Many people use the term “mock” to refer to all types of test doubles. It is useful to know how to distinguish the different types.


5.2 Demo: Stub — tire pressure

Background: Race car computer system. The Alarm class uses a sensor to detect tire pressure. If the pressure is too low or too high, the alarm is activated.

Bug reported: The alarm does not trigger when the pressure is exactly 17.0 or 21.0.

Problem: The Sensor makes a network call to a real racing car with a physical sensor. This is a “troublesome” collaborator for a unit test.

Production code

# sensor.py
import random

class Sensor(object):
    _OFFSET = 16

    def sample_pressure(self):
        pressure_telemetry_value = self.simulate_pressure()
        return Sensor._OFFSET + pressure_telemetry_value

    @staticmethod
    def simulate_pressure():
        # simulates a real sensor in a real tire
        pressure_telemetry_value = 6 * random.random() * random.random()
        return pressure_telemetry_value
# alarm.py
from sensor import Sensor

class Alarm:

    def __init__(self, sensor=None):
        self._low_pressure_threshold = 17.0
        self._high_pressure_threshold = 21.0
        self._sensor = sensor or Sensor()
        self._is_alarm_on = False

    def check(self):
        pressure = self._sensor.sample_pressure()
        # bug: should be <= and >=
        if pressure < self._low_pressure_threshold \
                or pressure > self._high_pressure_threshold:
            self._is_alarm_on = True

    @property
    def is_alarm_on(self):
        return self._is_alarm_on

Design tip: The Alarm constructor accepts an optional sensor. This makes the class testable by allowing the injection of a test sensor.

Tests with Stub and Mock

from unittest.mock import Mock

from alarm import Alarm
from sensor import Sensor

def test_alarm_is_off_by_default():
    alarm = Alarm()
    assert not alarm.is_alarm_on

# Stub écrit manuellement
class StubSensor:
    def sample_pressure(self):
        return 17.0

def test_alarm_is_on_at_lower_threshold():
    alarm = Alarm(StubSensor())
    alarm.check()
    assert alarm.is_alarm_on

# Stub créé avec unittest.mock.Mock
def test_alarm_is_on_at_higher_threshold():
    stub = Mock(Sensor)
    stub.sample_pressure.return_value = 21.0
    alarm = Alarm(stub)
    alarm.check()
    assert alarm.is_alarm_on

Two ways to create a stub:

  1. Stub written manually (StubSensor): simple class with the same methods as the collaborator, returning fixed values
  2. Stub with unittest.mock.Mock: use Mock(Sensor) and set .return_value on the method to be stubbed

5.3 Demo: Fake — HTML converter

Context: HtmlPagesConverter class that converts text from a file to HTML. Access to the file system is relatively slow and complicated to stub (multiple methods: seek, tell, end of file detection).

Solution: Use a fakeStringIO from the Python standard library. It has the same methods as a file but works entirely in memory.

Fake vs Stub: A fake has an implementation with logic and behavior, but is not suitable for production (real files can be very large and must persist between program executions).

Production code

# html_pages.py
import html as html_converter

class HtmlPagesConverter:

    def __init__(self, open_file):
        self.open_file = open_file
        self._find_page_breaks()

    def _find_page_breaks(self):
        """Read the file and note the positions of the page breaks
        so we can access them quickly"""
        self.breaks = [0]
        while True:
            line = self.open_file.readline()
            if not line:
                break
            if "PAGE_BREAK" in line:
                self.breaks.append(self.open_file.tell())
        self.breaks.append(self.open_file.tell())

    def get_html_page(self, page):
        """Return html page with the given number (zero indexed)"""
        page_start = self.breaks[page]
        # bug: retrieves the wrong page, should be self.breaks[page+1]
        page_end = self.breaks[page + 1]
        html = ""
        self.open_file.seek(page_start)
        while self.open_file.tell() != page_end:
            line = self.open_file.readline()
            if "PAGE_BREAK" in line:
                continue
            line = line.rstrip()
            html += html_converter.escape(line, quote=False)
            html += "<br />"
        return html

Test with a Fake (StringIO)

# test_html_pages.py
import io

from html_pages import HtmlPagesConverter

def test_convert_second_page():
    fake_file = io.StringIO("""\
page one
PAGE_BREAK
page two
PAGE_BREAK
page three
""")
    converter = HtmlPagesConverter(fake_file)
    converted_text = converter.get_html_page(1)
    assert converted_text == "page two<br />"

io.StringIO is the fake that replaces the real file. It has the same methods (readline, seek, tell) but stores the data in memory.


5.4 Demo: Dummy — Configurable FizzBuzz

Context: The fizzbuzz function is extended to accept additional configurable rules.

Production code with additional rules

# fizzbuzz.py
def fizzbuzz(n, additional_rules):
    """
    Convert a number to it's name in the game FizzBuzz
    """
    answer = ""
    rules = {3: "Fizz", 5: "Buzz"}
    if additional_rules:
        rules.update(additional_rules)
    for divisor in sorted(rules.keys()):
        if n % divisor == 0:
            answer += rules[divisor]
    if not answer:
        answer = str(n)
    return answer

Problem: Existing tests now fail because fizzbuzz() requires a second argument.

Use of dummy: None

@pytest.mark.parametrize(
    "number,expected", [
        (2, "2"),
        (3, "Fizz"),
        (5, "Buzz"),
        (15, "FizzBuzz"),
    ]
)
def test_fizzbuzz(number, expected):
    assert fizzbuzz(number, None) == expected  # None est le dummy

Here None is the dummy — it is not used by the function in these test cases. We are forced to pass a collaborator (additional_rules) but this collaborator is not used.

Code smell: A dummy often reports that the method has too many required arguments. The best solution is to add a default value:

def fizzbuzz(n, additional_rules=None):
    ...

With the default, old tests no longer need to pass None explicitly, and the design is clearer.

Complete tests

@pytest.mark.parametrize(
    "number,additional_rules,expected", [
        (7, {7: "Whizz"}, "Whizz"),
    ]
)
def test_fizzbuzz_with_whizz_too(number, additional_rules, expected):
    assert fizzbuzz(number, additional_rules) == expected

5.5 Summary: Dummy, Stub, Fake

TypeDescriptionWhen to use it
DummyNone or empty collectionWhen we are forced to pass on a collaborator but he is not used
StubResponds with pre-programmed fixed values ​​When the collaborator is difficult to use (slow, network, etc.)
FakeImplementation with logic, unsuitable for productionWhen the employee has complex behavior that is difficult to stub

In terms of resemblance to the real collaborator: Dummy → Stub → Fake (from less to more similar)

Also applicable to functions: A double test can be used to test a function that takes another function as an argument. We stub or fake this collaborative function.


5.6 Demo: Spy — DiscountManager

Context: DiscountManager creates discounts on products. Its collaborator Notifier sends emails to users.

Bug reported in production: Some users receive a lot of messages about discounts, but most do not. We suspect a bug in DiscountManager.

Production code (with bug)

# discounts.py
from model_objects import DiscountData, Product, User

class DiscountManager:
    def __init__(self, notifier):
        self.notifier = notifier

    def create_discount(self,
                        product: Product,
                        discount_details: DiscountData,
                        users: list[User]
                        ) -> None:
        if users:
            key_user = users[0]
        else:
            raise RuntimeError("Can't create a discount for a product with no key user")

        product.add_discount(discount_details)
        for user in users:
            if user.has_previously_bought(product):
                # Bug! This should not be key_user, it should be 'user'
                self.notifier.notify(key_user,
                                     f"You may be interested in a discount on this product! {product.name}")

Data Models

# model_objects.py
from dataclasses import dataclass, field

@dataclass
class DiscountData:
    name: str

@dataclass
class Product:
    name: str
    discounts: list[DiscountData] = field(default_factory=list)

    def add_discount(self, discount_details):
        self.discounts.append(discount_details)

@dataclass
class User:
    name: str
    products: list[Product] = field(default_factory=list)

    def has_previously_bought(self, product):
        return product in self.products

Spy written manually

class SpyNotifier:
    def __init__(self):
        self.notified_users = []

    def notify(self, user, message):
        # ne pas envoyer de messages depuis le test unitaire, enregistrer quels utilisateurs sont notifiés
        self.notified_users.append(user)

def test_discount_for_users_with_spy():
    notifier = SpyNotifier()
    discount_manager = DiscountManager(notifier)
    product = Product("headphones")
    product.discounts = []
    discount_details = DiscountData("10% off")
    users = [User("user1", [product]), User("user2", [product])]

    discount_manager.create_discount(product, discount_details, users)

    assert product.discounts == [discount_details]
    assert users[0] in notifier.notified_users
    assert users[1] in notifier.notified_users

This test fails because of the bug (only user1 is notified, not user2). After fixing the bug, both assertions pass.

Spy with the mocking framework (unittest.mock)

from unittest.mock import Mock, call

def test_discount_for_users_with_mocking_framework_spy():
    notifier = Mock()
    discount_manager = DiscountManager(notifier)
    product = Product("headphones")
    product.discounts = []
    discount_details = DiscountData("10% off")
    users = [User("user1", [product]), User("user2", [product])]

    discount_manager.create_discount(product, discount_details, users)

    assert product.discounts == [discount_details]
    expected_calls = [
        call(users[0], f"You may be interested in a discount on this product! {product.name}"),
        call(users[1], f"You may be interested in a discount on this product! {product.name}"),
    ]
    notifier.notify.assert_has_calls(expected_calls)

Mock() of unittest.mock is a spy that automatically records all calls. The assert_has_calls method verifies that the expected calls took place.


5.7 Demo: Mock — DiscountManager

A mock is different from a spy: it sets expectations in advance on the calls it should receive. If these expectations are not met, it throws an error immediately.

Mock written manually

class MockNotifier:
    def __init__(self):
        self.expected_user_notifications = []

    def notify(self, user, message):
        # ne pas envoyer de messages, vérifier que toutes les notifications sont attendues
        expected_user = self.expected_user_notifications.pop(0)
        if user != expected_user:
            raise RuntimeError(
                f"got notification message for unexpected user {user.name}, "
                f"was expecting {expected_user.name} instead"
            )

    def expect_notification_to(self, user):
        self.expected_user_notifications.append(user)

def test_discount_for_users_with_mock():
    notifier = MockNotifier()
    discount_manager = DiscountManager(notifier)
    product = Product("headphones")
    product.discounts = []
    discount_details = DiscountData("10% off")
    users = [User("user1", [product]), User("user2", [product])]
    notifier.expect_notification_to(users[0])
    notifier.expect_notification_to(users[1])

    discount_manager.create_discount(product, discount_details, users)

    assert product.discounts == [discount_details]

With the bug: The mock raises RuntimeError during the second call to notify because it receives user1 while it was expecting user2. The stack trace points directly to the heart of the production code.


5.8 Spy vs Mock Comparison

AppearanceSpyMock
Time of assertionAfter the test (post-verification)During testing (immediate verification)
In case of bugIndicates what happened afterwardsExplodes immediately with a stack trace
SimplicityGenerally simpler to implementMore complex
Readability of the testMore directPrerequisite setup required

Two double test groups:

  1. Dummy, Stub, Fake → To replace a difficult collaborator to use in a test
  2. Spy, Mock → To verify a contract between the unit under test and its collaborator (check that particular method calls took place in the right order or with the right arguments)

The three types of assertions:

TypeDescriptionDoubles used
Return valueCheck the value returned by the functionAll
Change of stateCheck side effects (modified state)All
Method callVerify that a particular method was calledSpy, Mock

5.9 Module Summary

A test double replaces a collaborator of the class, method or function being tested. Different types are suitable for different situations. We generally use a double test when the real collaborator is difficult to use (slow, complicated to build). Warning: using test doubles to test poorly designed code can make things worse. We’ll talk about this in the module on difficult-to-test code.


6. Improve test coverage and maintainability

6.1 Module Introduction

Topics covered:

  • Approval testing: alternative to traditional assertions
  • Code coverage: code coverage metrics to improve testing
  • Combination approvals: to achieve better coverage
  • Mutation testing: to evaluate the quality of the tests

6.2 Demo: Replace an assertion with approval testing

Context: Cash register software for supermarkets. We are testing the calculation of the receipt for a shopping cart with various special offers.

Required libraries:

pip install approvaltests pytest-approvaltests

Original test with multiple assertions:

def test_no_discount(teller, cart, toothbrush, apples):
    teller.add_special_offer(SpecialOfferType.TEN_PERCENT_DISCOUNT, toothbrush, 10.0)
    cart.add_item_quantity(apples, 2.5)
    
    receipt = teller.checks_out_articles_from(cart)
    
    # Plusieurs assertions
    assert receipt.total_price() == ...
    assert len(receipt.items) == 1
    assert receipt.items[0].product == apples
    ...

Same test with approval testing:

import approvaltests
from receipt_printer import ReceiptPrinter

def test_no_discount(teller, cart, toothbrush, apples):
    teller.add_special_offer(SpecialOfferType.TEN_PERCENT_DISCOUNT, toothbrush, 10.0)
    cart.add_item_quantity(apples, 2.5)

    receipt = teller.checks_out_articles_from(cart)

    approvaltests.verify(ReceiptPrinter().print_receipt(receipt))

Operation:

  1. First run: The test fails because there is no “approved” file yet. It creates a received file with the current result.
  2. Inspection: We examine the received file and if the content is correct, we approve it (renaming it to an approved file).
  3. Following runs: The test compares the current result to the approved file. If different → the test fails.

Example of ReceiptPrinter:

# receipt_printer.py
class ReceiptPrinter:
    def print_receipt(self, receipt):
        result = ""
        for item in receipt.items:
            result += f"{item.product.name:20s} {item.total_price:.2f}\n"
        for discount in receipt.discounts:
            result += f"{discount.description:20s} -{discount.discount_amount:.2f}\n"
        result += f"{'Total:':20s} {receipt.total_price():.2f}\n"
        return result

Fixes shared in conftest.py:

import pytest

from fake_catalog import FakeCatalog
from model_objects import Product, ProductUnit
from shopping_cart import ShoppingCart
from teller import Teller

@pytest.fixture
def toothbrush():
    return Product("toothbrush", ProductUnit.EACH)

@pytest.fixture
def apples():
    return Product("apples", ProductUnit.KILO)

@pytest.fixture
def catalog(toothbrush, apples):
    catalog = FakeCatalog()
    catalog.add_product(toothbrush, 0.99)
    catalog.add_product(apples, 1.99)
    return catalog

@pytest.fixture
def teller(catalog):
    return Teller(catalog)

@pytest.fixture
def cart():
    return ShoppingCart()

Complete tests with approval testing:

import approvaltests
import pytest

from model_objects import SpecialOfferType
from receipt_printer import ReceiptPrinter

def test_no_discount(teller, cart, toothbrush, apples):
    teller.add_special_offer(SpecialOfferType.TEN_PERCENT_DISCOUNT, toothbrush, 10.0)
    cart.add_item_quantity(apples, 2.5)
    receipt = teller.checks_out_articles_from(cart)
    approvaltests.verify(ReceiptPrinter().print_receipt(receipt))

def test_ten_percent_discount(teller, cart, toothbrush):
    teller.add_special_offer(SpecialOfferType.TEN_PERCENT_DISCOUNT, toothbrush, 10.0)
    cart.add_item_quantity(toothbrush, 2)
    receipt = teller.checks_out_articles_from(cart)
    approvaltests.verify(ReceiptPrinter().print_receipt(receipt))

def test_three_for_two_discount(teller, cart, toothbrush):
    teller.add_special_offer(SpecialOfferType.THREE_FOR_TWO, toothbrush, 10.0)
    cart.add_item_quantity(toothbrush, 3)
    receipt = teller.checks_out_articles_from(cart)
    approvaltests.verify(ReceiptPrinter().print_receipt(receipt))

6.3 Appropriate use of approval testing

Structure of an approval test:

  • Arrange: Prepare the context
  • Act: Execute action
  • Assert → Print & Diff: Convert to string and compare to approved file

Advantages:

  1. Easier understanding of failures: Instead of reading an assertion message, we look at a diff which shows the change in context
  2. Easy update: If the new result is preferable, you can easily update with a diff merge tool
  3. Investment comparable to assertions: The design of the printer should not be significantly more expensive than the assertions

The testing pyramid (Martin Fowler):

  • Lots of unit tests (small, fast, inexpensive)
  • Fewer service tests (test larger parts of the application, slower)
  • Even fewer UI tests (slowest and most expensive)

When to use approval testing:

  • Mostly useful for slightly larger tests (not necessarily pure unit tests)
  • When you want to assert several things on a complex object

6.4 Demo: Code coverage

Installation:

pip install coverage

Command line usage:

# Exécuter les tests avec collecte de couverture
coverage run -m pytest --rootdir=. --import-mode=importlib -v

# Afficher le rapport de couverture
coverage report -m

# Générer un rapport HTML
coverage html

Report example:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/catalog.py             15      0   100%
src/model_objects.py       28      3    89%   45-47
src/receipt.py             12      0   100%
src/shopping_cart.py       21      6    71%   33-38
src/teller.py              25      0   100%

Interpretation: shopping_cart.py only has 71% coverage. Lines 33-38 are never executed during testing.

Configuration in pytest.ini to enable coverage automatically:

[pytest]
addopts = --cov=src --cov-report=html

In PyCharm: The IDE can display the cover directly in the editor with green/red markers in the gutter.

Coverage improvement: After adding a test for “three for two” offers, coverage increases from 71% to 77%.


6.5 Demo: Combination Approvals

Context: The Gilded Rose kata — complex logic for updating item properties.

# gilded_rose.py
class Item:
    def __init__(self, name, sell_in, quality):
        self.name = name
        self.sell_in = sell_in
        self.quality = quality

class GildedRose(object):

    def __init__(self, items):
        self.items = items

    def update_quality(self):
        for item in self.items:
            if item.name != "Aged Brie" and item.name != "Backstage passes to a TAFKAL80ETC concert":
                if item.quality > 0:
                    if item.name != "Sulfuras, Hand of Ragnaros":
                        item.quality = item.quality - 1
            else:
                if item.quality < 50:
                    item.quality = item.quality + 1
                    if item.name == "Backstage passes to a TAFKAL80ETC concert":
                        if item.sell_in < 11:
                            if item.quality < 50:
                                item.quality = item.quality + 1
                        if item.sell_in < 6:
                            if item.quality < 50:
                                item.quality = item.quality + 1
            if item.name != "Sulfuras, Hand of Ragnaros":
                item.sell_in = item.sell_in - 1
            if item.sell_in < 0:
                if item.name != "Aged Brie":
                    if item.name != "Backstage passes to a TAFKAL80ETC concert":
                        if item.quality > 0:
                            if item.name != "Sulfuras, Hand of Ragnaros":
                                item.quality = item.quality - 1
                    else:
                        item.quality = item.quality - item.quality
                else:
                    if item.quality < 50:
                        item.quality = item.quality + 1

Problem: The initial coverage of a simple test approval is not good — large parts of the logic are not executed.

Strategy: verify_all_combinations

import approvaltests

def print_item(item):
    return f"Item({item.name}, sell_in={item.sell_in}, quality={item.quality})"

def update_quality_printer(args, result):
    return f"{args} => {print_item(result)}\n"

def update_quality_for_item(name, sell_in, quality):
    item = Item(name, sell_in, quality)
    items = [item]
    gilded_rose = GildedRose(items)
    gilded_rose.update_quality()
    return item

def test_update_quality():
    names = ["foo", "Aged Brie", "Backstage passes to a TAFKAL80ETC concert", "Sulfuras, Hand of Ragnaros"]
    sell_ins = [-1, 0, 1, 6, 11]
    qualitys = [0, 1, 2, 48, 49, 50]

    approvaltests.verify_best_covering_pairs(
        update_quality_for_item,
        [
            names,
            sell_ins,
            qualitys
        ],
        formatter=update_quality_printer
    )

Principle: verify_all_combinations calls the test function with all possible combinations of the supplied values. The result of each combination is displayed according to the formatter and the whole is compared to the approved file.


6.6 Appropriate use of combination approvals

Exponential growth of combinations:

SettingsValues ​​per parameterNumber of combinations
32 each8
32 × 2 × 312
32 × 2 × 624

The number of combinations increases rapidly with the number of values ​​per parameter.

Benefit: Get good test coverage with very little additional code. Cost: High number of test cases and longer execution time.


6.7 Demo: Mutation testing and branch coverage

Mutation testing (manual):

  1. Start with passing tests
  2. Deliberately introduce a bug (create a “mutant”)
  3. Run tests
  4. If the tests fail → the mutant is “killed” (good news)
  5. If the tests pass → the mutant has “survived” (problem: the tests are not good enough)

Results obtained:

  • With 100% coverage of statements, some mutants still survive
  • Some lines of code are not completely covered

Branch coverage:

Activate with the --branch flag:

coverage run --branch -m pytest
coverage report -m

With branch coverage, partially covered conditional rows appear yellow in the HTML report.

Explanation: If a condition is always true in your tests, when you run coverage:

  • True branch is marked green (covered)
  • The other branch is marked red (not covered)

If you enable branch coverage, the condition line itself is marked yellow (covered but with only one result). This is particularly useful when there is no else clause — without branch covering, uncovered lines would be invisible.


6.8 Using coverage and mutation testing

Mutation testing:

  • Useful for assessing test quality
  • Automatic tools only know certain types of bugs to insert
  • Costly in time and resources — impossible to do constantly

Recommendation:

  • Code coverage and mutation testing are tools for making informed decisions
  • Use to identify where you don’t have enough tests
  • It’s not perfect — testing will never be perfect either

6.9 Demo: Pairwise testing

Problem: With 120 combinations, test execution time becomes problematic.

Solution: verify_best_covering_pairs

Instead of testing all combinations, select an optimized subset that covers all pairs of values:

def test_update_quality():
    names = ["foo", "Aged Brie", "Backstage passes to a TAFKAL80ETC concert", "Sulfuras, Hand of Ragnaros"]
    sell_ins = [-1, 0, 1, 6, 11]
    qualitys = [0, 1, 2, 48, 49, 50]

    approvaltests.verify_best_covering_pairs(
        update_quality_for_item,
        [names, sell_ins, qualitys],
        formatter=update_quality_printer
    )

Result: 30 scenarios instead of 120, optimized for the best coverage of pairs of different values.

Visual illustration:

ApproachCombinations (example: 3 params × 2-2-4 values)Cover
verify_all_combinations24Complete
verify_best_covering_pairs12Near-complete (all pairs)

Module 6 Summary:

  • Combination testing improves coverage
  • Approval testing makes these tests easier to manage and update
  • All pairs reduces the number of combinations to execute

6.10 Module Summary

  • Approval testing: particularly useful when the unit is a little larger and you want to assert several things
  • Code coverage: useful to identify what is not tested
  • Combination approvals + pairwise: to increase code coverage
  • Mutation testing: to evaluate the quality of the tests

7. Code difficult to test

7.1 Module Introduction

Context: This module deals with code that is difficult to test. This type of code usually has:

  • Poor separation of concerns
  • A lack of encapsulation
  • Bad general structure

These techniques are generally not necessary if the code is well designed. But often, code requires refactoring, and before refactoring, we want to have tests in place as a safety net.

Easy to test code: This is generally a kind of pure function — logic with no side effects or external dependencies. Most of the sample code in this course was relatively easy to test.

Strategies presented in this module:

  1. Peel — for hard-to-test code at the start/end of a method
  2. Slice — for hard-to-test code in the middle of a method
  3. Monkey patching — to insert test doubles into difficult dependencies
  4. Self-initializing fake — for network dependencies and external services

7.2 Demo: “Peel” strategy for hard-to-test code

Definition: The Peel strategy applies when difficult-to-test code is at the beginning and end of the method. We “peel” the non-testable part like peeling a banana to access the testable part in the middle.

Principle: Extract pure logic into a new testable method with more arguments. The original method now contains very little code and only concerns side effects and dependencies.

Background: Ice cream sales forecasting system. The get_score method has a dependency on lookup_weather (external API call) and uses a global variable flavour.

Problematic original code

import enum
import random
import requests

class IceCream(enum.Enum):
    Strawberry = 1
    Chocolate = 2
    Vanilla = 3

flavour = None

def get_score():
    sunny_today = lookup_weather()
    return get_score_with_weather_and_flavour(sunny_today, flavour)

def lookup_weather(location=None):
    location = location or (59.3293, 18.0686)  # default to Stockholm
    days_forward = 0
    params = {"latitude": location[0], "longitude": location[1], "days_forward": days_forward}
    weather_app = "http://127.0.0.1:3005"
    response = requests.get(weather_app + "/forecast", params=params)
    if response.status_code != 200:
        raise RuntimeError("Weather service unavailable")
    forecast = response.json()
    return bool(forecast["weather"]["main"] == "Sunny")

Problems:

  • lookup_weather makes a network call → difficult to test
  • The global variable flavour → the tests interfere with each other

Problem observed during the tests: If we execute a test in isolation it passes, but executed with all the other tests, it fails because another test also modifies the global variable flavour.

Applying Peel policy

Step 1: Extract pure logic into a new method with dependencies as parameters:

def get_score_with_weather_and_flavour(sunny_today, current_flavour=None):
    if current_flavour == IceCream.Strawberry:
        if sunny_today:
            return 10
        else:
            return 5
    elif current_flavour == IceCream.Chocolate:
        return 6
    elif current_flavour == IceCream.Vanilla:
        if sunny_today:
            return 7
        else:
            return 5
    else:
        return -1

Step 2: The original method becomes a simple wrapper:

def get_score():
    sunny_today = lookup_weather()
    return get_score_with_weather_and_flavour(sunny_today, flavour)

Step 3: Test the new testable method:

def test_get_score_with_weather():
    weathers = [True, False]
    flavours = [IceCream.Strawberry, IceCream.Chocolate, IceCream.Vanilla, None]
    approvaltests.verify_all_combinations(
        get_score_with_weather_and_flavour,
        [weathers, flavours],
        formatter=print_get_score
    )

This method is fully testable: no network calls, no global variables.


7.3 Demo: Slice strategy for hard-to-test code

Definition: The Slice strategy applies when difficult-to-test code is in the middle of the method. We “slice” this difficult part like removing the pit from a cherry.

Principle:

  1. Create a local function that makes code difficult to test
  2. Move the function declaration to the beginning of the method
  3. Call it from the original place in the code
  4. We can now apply the Peel strategy on this logic

Context: print_sales_forecast in module forecasts.py — has testability issues with current date/time and print().

Production code (with testability challenges)

import datetime

import scorer
from scorer import IceCream

def print_sales_forecasts(now=None):
    def update_selection():
        scorer.update_selection()

    print_sales_forecase_and_update_selection(now, update_selection)

def print_sales_forecase_and_update_selection(now, update_selection):
    names = ["Steve", "Julie", "Francis"]
    now = now or datetime.datetime.now()
    print(f"Forecast at time {now}")
    for name in names:
        if name == "Steve":
            scorer.flavour = IceCream.Strawberry
        else:
            update_selection()
        score = scorer.get_sales_forecast()
        print(f"{name} score: {score}")

Technical: Pass the current date/time as an optional argument with None as the default value:

def print_sales_forecasts(now=None):
    now = now or datetime.datetime.now()
    ...

This is a standard pattern: if the code depends on a global variable like the current time, add an optional parameter with the current value as default.

Fix pytest capsys to capture stdout:

def test_sales_forecasts(capsys):
    print_sales_forecase_and_update_selection(
        now=datetime.datetime.fromisoformat("2023-05-08 15:26:03.797869"),
        update_selection=stub_update_selection
    )
    output = capsys.readouterr()
    approvaltests.verify(output.out)

Stub for update_selection:

def stub_update_selection():
    scorer.flavour = IceCream.Vanilla

The test passes update_selection as an argument, replacing the real logic with a stub — this is the Slice technique.


7.4 Consequences of Peel and Slice strategies

For code that is mostly pure logic with little hard-to-test code:

  • Peel and Slice allow logic to be separated and put under test
  • Remaining code (side effects, dependencies) is not tested
  • Overall design may be worse after these transformations
  • This is a short term strategy to have enough tests in place to start refactoring

For code mainly composed of side effects and little pure logic:

Three possible strategies:

  1. No automated testing for this part — rely on manual testing. This works if the code is very obvious when it breaks (e.g. API code used by the frontend)
  2. Careful inspection of the code + some manual checks — if the code follows an obvious standard design pattern (e.g.: MapReduce, concurrency)
  3. Use double test instead — if automated testing is really necessary. Requires advanced skills and has many pitfalls.

7.5 Demo: Monkey patching to insert double tests

Definition: Monkey patching is another name for metaprogramming. This is when we dynamically change an attribute or piece of code at runtime — swapping the code present at program startup with other code.

Context: The lookup_weather method now makes a real HTTP call to an external weather service.

def lookup_weather(location=None):
    location = location or (59.3293, 18.0686)  # default to Stockholm
    days_forward = 0
    params = {"latitude": location[0], "longitude": location[1], "days_forward": days_forward}
    weather_app = "http://127.0.0.1:3005"
    response = requests.get(weather_app + "/forecast", params=params)
    if response.status_code != 200:
        raise RuntimeError("Weather service unavailable")
    forecast = response.json()
    return bool(forecast["weather"]["main"] == "Sunny")

Using the pytest monkeypatch fixture

class StubWeatherServiceResponse:
    def __init__(self):
        self.status_code = 200

    def json(self):
        return {"weather": {"main": "Sunny"}}

def test_lookup_weather_default_location(monkeypatch):
    def stub_requests_get(*args, **kwargs):
        return StubWeatherServiceResponse()

    monkeypatch.setattr(requests, "get", stub_requests_get)
    assert scorer.lookup_weather() == True

Explanation:

  • monkeypatch.setattr(requests, "get", stub_requests_get) temporarily replaces requests.get with the stub_requests_get function
  • After testing, pytest automatically restores the original value
  • The stub returns a 200 response with "main": "Sunny"lookup_weather() returns True

Advantages of monkey patching:

  • Allows you to test global dependencies that cannot be injected otherwise
  • pytest automatically restores original values after each test
  • Can be used to stub entire modules or global functions

7.6 Demo: Self-initializing Fake

Definition: A self-initializing fake is a type of double test that can be introduced via monkey patching. It reduces the amount of duplicate test code to write by recording real interactions in a first run, then replaying them in subsequent runs.

VCR concept: Like a VCR, it records “cassettes” of network interactions and replays them. Inspired by VCR.js (JavaScript).

Required libraries:

pip install vcrpy pytest-recording

Test with @pytest.mark.vcr

@pytest.mark.vcr
def test_lookup_weather_not_sunny():
    location_aare = (63.3990, 13.0815)  # Are, station de ski suédoise
    assert scorer.lookup_weather(location_aare) == False

Recording mode:

# Première exécution : enregistrer les interactions
pytest --record-mode=once

# Exécutions suivantes : rejouer depuis la cassette
pytest

once mode: Records all new interactions on first run. After that, expect to be able to play them again from the tape.

Cassette generated (YAML file)

# tests/cassettes/test_scorer/test_lookup_weather_not_sunny.yaml
interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.30.0
    method: GET
    uri: http://127.0.0.1:3005/forecast?latitude=63.399&longitude=13.0815&days_forward=0
  response:
    body:
      string: '{"days_forward":0.0,"latitude":63.399,"longitude":13.0815,
        "todays_date":"2023-05-17T15:36:53.191147",
        "weather":{"clouds":{"afternoon":100,"evening":75,"morning":100,"pre_dawn":50},
        "description":"moderate snow","main":"Snow",...}}'
    headers:
      Content-Type:
      - application/json
    status:
      code: 200
      message: OK
version: 1

The YAML cassette contains the entire HTTP request (method, URI, headers) and the complete response (JSON body, headers, status code). On subsequent runs, vcrpy intercepts HTTP calls and returns the recorded response instead of making a real network call.

Advantages of self-initializing fake:

  • No need to write the stub manually
  • Tape captures true service response
  • Tests remain reliable even if service is unavailable or weather changes
  • Cassette can be versioned and shared with the team

7.7 Module Summary

This module focused on code that is difficult to test. Especially :

  • Peel Strategy: Extract pure logic by separating untestable code at the start/end of the method
  • Slice Strategy: Create a local function to isolate untestable code in the middle of a method
  • Monkey patching: Use pytest’s monkeypatch fixture to dynamically replace difficult-to-inject global dependencies
  • Self-initializing fake (VCR): Record real network interactions and replay them for reproducible and independent testing

These techniques allow enough testing to begin refactoring and improving the code design.


8. Appendix: Command Summary

Installation

pip install pytest
pip install coverage
pip install approvaltests pytest-approvaltests
pip install vcrpy pytest-recording

Running tests

# Tous les tests
python -m unittest
python -m pytest

# Avec chemins Python
PYTHONPATH=src python -m pytest

# Exclure les tests lents
pytest -m "not slow"

# Avec couverture
coverage run -m pytest
coverage report -m
coverage html

# Avec branch coverage
coverage run --branch -m pytest

# Avec VCR en mode enregistrement
pytest --record-mode=once
mon_projet/
├── pytest.ini
├── setup.py
├── README.md
├── src/
│   └── mon_module/
│       ├── __init__.py
│       └── ma_classe.py
└── test/
    ├── __init__.py
    ├── conftest.py          # fixtures partagées
    └── test_ma_classe.py

Typical pytest.ini file

[pytest]
addopts = --strict-markers
markers =
    slow: Tests lents avec des données de fichier (désactiver avec '-m "not slow"')

9. Appendix: Dual Test Type Comparison Table

TypeSimilarity to the real thingClean logicMay cause test to failTypical use case
DummyVery lowNoNoMandatory argument not used
StubLowNoNoReplace a slow collaborator/network
FakeAverageYesNoStringIO instead of a file
SpyHighLittleYes (a posteriori)Check that emails have been sent
MockHighLittleYes (immediate)Strict contract on the order of calls

10. Appendix: Design test patterns

Arrange – Act – Assert (AAA)

def test_exemple(phonebook):
    # Arrange
    phonebook.add("Bob", "12345")

    # Act
    number = phonebook.lookup("Bob")

    # Assert
    assert number == "12345"

TDD Cycle (Red – Green – Refactor)

1. Écrire un test qui échoue (Red)
2. Écrire le minimum de code pour le faire passer (Green)
3. Refactorer le code (sans casser les tests) (Refactor)
4. Recommencer

Pattern “Peel”

# Avant (difficile à tester)
def get_result():
    data = fetch_from_external_api()  # dépendance difficile
    return process(data)              # logique pure

# Après (avec peel)
def get_result():
    data = fetch_from_external_api()  # reste ici, difficile à tester
    return process_with_data(data)    # extraite, facile à tester

def process_with_data(data):
    return process(data)              # logique pure, facile à tester

Pattern “Slice”

# Avant (difficile à tester)
def do_work():
    result_part1 = compute_part1()
    data = fetch_from_external_api()   # au milieu, difficile à tester
    result_part2 = compute_part2(data)
    return combine(result_part1, result_part2)

# Après (avec slice)
def do_work(fetch=None):
    def default_fetch():
        return fetch_from_external_api()

    fetch = fetch or default_fetch       # injectée ou défaut
    result_part1 = compute_part1()
    data = fetch()
    result_part2 = compute_part2(data)
    return combine(result_part1, result_part2)


Search Terms

testing · python · foundations · data · analysis · engineering · analytics · test · tests · pytest · production · coverage · design · fake · mock · peel · spy · tdd · dummy · slice · strategy · stub · act · appropriate

Interested in this course?

Contact us to book it or get a custom training plan for your team.