Testing and debugging · about 50 min

Testing with unittest

Why this matters

Bare assert statements in a function work, but they stop at the first failure, give little detail, and you have to call every test function yourself. A test framework fixes all of that: it finds your tests automatically, runs each one in isolation so one failure does not hide the others, reports exactly what was expected and what was received, and returns an exit status that scripts and CI systems can act on. Python ships one in the standard library, unittest. (Many projects use the third-party pytest, which can also run unittest tests; the ideas carry over directly.)

The shape of a unittest file

Here is a module to test, textutils.py, with a bug in word_count:

def word_count(text):
    return len(text.split(" "))


def initials(name):
    return "".join(part[0].upper() for part in name.split())

And its tests, test_textutils.py:

import unittest

from textutils import initials, word_count


class TestWordCount(unittest.TestCase):
    def test_simple_sentence(self):
        self.assertEqual(word_count("the quick brown fox"), 4)

    def test_extra_spaces(self):
        self.assertEqual(word_count("  the   quick  "), 2)

    def test_empty(self):
        self.assertEqual(word_count(""), 0)


class TestInitials(unittest.TestCase):
    def test_two_names(self):
        self.assertEqual(initials("ada lovelace"), "AL")


if __name__ == "__main__":
    unittest.main()

This uses a class, which you will study properly in the object-oriented module. For now, treat it as a recipe:

Running the tests

From the directory containing both files:

$ python3 -m unittest test_textutils
..FF
======================================================================
FAIL: test_empty (test_textutils.TestWordCount.test_empty)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ada/proj/test_textutils.py", line 14, in test_empty
    self.assertEqual(word_count(""), 0)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
AssertionError: 1 != 0

======================================================================
FAIL: test_extra_spaces (test_textutils.TestWordCount.test_extra_spaces)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ada/proj/test_textutils.py", line 11, in test_extra_spaces
    self.assertEqual(word_count("  the   quick  "), 2)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 8 != 2

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=2)

How to read it:

Useful ways to run:

$ python3 -m unittest                          # discover test*.py files in this directory tree
$ python3 -m unittest -v test_textutils         # verbose: one line per test with its name
$ python3 -m unittest test_textutils.TestWordCount.test_empty   # a single test
$ python3 test_textutils.py                     # works too, thanks to unittest.main()

Discovery (python3 -m unittest with no names) imports every file matching test*.py, finds every TestCase class in it, and runs every method whose name starts with test. That is why the naming matters: a method called check_empty is never run, and nothing warns you.

FAIL versus ERROR

Every test runs independently: unittest catches whatever happens in one test, records it, and moves on. You see all the problems in one run instead of only the first.

The assert methods

MethodChecks
assertEqual(a, b) / assertNotEquala == b / a != b
assertTrue(x) / assertFalse(x)bool(x) is True / False
assertIs(a, b) / assertIsNone(x)identity: a is b / x is None
assertIn(a, b) / assertNotIna in b
assertAlmostEqual(a, b, places=7)floats equal after rounding: use for float results
assertRaises(Exc)the block raises Exc (see below)
assertCountEqual(a, b)same elements, any order: handy for results built from sets

Why not plain assert? Because the methods know what they compare and produce informative messages. assertEqual on two lists says which index differs; on two long strings it shows a diff. Every method also accepts a final msg argument for extra context.

Testing that errors are raised

def test_rejects_negative(self):
    with self.assertRaises(ValueError):
        sqrt_int(-4)

The with block passes if the code inside raises ValueError (or a subclass), and fails with AssertionError: ValueError not raised if it completes normally. This replaces the try/except/else dance from the previous lesson. To check the message too: with self.assertRaisesRegex(ValueError, "negative"):.

Many similar cases: subTest

def test_squares(self):
    for n, expected in [(0, 0), (3, 9), (-4, 16)]:
        with self.subTest(n=n):
            self.assertEqual(square(n), expected)

Without subTest, the first wrong case would end the test. With it, every case is checked and each failure is reported with its parameters (n=-4).

setUp: shared preparation

When several tests need the same starting data, put it in a method named setUp. unittest calls it before each test, giving every test a fresh copy:

class TestInventory(unittest.TestCase):
    def setUp(self):
        self.stock = {"apple": 3, "pear": 0}

    def test_remove_one(self):
        remove(self.stock, "apple")
        self.assertEqual(self.stock["apple"], 2)

    def test_remove_missing_raises(self):
        with self.assertRaises(KeyError):
            remove(self.stock, "kiwi")

self.stock stores the dictionary on the test object so the test method can reach it. Because setUp runs again before every test, a test that modifies self.stock cannot affect the next one. Tests must never depend on running in a particular order. tearDown is the matching cleanup method, run after each test even if it failed (useful for deleting temporary files).

What makes a good test suite

Common mistakes

Recap

Going further: read the unittest documentation sections "Basic example" and "Organizing test code", and look at unittest.mock, which replaces parts of your system (files, clocks, network calls) with fakes during a test.

Exercises

Exercise 1 · quiz

Which methods run?

import unittest

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(1 + 1, 2)

    def check_subtract(self):
        self.assertEqual(3 - 1, 5)

    def testMultiply(self):
        self.assertEqual(2 * 3, 6)

What does python3 -m unittest report for this file?

Answer
  • Ran 2 tests, OK

unittest runs every method whose name starts with test: test_add and testMultiply qualify, check_subtract does not, so its wrong assertion never runs and the result is a misleading OK. A test you forgot to name correctly is a test that silently does not exist; when you add a test, watch the "Ran N tests" count go up, and make sure it can fail.

Exercise 2 · write code

Make the tests pass

test_roman.py (in your working directory) contains a unittest suite for a function to_roman(n) that converts an integer from 1 to 3999 to Roman numerals (4 is IV, 1994 is MCMXCIV), and raises ValueError for anything outside that range.

Write roman.py so that python3 -m unittest -v test_roman passes. Read the tests to find out exactly what is expected; run them often and read the failure messages.

One standard approach: walk through the value/symbol pairs 1000 M, 900 CM, 500 D, 400 CD, 100 C, 90 XC, 50 L, 40 XL, 10 X, 9 IX, 5 V, 4 IV, 1 I from largest to smallest, and while n is at least the value, append the symbol and subtract the value.

File: roman.py

Starting point

def to_roman(n):
    return ""
Hint 1

Start with the range check: if not 1 <= n <= 3999: raise ValueError(...).

Hint 2

Keep the pairs in a list of tuples, largest first, and loop over it with for value, symbol in SYMBOLS:.

Hint 3

Inside, while n >= value: append the symbol and subtract the value.

Reference solution
"""Convert integers to Roman numerals."""

SYMBOLS = [
    (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
    (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
    (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]


def to_roman(n):
    """Return n (1..3999) as a Roman numeral string."""
    if not 1 <= n <= 3999:
        raise ValueError(f"to_roman needs 1..3999, got {n}")
    parts = []
    for value, symbol in SYMBOLS:
        while n >= value:
            parts.append(symbol)
            n -= value
    return "".join(parts)

Exercise 3 · quiz

FAIL or ERROR?

unittest prints F and FAIL: for some broken tests and E and ERROR: for others. A test method contains:

def test_parse(self):
    result = parse_price("12.50")
    self.assertEqual(result, 12.5)

parse_price has a bug that makes it raise TypeError before returning. How is this test reported?

Answer
  • ERROR, because an exception other than an assertion failure escaped from the test

A FAIL means an assertion ran and found the wrong value: the code returned something, just not the expected thing. An ERROR means the test crashed with some other exception before it could reach its verdict. The distinction is a useful clue: an ERROR often points at a crash in the code under test (or a broken test), and its traceback leads to the line that raised.

Exercise 4 · write code

Write a test suite that catches bugs

slug.py (in your working directory) defines slugify(title), which turns a title into a URL-friendly "slug":

slugify("Hello, World!")      # 'hello-world'
slugify("  Python 3.13  ")    # 'python-3-13'
slugify("!!!")                # ValueError

Write test_slug.py: a unittest suite (class TestSlugify(unittest.TestCase): with at least four test_... methods) that passes against the correct slug.py. The checker then swaps in five buggy versions of slugify, one at a time, and your suite must FAIL (or ERROR) for each. The bugs are realistic: think about case, runs of punctuation, the ends of the string, digits, and the empty case (assertRaises).

File: test_slug.py

Starting point

import unittest

from slug import slugify


class TestSlugify(unittest.TestCase):
    def test_simple(self):
        self.assertEqual(slugify("hello"), "hello")


if __name__ == "__main__":
    unittest.main()
Hint 1

One test method per rule: case, runs of punctuation, ends, digits, empty result.

Hint 2

Use inputs that would expose each bug: "hello, world" has a comma and a space between the words, a run of two.

Hint 3

For the error: with self.assertRaises(ValueError): slugify("!!!").

Reference solution
import unittest

from slug import slugify


class TestSlugify(unittest.TestCase):
    def test_lowercases(self):
        self.assertEqual(slugify("Hello"), "hello")

    def test_punctuation_run_becomes_one_dash(self):
        self.assertEqual(slugify("hello, world"), "hello-world")

    def test_strips_dashes_at_ends(self):
        self.assertEqual(slugify("  Hello, World!  "), "hello-world")

    def test_keeps_digits(self):
        self.assertEqual(slugify("Python 3.13"), "python-3-13")

    def test_empty_result_raises(self):
        with self.assertRaises(ValueError):
            slugify("!!!")


if __name__ == "__main__":
    unittest.main()