Testing in Python with unittest — practice with FP2 grades, the shop discount and a TDD for the pool (and the display that said 82 degrees)
One Monday, the display at the sports centre entrance announced: “Water temperature: 82 degrees”. Nobody got into the pool, of course. The new sensor measured in Fahrenheit and the display program showed the number as it was, as if it were degrees Celsius (82 °F is about 27.8 °C, perfect for swimming). Nobody had written a single test of the conversion. The lesson: if a function does calculations that matter, it gets tested, and better before writing it. At the end of this practice you will build that Temperature class with TDD.
In the Python testing theory you saw how to choose cases with black box (equivalence classes and boundary values) and with white box (flow graph and cyclomatic complexity), how a unittest suite is written and read, and the red-green-refactor cycle of TDD. Today you apply it in three medium-level blocks, each with one technique: black box with the grade for a mark, white box with the sports centre shop discount and a full TDD of the Temperature class. The data is real-life: marks with decimals, purchases right at the limit and temperatures that can be in the wrong scale.
Table of Contents
Before you start
It helps to be clear about:
- A test class inherits from
unittest.TestCase, and only methods starting withtestrun. - What an equivalence class is and why both sides of every boundary are tested.
- How cyclomatic complexity is counted: edges − nodes + 2, or predicates + 1.
FAILis anassertthat does not hold andERROR, an unexpected exception.
If any of these sounds shaky, go back to the theory before carrying on.
Each program has the function and its tests in the same file and ends with unittest.main(verbosity=2), so python3 file.py is enough. In a real project they would be separate (grades.py and test_grades.py, with from grades import classify_grade) and run with python3 -m unittest -v. The outputs are the real ones; only the time (“in 0.001s”) will change on your computer.
Python testing practice — Block 1: Black box testing with equivalence classes and boundary values
The function classify_grade(grade) receives a mark from 0 to 10 and returns its grade: below 5, Fail; from 5 to below 7, Pass; from 7 to below 9, Good; from 9 to 10, Excellent. If the mark is not a number it raises TypeError, and if it is outside 0-10, ValueError. What makes it tricky: marks have decimals (6.99 is not a 7) and the boundaries are exactly where the grade changes.
The idea
Design the tests using only the specification, as if we could not see the code. First, the equivalence classes: four valid ones (one per grade) and three invalid ones (negative, over 10 and text). Then, one representative per class. Finally, the boundary values: both sides of every boundary, to two decimal places because that is how marks come.
Before coding, count the boundaries in the specification. How many boundary values do you get if you test both sides of each one?
In the drawing, the coral boundaries are the ones that change the grade; the violet ones separate what is valid from what gives an error.
| Class | Range | Result | Representative |
|---|---|---|---|
| EC1 | 0 ≤ mark < 5 | Fail | 3.2 |
| EC2 | 5 ≤ mark < 7 | Pass | 6 |
| EC3 | 7 ≤ mark < 9 | Good | 7.8 |
| EC4 | 9 ≤ mark ≤ 10 | Excellent | 9.5 |
| EC5 | mark < 0 | ValueError | −2 |
| EC6 | mark > 10 | ValueError | 11 |
| EC7 | not a number | TypeError | 'eight' |
The code
import unittest
def classify_grade(grade):
"""Grade for a mark from 0 to 10 (both included):
below 5 Fail, 5 to below 7 Pass, 7 to below 9 Good, 9 to 10 Excellent.
TypeError if the grade is not a number; ValueError if it is outside 0-10."""
if isinstance(grade, bool) or not isinstance(grade, (int, float)):
raise TypeError('the grade must be a number')
if grade < 0 or grade > 10:
raise ValueError('the grade must be between 0 and 10')
if grade < 5:
return 'Fail'
if grade < 7:
return 'Pass'
if grade < 9:
return 'Good'
return 'Excellent'
class TestClassifyGrade(unittest.TestCase):
# Valid classes: one representative of each
def test_ec1_fail(self):
self.assertEqual(classify_grade(3.2), 'Fail')
def test_ec2_pass(self):
self.assertEqual(classify_grade(6), 'Pass')
def test_ec3_good(self):
self.assertEqual(classify_grade(7.8), 'Good')
def test_ec4_excellent(self):
self.assertEqual(classify_grade(9.5), 'Excellent')
# Invalid classes
def test_ec5_negative(self):
with self.assertRaises(ValueError):
classify_grade(-2)
def test_ec6_over_10(self):
with self.assertRaises(ValueError):
classify_grade(11)
def test_ec7_text(self):
with self.assertRaises(TypeError):
classify_grade('eight')
# Boundary values of each boundary
def test_lim_invalid(self):
for grade in (-0.01, 10.01):
with self.subTest(grade=grade):
with self.assertRaises(ValueError):
classify_grade(grade)
def test_lim_valid(self):
cases = [(0, 'Fail'), (4.99, 'Fail'), (5, 'Pass'), (6.99, 'Pass'),
(7, 'Good'), (8.99, 'Good'), (9, 'Excellent'), (10, 'Excellent')]
for grade, expected in cases:
with self.subTest(grade=grade):
self.assertEqual(classify_grade(grade), expected)
unittest.main(verbosity=2)
The validation at the start has a detail:
def classify_grade(grade):
"""Grade for a mark from 0 to 10 (both included):
below 5 Fail, 5 to below 7 Pass, 7 to below 9 Good, 9 to 10 Excellent.
TypeError if the grade is not a number; ValueError if it is outside 0-10."""
if isinstance(grade, bool) or not isinstance(grade, (int, float)):
raise TypeError('the grade must be a number')
if grade < 0 or grade > 10:
raise ValueError('the grade must be between 0 and 10')
if grade < 5:
return 'Fail'
if grade < 7:
return 'Pass'
if grade < 9:
return 'Good'
return 'Excellent'
isinstance(grade, (int, float))accepts integers and decimals with a single check.isinstance(grade, bool)goes first because in PythonTrueis anint: without that line,classify_grade(True)would return “Fail” (True is worth 1).- After validating, the
ifs go from low to high and each one only needs to compare with its upper limit: if we reachgrade < 7, we already know it was not below 5.
Output
test_ec1_fail (__main__.TestClassifyGrade.test_ec1_fail) ... ok test_ec2_pass (__main__.TestClassifyGrade.test_ec2_pass) ... ok test_ec3_good (__main__.TestClassifyGrade.test_ec3_good) ... ok test_ec4_excellent (__main__.TestClassifyGrade.test_ec4_excellent) ... ok test_ec5_negative (__main__.TestClassifyGrade.test_ec5_negative) ... ok test_ec6_over_10 (__main__.TestClassifyGrade.test_ec6_over_10) ... ok test_ec7_text (__main__.TestClassifyGrade.test_ec7_text) ... ok test_lim_invalid (__main__.TestClassifyGrade.test_lim_invalid) ... ok test_lim_valid (__main__.TestClassifyGrade.test_lim_valid) ... ok ---------------------------------------------------------------------- Ran 9 tests in 0.001s OK
Nine tests, nine ok: seven classes and two boundary value tests. With subTest, those two check 2 + 8 = 10 values: −0.01 and 10.01 (outside), and 0, 4.99, 5, 6.99, 7, 8.99, 9 and 10 (inside). In total, 7 + 10 = 17 cases in 9 tests.
Questions you might ask
Why 4.99 and not 4.9 as the boundary value? Because the subject’s marks have two decimals: the closest value below 5 that can appear is 4.99. With 4.9 you would leave the gap between 4.9 and 5 untested.
Why is 10 valid and 10.01 not? Because of the specification: “from 9 to 10” includes 10. If the programmer had written grade >= 10 to raise an error, the test for 10 would catch it.
Do I also need to test 5.5 or 8? No: they are in the same class as 6 and 7.8. Another value from the same class only repeats work; a boundary value, on the other hand, can reveal a < that should have been <=.
Why do the names have ec1, ec2… and lim? So the report comes out in the order of the table (unittest sorts them alphabetically) and so that, if one fails, you see at a glance which class or boundary failed.
Writing a single test per grade with values “from the middle” (3, 6, 8, 9.5) and calling the suite done. They would all pass even if the code had grade <= 5 instead of grade < 5; only exactly 5 reveals it.
Try it yourself
- Add the “Matrícula de Honor” (top distinction) as a new class (mark 10 with a flag
mh=True) and write its tests before touching the function. - Change
grade < 7tograde <= 7in the function and run it: which test fails and what does the report say?
Python testing practice — Block 2: White box testing with flow graph analysis
The sports centre shop gives discounts: with a purchase of 50 € or more, 5 %; if the member also has 100 points or more, 10 %; and premium members add an extra 5 % to whatever they have. The function customer_discount(purchase, points, is_premium) returns the percentage (0, 5, 10 or 15). What makes it tricky: one if sits inside another, and it is easy to leave a combination untested.
The idea
Now we do look at the code. We number its nodes, draw the flow graph, compute the cyclomatic complexity and choose one test case per basic path. On top of that, since the two techniques complement each other, we add the boundary values of each condition (black box).
Before coding, count the ifs. If there are three, how many basic paths do you expect?
The code
import unittest
def customer_discount(purchase, points, is_premium):
"""Discount percentage at the sports centre shop (0, 5, 10 or 15)."""
discount = 0 # node 1
if purchase >= 50: # node 2 (predicate)
discount = 5 # node 3
if points >= 100: # node 4 (predicate)
discount = 10 # node 5
if is_premium: # node 6 (predicate)
discount += 5 # node 7
return discount # node 8
class TestCustomerDiscount(unittest.TestCase):
# White box: one test per basic path, V(G) = 3 + 1 = 4
def test_path1_small_purchase(self):
self.assertEqual(customer_discount(30, 50, False), 0)
def test_path2_premium_only(self):
self.assertEqual(customer_discount(30, 50, True), 5)
def test_path3_purchase_no_points(self):
self.assertEqual(customer_discount(60, 50, False), 5)
def test_path4_everything(self):
self.assertEqual(customer_discount(60, 150, True), 15)
# Black box on top: the boundaries of each if
def test_value_limit_amount_50(self):
self.assertEqual(customer_discount(49.99, 0, False), 0)
self.assertEqual(customer_discount(50, 0, False), 5)
def test_value_limit_points_100(self):
self.assertEqual(customer_discount(50, 99, False), 5)
self.assertEqual(customer_discount(50, 100, False), 10)
unittest.main(verbosity=2)
Building the graph with the code in front of you:
- Node 1:
discount = 0. Node 2:purchase >= 50? (predicate). - If yes, node 3 (
discount = 5) and node 4:points >= 100? (predicate, inside the first one). If yes, node 5. - The three possible exits (2-No, 4-No and 5) reach node 6:
is_premium? (predicate). If yes, node 7; both paths end at 8 (return). - Count: 8 nodes and 10 edges → 10 − 8 + 2 = 4. Check: 3 predicates + 1 = 4.
Output
test_path1_small_purchase (__main__.TestCustomerDiscount.test_path1_small_purchase) ... ok test_path2_premium_only (__main__.TestCustomerDiscount.test_path2_premium_only) ... ok test_path3_purchase_no_points (__main__.TestCustomerDiscount.test_path3_purchase_no_points) ... ok test_path4_everything (__main__.TestCustomerDiscount.test_path4_everything) ... ok test_value_limit_amount_50 (__main__.TestCustomerDiscount.test_value_limit_amount_50) ... ok test_value_limit_points_100 (__main__.TestCustomerDiscount.test_value_limit_points_100) ... ok ---------------------------------------------------------------------- Ran 6 tests in 0.000s OK
Six ok: the four basic paths and two boundary values. Checking each path by hand:
| Path | Nodes | Data | Working | Expected |
|---|---|---|---|---|
| 1 | 1-2-6-8 | 30 €, 50 pts, not premium | 30 < 50 and not premium | 0 |
| 2 | 1-2-6-7-8 | 30 €, 50 pts, premium | 0 + 5 | 5 |
| 3 | 1-2-3-4-6-8 | 60 €, 50 pts, not premium | 60 ≥ 50 → 5; 50 < 100 | 5 |
| 4 | 1-2-3-4-5-6-7-8 | 60 €, 150 pts, premium | 5 → 10 (points) → 10 + 5 | 15 |
And the boundaries: 49.99 € gives 0 and 50 € gives 5 (purchase boundary); 99 points give 5 and 100 give 10 (points boundary, with the purchase right at 50).
Questions you might ask
Do four paths test every combination? No: there are more combinations (for example, 60 €, 150 points and not premium, which gives 10). Basic paths guarantee that every edge is walked at least once, not every combination. It is the minimum, not the maximum.
Why does path 4 go through all eight nodes? Because it adds the edges 4→5 and 5→6, which no earlier path had used. A basic path only counts if it brings at least one new edge.
What are the boundary tests for, if white box already covers everything? White box cannot see whether >= should have been >: with 60 € and with 30 € both operators give the same. Only exactly 50 tells >= from >.
Why two asserts in the same boundary test? Because they check one idea (the purchase boundary) from both sides. If the test fails, the message says which of the two values did not match.
Miscounting the edges of an if without else. The “No” exit is also an edge (from 2 to 6 here). If you leave it out, you get V(G) = 3 and you are one test case short.
Try it yourself
- Move
if points >= 100outside the firstif(to the same level). Draw the new graph: does V(G) change? Which new paths appear? - Add the rule “premium members never get more than 15 %” and write the test that checks it first.
Python testing practice — Block 3: Complete TDD cycle for a Temperature class
The pool display’s Temperature class stores a value and a scale ('C', 'F' or 'K'), converts between scales, compares with other temperatures even in another scale and shows itself as 28°C. It raises ValueError with an unknown scale or below absolute zero (−273.15 °C). What makes it tricky: three scales converting into each other and decimals that do not come out exact.
The idea
Strict TDD: for each requirement, first the test (red), then the minimum code (green) and, if needed, refactor. Six iterations: create, validate the scale, validate absolute zero, convert, compare and show. Here you will see two real reds (iteration 1 and iteration 3) and the final result with every test.
Before coding, think of a single helper function that turns any scale into Celsius. How many places in the class could use it?
Iteration 1 — Create a valid temperature
The first test, without a single line of the class:
import unittest
class TestTemperature(unittest.TestCase):
# Iteration 1: create (the class does not exist yet)
def test_i1_create_celsius(self):
t = Temperature(25, 'C')
self.assertEqual((t.value, t.scale), (25, 'C'))
unittest.main(verbosity=2)
test_i1_create_celsius (__main__.TestTemperature.test_i1_create_celsius) ... ERROR
======================================================================
ERROR: test_i1_create_celsius (__main__.TestTemperature.test_i1_create_celsius)
----------------------------------------------------------------------
Traceback (most recent call last):
File "red1.py", line 7, in test_i1_create_celsius
t = Temperature(25, 'C')
^^^^^^^^^^^
NameError: name 'Temperature' is not defined. Did you mean: 'TestTemperature'?
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
Red, but a special red: it is an ERROR (NameError), not a FAIL, because the class does not even exist. In TDD that is normal in the first iteration. The minimum code to pass it is an __init__ that stores value and scale.
Iterations 2 and 3 — Validate the scale and absolute zero
In iteration 2 we add the invalid scale test and the check if scale not in self.SCALES. Moving on to iteration 3, we write the absolute zero test and run it before touching the class:
import unittest
class Temperature:
SCALES = ('C', 'F', 'K')
def __init__(self, value, scale):
if scale not in self.SCALES:
raise ValueError(f'invalid scale {scale!r}: use C, F or K')
self.value = value # iteration 2 green: absolute zero is not checked yet
self.scale = scale
class TestTemperature(unittest.TestCase):
def test_i1_create_celsius(self):
t = Temperature(25, 'C')
self.assertEqual((t.value, t.scale), (25, 'C'))
def test_i2_invalid_scale(self):
with self.assertRaises(ValueError):
Temperature(25, 'X')
def test_i3_below_absolute_zero(self): # the new test: red
with self.assertRaises(ValueError):
Temperature(-273.16, 'C')
unittest.main(verbosity=2)
test_i1_create_celsius (__main__.TestTemperature.test_i1_create_celsius) ... ok
test_i2_invalid_scale (__main__.TestTemperature.test_i2_invalid_scale) ... ok
test_i3_below_absolute_zero (__main__.TestTemperature.test_i3_below_absolute_zero) ... FAIL
======================================================================
FAIL: test_i3_below_absolute_zero (__main__.TestTemperature.test_i3_below_absolute_zero)
----------------------------------------------------------------------
Traceback (most recent call last):
File "red3.py", line 24, in test_i3_below_absolute_zero
with self.assertRaises(ValueError):
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
AssertionError: ValueError not raised
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)
The two previous iterations stay green and the new one fails with ValueError not raised: the code accepts −273.16 °C without complaining. To make it green we need the Celsius value of any scale, and that is where the helper method _celsius comes from, later reused by the conversions (that is this iteration’s refactor). Notice that the iteration 3 test uses exactly the boundary value: −273.16, one hundredth below absolute zero. And its partner, test_i3_zero_kelvin_is_valid, checks the other side: exactly 0 K is valid.
Iterations 4 to 6 — Convert, compare and show
Conversions are tested with values you know by heart: 0 °C = 273.15 K, 212 °F = 100 °C and 373.15 K = 212 °F. Comparison, with 100 °C == 212 °F (equal even though the scale is different) and 25 °C != 30 °C. Text, with str and repr of 28 °C.
The complete class — the result of TDD
import unittest
class Temperature:
SCALES = ('C', 'F', 'K')
ABSOLUTE_ZERO_C = -273.15
def __init__(self, value, scale):
if scale not in self.SCALES:
raise ValueError(f'invalid scale {scale!r}: use C, F or K')
if self._celsius(value, scale) < self.ABSOLUTE_ZERO_C:
raise ValueError('below absolute zero')
self.value = value
self.scale = scale
@staticmethod
def _celsius(value, scale):
"""Turns any scale into Celsius (used by the validation and the conversions)."""
if scale == 'C':
return value
if scale == 'F':
return (value - 32) * 5 / 9
return value - 273.15
def to_celsius(self):
return Temperature(self._celsius(self.value, self.scale), 'C')
def to_fahrenheit(self):
return Temperature(self._celsius(self.value, self.scale) * 9 / 5 + 32, 'F')
def to_kelvin(self):
return Temperature(self._celsius(self.value, self.scale) + 273.15, 'K')
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
# in Celsius and with a margin: 80.6 °F in C gives 26.999999999999996, not 27
return abs(self.to_celsius().value - other.to_celsius().value) < 1e-9
def __str__(self):
return f'{self.value}°{self.scale}'
def __repr__(self):
return f'Temperature({self.value!r}, {self.scale!r})'
class TestTemperature(unittest.TestCase):
# Iteration 1: create
def test_i1_create_celsius(self):
t = Temperature(25, 'C')
self.assertEqual((t.value, t.scale), (25, 'C'))
# Iteration 2: the scale
def test_i2_invalid_scale(self):
with self.assertRaises(ValueError):
Temperature(25, 'X')
# Iteration 3: absolute zero (with its boundary values)
def test_i3_below_absolute_zero(self):
with self.assertRaises(ValueError):
Temperature(-273.16, 'C')
def test_i3_zero_kelvin_is_valid(self):
self.assertEqual(Temperature(0, 'K').value, 0)
# Iteration 4: conversions
def test_i4_celsius_to_kelvin(self):
self.assertAlmostEqual(Temperature(0, 'C').to_kelvin().value, 273.15)
def test_i4_fahrenheit_to_celsius(self):
self.assertAlmostEqual(Temperature(212, 'F').to_celsius().value, 100)
def test_i4_kelvin_to_fahrenheit(self):
self.assertAlmostEqual(Temperature(373.15, 'K').to_fahrenheit().value, 212)
# Iteration 5: compare
def test_i5_equal_in_another_scale(self):
self.assertEqual(Temperature(100, 'C'), Temperature(212, 'F'))
def test_i5_different(self):
self.assertNotEqual(Temperature(25, 'C'), Temperature(30, 'C'))
# Iteration 6: text
def test_i6_repr(self):
self.assertEqual(repr(Temperature(28, 'C')), "Temperature(28, 'C')")
def test_i6_str(self):
self.assertEqual(str(Temperature(28, 'C')), '28°C')
unittest.main(verbosity=2)
The pieces that came out of the tests:
@staticmethod
def _celsius(value, scale):
"""Turns any scale into Celsius (used by the validation and the conversions)."""
if scale == 'C':
return value
if scale == 'F':
return (value - 32) * 5 / 9
return value - 273.15
def to_celsius(self):
return Temperature(self._celsius(self.value, self.scale), 'C')
def to_fahrenheit(self):
return Temperature(self._celsius(self.value, self.scale) * 9 / 5 + 32, 'F')
def to_kelvin(self):
return Temperature(self._celsius(self.value, self.scale) + 273.15, 'K')
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
# in Celsius and with a margin: 80.6 °F in C gives 26.999999999999996, not 27
return abs(self.to_celsius().value - other.to_celsius().value) < 1e-9
_celsiusis a@staticmethodbecause it does not need the object: it receives value and scale and returns Celsius. It is used by the constructor (for absolute zero, before the object exists) and by the three conversions.- Conversions return a new
Temperatureand do not touch the original. __eq__compares in Celsius and with a margin (< 1e-9): in Python, (80.6 − 32) × 5 / 9 gives26.999999999999996, so an exact comparison would say 80.6 °F is not equal to 27 °C. For the same reason, the conversion tests useassertAlmostEqual, even though the three values we chose come out exact.
Output
test_i1_create_celsius (__main__.TestTemperature.test_i1_create_celsius) ... ok test_i2_invalid_scale (__main__.TestTemperature.test_i2_invalid_scale) ... ok test_i3_below_absolute_zero (__main__.TestTemperature.test_i3_below_absolute_zero) ... ok test_i3_zero_kelvin_is_valid (__main__.TestTemperature.test_i3_zero_kelvin_is_valid) ... ok test_i4_celsius_to_kelvin (__main__.TestTemperature.test_i4_celsius_to_kelvin) ... ok test_i4_fahrenheit_to_celsius (__main__.TestTemperature.test_i4_fahrenheit_to_celsius) ... ok test_i4_kelvin_to_fahrenheit (__main__.TestTemperature.test_i4_kelvin_to_fahrenheit) ... ok test_i5_different (__main__.TestTemperature.test_i5_different) ... ok test_i5_equal_in_another_scale (__main__.TestTemperature.test_i5_equal_in_another_scale) ... ok test_i6_repr (__main__.TestTemperature.test_i6_repr) ... ok test_i6_str (__main__.TestTemperature.test_i6_str) ... ok ---------------------------------------------------------------------- Ran 11 tests in 0.001s OK
Eleven green tests, ordered by iteration thanks to the i1…i6 prefix. Checking one conversion by hand: 373.15 K − 273.15 = 100 °C, and 100 × 9 / 5 + 32 = 212 °F.
Questions you might ask
Why write the test if I know it will fail? To check that the test can fail. A test that passes without new code is testing nothing (or testing something else). Red is the guarantee.
Why “the minimum code” and not the whole class at once? Because every line you write without a test asking for it is an untested line. Moving in short steps means that, when something breaks, you know it was the last thing you touched.
Why isinstance(other, Temperature) and NotImplemented in __eq__? So that comparing a temperature with a number (t == 28) does not blow up trying to call other.to_celsius(). With NotImplemented, Python tries other ways and, in the end, returns False (you saw it in operator overloading).
Would this have prevented the display’s 82 degrees? A test like test_i4_fahrenheit_to_celsius would have forced someone to think about the sensor’s scale before showing the number. The bug was not in a calculation, but in nobody having written what should happen with Fahrenheit.
Comparing the result of a conversion with assertEqual. Temperature(80.6, 'F').to_celsius().value is 26.999999999999996, and assertEqual(..., 27) would FAIL even though the class is fine. With decimals, assertAlmostEqual.
Try it yourself
- Add, with TDD, the method
is_nice_for_swimming():Truebetween 26 and 29 °C, in any scale. Write the boundary value tests first. - Make the scale accept lower case (
'c'): first the test (red), then the change (green).
The pattern to take away
| Technique | Steps | What it gives you |
|---|---|---|
| Black box | Specification → valid and invalid classes → one representative per class → both sides of every boundary | Cases the programmer forgot |
| White box | Code → nodes and edges → V(G) → one case per basic path | No branch left unrun |
| TDD | Failing test → minimum code → refactor → next requirement | Code that always has its tests |
Common mistakes in this practice
- Testing only values from the middle of each class: the boundaries (
5,50,100,−273.16) are what reveal a<where<=belonged. - Forgetting the “No” edge of an
ifwithoutelse: the graph ends up one edge short and V(G) one path short. - Thinking basic paths are every combination: they are the minimum to walk every edge.
- Comparing decimals with
assertEqual: temperature conversions do not come out exact. - Writing the test after the code in TDD: if you have never seen it red, you do not know whether it can fail.
- Copying what the function returns into the test: the expected value comes from the specification.
Visualise it
Python Tutor cannot run unittest, but you can watch block 2’s four basic paths one by one. Choose Python 3 and paste:
def customer_discount(purchase, points, is_premium):
discount = 0
if purchase >= 50:
discount = 5
if points >= 100:
discount = 10
if is_premium:
discount += 5
return discount
paths = [(30, 50, False, 0), (30, 50, True, 5), (60, 50, False, 5), (60, 150, True, 15)]
for purchase, points, premium, expected in paths:
got = customer_discount(purchase, points, premium)
print(purchase, points, premium, '->', got, got == expected)
Output:
30 50 False -> 0 True 30 50 True -> 5 True 60 50 False -> 5 True 60 150 True -> 15 True
What to watch:
- On each lap of the
for, follow the arrow insidecustomer_discount: each case goes through a different path of the graph. - In the case (30, 50, False) the arrow jumps straight from the first
ifto the premium one: it is the 2→6 edge many people forget. - In the case (60, 150, True) the arrow enters all three
ifs: it is the only one going through node 5. - The last column (
True) is theassertEqual: got matches expected in all four.
Summary and next step
- Black box: valid and invalid equivalence classes, one representative per class and both sides of every boundary.
- With decimals, the “other side” of the boundary is the closest value that makes sense (4.99 for marks with two decimals).
- White box: flow graph, V(G) = edges − nodes + 2 = predicates + 1, and one test per basic path.
- The two techniques combine: paths cover the code; boundaries, the conditions.
- TDD: red, green, refactor. The first red is usually an
ERROR(the class does not exist); the following ones,FAIL. - With decimals,
assertAlmostEqual; for exceptions,with self.assertRaises(...).
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Can you get the 7 classes of
classify_gradefrom the specification alone? - Do you get V(G) = 4 for
customer_discountwith both formulas? - Do you know why TDD’s first red was an
ERRORand iteration 3’s aFAIL? - Have you tried the “Try it yourself” tasks without looking at the theory?
If you answered yes to almost everything, you are ready for the Python testing exercises, where I no longer give you the code. See you there!
Next step: exercisesTesting in Python with unittest — exercises with black box, badly designed tests and a TDD bank account (and the test that always passed)Did this article help you? Here is how you can support SergioLearns →




What is an operating system — theory with your laptop on a study afternoon, a write() call and a CPU that never rests (and the three programs that weren’t running at the same time)