Main tool: Python 3 + pytest
Table of Contents
- 2.1 Introduction and used versions
- 2.2 Demo: Writing a test with unittest
- 2.3 Test suites and test runners
- 2.4 Demo: Test Fixtures — setUp and tearDown
- 2.5 Fixture execution order
- 2.6 Demo: Test design — naming
- 2.7 Demo: Arrange – Act – Assert
- 2.8 Module Summary
- 3.1 Introduction to pytest
- 3.2 Demo: Writing tests with pytest
- 3.3 Demo: Other types of assertions in pytest
- 3.4 Demo: Fixtures in pytest
- 3.5 Demo: Parameterized tests in pytest
- 3.6 Demo: Organizing test code in a larger project
- 3.7 Module Summary
- 4.1 Module Introduction
- 4.2 Demo: The developer feedback loop
- 4.3 Demo: Regression tests
- 4.4 Tests after vs Test-Driven Development
- 4.5 Demo: TDD in practice — FizzBuzz
- 4.6 TDD influences your design
- 4.7 Continuous integration
- 4.8 Module Summary
- 5.1 Module Introduction
- 5.2 Demo: Stub — tire pressure
- 5.3 Demo: Fake — HTML converter
- 5.4 Demo: Dummy — FizzBuzz configurable
- 5.5 Summary: Dummy, Stub, Fake
- 5.6 Demo: Spy — DiscountManager
- 5.7 Demo: Mock — DiscountManager
- 5.8 Spy vs Mock Comparison
- 5.9 Module Summary
- 6.1 Module Introduction
- 6.2 Demo: Replace an assertion with approval testing
- 6.3 Proper use of approval testing
- 6.4 Demo: Code coverage
- 6.5 Demo: Combination Approvals
- 6.6 Proper use of combination approvals
- 6.7 Demo: Mutation testing and branch coverage
- 6.8 Using coverage and mutation testing
- 6.9 Demo: Pairwise testing
- 6.10 Module Summary
- 7.1 Module Introduction
- 7.2 Demo: “Peel” strategy for hard-to-test code
- 7.3 Demo: “Slice” strategy for hard-to-test code
- 7.4 Consequences of Peel and Slice strategies
- 7.5 Demo: Monkey patching to insert test doubles
- 7.6 Demo: Self-initializing Fake
- 7.7 Module Summary
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
unittestmodule 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
lookupimplementation usesdict.get(), it does not raise aKeyError. You must use direct accessself.numbers[name].
2.3 Test suites and test runners
Key vocabulary:
| Term | Definition |
|---|---|
| Test case (test case) | An individual test that checks a unit of code. Must be independent, repeatable, and clearly report success or failure. |
| Test continued | A set of test cases executed together by a test runner. |
| Test runner | A program that runs the test cases and reports the results. |
Two types of test runners:
- Command line:
python -m unittest— automatic discovery of all tests - 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 resourcestearDown: 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 test →
tearDownis still called (to free the resources allocated insetUp) - If an exception is thrown in
setUp→ the test fails, the test method andtearDownare not called (becausesetUpcould not allocate resources)
Vocabulary summary:
| Term | Definition |
|---|---|
| Unit under test | The unit of code tested (class, method, function) |
| Test fixture | Support code to initialize/clean resources |
| Test case | An individual test with arrange, act, assert |
| Test continued | Set of tests run together |
| Test runner | Program 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
unittestmodule - 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:
- The name of each test must reflect the scenario tested
- Each test follows the structure arrange – act – assert
- Prefer several small tests rather than one large test
3. Using pytest
3.1 Introduction to pytest
Historical context:
- The
unittestmodule is a port of JUnit (Java), created by Kent Beck and Erich Gamma in 1997 unittestwas 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:
| Appearance | unittest | pytest |
|---|---|---|
| Required class | Yes (class PhonebookTest(TestCase)) | No |
| Required inheritance | Yes (unittest.TestCase) | No |
| Assertions | Specific methods (assertEqual, assertTrue) | Standard Python keyword assert |
| Naming convention | test_ (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
==wasNone
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.raisesis similar toassertRaisesfromunittest, 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.parametrizeis 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:
- Make sure the code does what you think — don’t ship code that doesn’t work
- 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:
- Python Console: Import functions and call them manually — fast but not reproducible
- Debugger: Create a
mainprogram with a breakpoint — visual feedback but still not reproducible - 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 thedoctestmodule. 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):
- Red: Write a test that fails (for a feature that does not yet exist)
- Green: Write just enough production code to pass the test
- 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
capsysfix: 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 usesprint().
4.6 TDD influences your design
Advantages of TDD on design:
-
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.
-
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.
-
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:
| Approach | Result |
|---|---|
| With TDD | Two separate functions: fizzbuzz() (logic) + print_fizzbuzz() (display) |
| Without TDD | A 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):
| Type | Features |
|---|---|
| Dummy | Usually None or empty collection. Passed but never actually used. |
| Stub | Responds with fixed pre-programmed responses. No proper logic. |
| Fake | Has an implementation with logic, but not suitable for production. |
| Spy | Like a stub, but logs received calls. May cause the test to fail after the fact. |
| Mock | Like 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
Alarmconstructor accepts an optionalsensor. 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:
- Stub written manually (
StubSensor): simple class with the same methods as the collaborator, returning fixed values - Stub with
unittest.mock.Mock: useMock(Sensor)and set.return_valueon 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 fake — StringIO 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
| Type | Description | When to use it |
|---|---|---|
| Dummy | None or empty collection | When we are forced to pass on a collaborator but he is not used |
| Stub | Responds with pre-programmed fixed values | When the collaborator is difficult to use (slow, network, etc.) |
| Fake | Implementation with logic, unsuitable for production | When 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
| Appearance | Spy | Mock |
|---|---|---|
| Time of assertion | After the test (post-verification) | During testing (immediate verification) |
| In case of bug | Indicates what happened afterwards | Explodes immediately with a stack trace |
| Simplicity | Generally simpler to implement | More complex |
| Readability of the test | More direct | Prerequisite setup required |
Two double test groups:
- Dummy, Stub, Fake → To replace a difficult collaborator to use in a test
- 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:
| Type | Description | Doubles used |
|---|---|---|
| Return value | Check the value returned by the function | All |
| Change of state | Check side effects (modified state) | All |
| Method call | Verify that a particular method was called | Spy, 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:
- First run: The test fails because there is no “approved” file yet. It creates a
receivedfile with the current result. - Inspection: We examine the
receivedfile and if the content is correct, we approve it (renaming it to anapprovedfile). - Following runs: The test compares the current result to the
approvedfile. 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:
- Easier understanding of failures: Instead of reading an assertion message, we look at a diff which shows the change in context
- Easy update: If the new result is preferable, you can easily update with a diff merge tool
- 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:
| Settings | Values per parameter | Number of combinations |
|---|---|---|
| 3 | 2 each | 8 |
| 3 | 2 × 2 × 3 | 12 |
| 3 | 2 × 2 × 6 | 24 |
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):
- Start with passing tests
- Deliberately introduce a bug (create a “mutant”)
- Run tests
- If the tests fail → the mutant is “killed” (good news)
- 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:
| Approach | Combinations (example: 3 params × 2-2-4 values) | Cover |
|---|---|---|
verify_all_combinations | 24 | Complete |
verify_best_covering_pairs | 12 | Near-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:
- Peel — for hard-to-test code at the start/end of a method
- Slice — for hard-to-test code in the middle of a method
- Monkey patching — to insert test doubles into difficult dependencies
- 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_weathermakes 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:
- Create a local function that makes code difficult to test
- Move the function declaration to the beginning of the method
- Call it from the original place in the code
- 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:
- 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)
- Careful inspection of the code + some manual checks — if the code follows an obvious standard design pattern (e.g.: MapReduce, concurrency)
- 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 replacesrequests.getwith thestub_requests_getfunction- After testing, pytest automatically restores the original value
- The stub returns a
200response with"main": "Sunny"→lookup_weather()returnsTrue
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
monkeypatchfixture 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
Recommended structure of a Python project with tests
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
| Type | Similarity to the real thing | Clean logic | May cause test to fail | Typical use case |
|---|---|---|---|---|
| Dummy | Very low | No | No | Mandatory argument not used |
| Stub | Low | No | No | Replace a slow collaborator/network |
| Fake | Average | Yes | No | StringIO instead of a file |
| Spy | High | Little | Yes (a posteriori) | Check that emails have been sent |
| Mock | High | Little | Yes (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