Testing in Python

Write tests that run with nothing installed, then the same tests the way pytest wants them — and know what is worth asserting.

23of 24
≈ 13Min read
5Parts

unittest is already there

A method whose name starts with test_ is a test; everything else on the class is a helper. setUp runs before each one and tearDown after, so every test starts from the same state — and the order they run in is alphabetical, which you should never depend on.

py
import unittest

def total(prices, tax=0.2):
    return round(sum(prices) * (1 + tax), 2)

class TestTotal(unittest.TestCase):
    def test_adds_tax(self):
        self.assertEqual(total([100]), 120.0)

    def test_empty_basket(self):
        self.assertEqual(total([]), 0.0)

    def test_negative_price_is_refused(self):
        with self.assertRaises(ValueError):
            total([-1])

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

The assertions worth knowing

assertTrue is the one to avoid: when it fails it can only tell you that something was false. assertEqual tells you what you got and what you expected, which is the difference between a two-minute fix and a debugging session.

MethodUse
assertEqual(a, b)The default. The failure message shows both values
assertAlmostEqual(a, b)Floats — 0.1 + 0.2 is not 0.3
assertRaises(Err)As a context manager, so you can inspect the exception
assertIn(x, seq) / assertIsNone(x)Clearer failures than assertTrue(x in seq)
assertCountEqual(a, b)Same items, any order
subTest(...)A loop of cases where every failure is reported, not just the first

The same tests, pytest-shaped

pytest is not in the standard library, and it is what almost everyone uses: plain functions, plain assert, and a rewritten assertion that still shows both sides. parametrize turns a table of cases into separate tests, each named and each reported on its own.

The same idea appears in both: a fixture (or setUp) builds the world, the test acts, and the assertion checks one thing.

py
def test_adds_tax():
    assert total([100]) == 120.0

def test_negative_price_is_refused():
    with pytest.raises(ValueError, match="negative"):
        total([-1])

@pytest.mark.parametrize("prices,expected", [
    ([], 0.0),
    ([100], 120.0),
    ([50, 50], 120.0),
])
def test_totals(prices, expected):
    assert total(prices) == expected

@pytest.fixture
def basket():
    return [10, 20]                 # anything after a yield is teardown

What to assert

And the name is part of the test: test_refuses_negative_price tells you what broke from the report alone. test_total_2 does not.

  • Behaviour, not implementation. Assert what the function returns, not which private helper it called — or the test breaks on every refactor and finds no bugs.
  • The edges. Empty, one, many; zero and negative; the first and the last; the boundary the condition names.
  • The failures. A function that raises on bad input needs a test that it does, or one day it will not.
  • One reason to fail. A test named test_totals that checks six things tells you very little when it goes red.

Running them from inside Python

Failures and errors are different on purpose: a failure is your assertion refusing, an error is an exception nobody expected. A test suite full of errors is usually one broken import, not a hundred broken tests.

py
suite = unittest.TestLoader().loadTestsFromTestCase(TestTotal)
result = unittest.TextTestRunner(verbosity=0).run(suite)

result.testsRun        # how many ran
result.wasSuccessful() # True if nothing failed or errored
result.failures        # [(test, traceback), …]  — an assertion said no
result.errors          # [(test, traceback), …]  — the code blew up

Practice