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.

Before you start

It helps to be clear about:

  • A test class inherits from unittest.TestCase, and only methods starting with test run.
  • 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.
  • FAIL is an assert that does not hold and ERROR, 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?

Classes of classify_gradeGrade line from 0 to 10 with four valid classes (Fail, Pass, Good, Excellent) and two invalid ones below 0 and above 10.classify_grade(grade): 7 equivalence classeserror< 0Fail0 – 4.99Pass5 – 6.99Good7 – 8.99Excel.9 – 10error> 1004.99|56.99|78.99|910And off the line, EC7: a string like 'eight' (TypeError).
The coral boundaries change the grade: both sides are tested.

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.

ClassRangeResultRepresentative
EC10 ≤ mark < 5Fail3.2
EC25 ≤ mark < 7Pass6
EC37 ≤ mark < 9Good7.8
EC49 ≤ mark ≤ 10Excellent9.5
EC5mark < 0ValueError−2
EC6mark > 10ValueError11
EC7not a numberTypeError'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 Python True is an int: 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 reach grade < 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 < 7 to grade <= 7 in 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)
Flow graph of customer_discountThe points if (4) sits inside the purchase if (2); the premium one (6) comes after. Four basic paths.YesNoYesNoYesNo12345678purchase >= 50?points >= 100?discount += 5is_premium?Basic paths (V(G) = 4)1-2-6-830 €, 50 pts, not premium01-2-6-7-830 €, 50 pts, premium51-2-3-4-6-860 €, 50 pts, not premium51-2-3-4-5-6-7-860 €, 150 pts, premium1510 edges − 8 nodes + 2 = 43 predicates + 1 = 4
These four paths go through all ten edges at least once.

Building the graph with the code in front of you:

  1. Node 1: discount = 0. Node 2: purchase >= 50? (predicate).
  2. If yes, node 3 (discount = 5) and node 4: points >= 100? (predicate, inside the first one). If yes, node 5.
  3. 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).
  4. 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:

PathNodesDataWorkingExpected
11-2-6-830 €, 50 pts, not premium30 < 50 and not premium0
21-2-6-7-830 €, 50 pts, premium0 + 55
31-2-3-4-6-860 €, 50 pts, not premium60 ≥ 50 → 5; 50 < 1005
41-2-3-4-5-6-7-860 €, 150 pts, premium5 → 10 (points) → 10 + 515

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 >= 100 outside the first if (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?

The TDD cycleRed, green and refactor in a loop, and the Temperature class iterations with their result: E, OK, F, OK, OK, OK, OK.REDwrite a testthat failsGREENthe minimum codeto make it passREFACTORclean up withoutbreaking any testnext requirementTemperature in the practice (what unittest said):i1Ei2OKi3Fi3OKi4OKi5OKi6OKi1: the class did not exist (E, NameError). i3: absolute zero was not checked yet (F).
Red is not a failure: it proves the test is good for something.

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
  • _celsius is a @staticmethod because 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 Temperature and do not touch the original.
  • __eq__ compares in Celsius and with a margin (< 1e-9): in Python, (80.6 − 32) × 5 / 9 gives 26.999999999999996, so an exact comparison would say 80.6 °F is not equal to 27 °C. For the same reason, the conversion tests use assertAlmostEqual, 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(): True between 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

TechniqueStepsWhat it gives you
Black boxSpecification → valid and invalid classes → one representative per class → both sides of every boundaryCases the programmer forgot
White boxCode → nodes and edges → V(G) → one case per basic pathNo branch left unrun
TDDFailing test → minimum code → refactor → next requirementCode 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 if without else: 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:

  1. On each lap of the for, follow the arrow inside customer_discount: each case goes through a different path of the graph.
  2. In the case (30, 50, False) the arrow jumps straight from the first if to the premium one: it is the 2→6 edge many people forget.
  3. In the case (60, 150, True) the arrow enters all three ifs: it is the only one going through node 5.
  4. The last column (True) is the assertEqual: 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_grade from the specification alone?
  • Do you get V(G) = 4 for customer_discount with both formulas?
  • Do you know why TDD’s first red was an ERROR and iteration 3’s a FAIL?
  • 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 →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *