PYTH-230 · Lesson 2 of 12 · 28 min

Unit tests with pytest

▶ Video coming soon A video version of this lesson is on the way.

The little runner at the end of lesson 1 worked, but it was missing most of what a team needs. It could not find tests in other files, it said nothing about why a test failed beyond the name, and it had no way to run only some of the tests. Python has two standard answers to these problems. unittest is part of the standard library and has been since Python 2.1. pytest is a separate package, installed with pip, that has become the most widely used test tool in the Python world. Ridgeline's project already lists pytest as a development dependency, a decision Jordan and Sam made in PYTH-110.

This lesson shows both. You will write tests as unittest TestCase classes, which run anywhere Python runs, including this page, and as plain pytest functions, which are shorter and give more helpful failure messages. You will see how test discovery works, how to test that code raises an exception, how to compare floating-point numbers safely, and how to organize tests in a project.

unittest: tests as classes

In unittest, tests are methods of a class that inherits from unittest.TestCase. Each method whose name starts with test is one test. Instead of the bare assert statement, a TestCase provides assertion methods such as assertEqual, assertTrue and assertIn, which produce clear messages when they fail. The test runner creates a fresh instance of the class for every test method, so tests cannot leak state into each other through attributes.

On this page a short helper called report() stands in for the test runner. It runs the TestCase classes it is given and prints one line per test, ok or FAIL with the assertion message, and a total. In your own project you would type python -m unittest or pytest in a terminal instead, which do the same job and add timings.

A unittest TestCase for the loyalty tiers
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

def tier(points):
    """Loyalty tier for a number of points."""
    if points >= 300:
        return "Reserve"
    if points >= 100:
        return "Roast"
    return "Bean"

class TestTier(unittest.TestCase):
    def test_new_member_is_bean(self):
        self.assertEqual(tier(0), "Bean")

    def test_100_points_is_roast(self):
        self.assertEqual(tier(100), "Roast")

    def test_299_points_is_still_roast(self):
        self.assertEqual(tier(299), "Roast")

    def test_300_points_is_reserve(self):
        self.assertEqual(tier(300), "Reserve")

    def test_tier_names_are_known(self):
        self.assertIn(tier(1000), {"Bean", "Roast", "Reserve"})

report(TestTier)
Output
test_100_points_is_roast           ok
test_299_points_is_still_roast     ok
test_300_points_is_reserve         ok
test_new_member_is_bean            ok
test_tier_names_are_known          ok
5 tests: 5 passed, 0 failed

Five tests, one behaviour each, all passing. The order in the report is alphabetical by method name, because that is the order unittest uses; tests must never depend on running in a particular order. To see what a failure looks like, imagine Mei asked for the Roast tier to start at 150 and someone changed only one of the two numbers in the code. The next listing makes that mistake on purpose.

A boundary slip caught: assertEqual shows both values
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

def tier(points):
    if points >= 300:
        return "Reserve"
    if points > 100:          # a slip: > instead of >=
        return "Roast"
    return "Bean"

class TestTier(unittest.TestCase):
    def test_100_points_is_roast(self):
        self.assertEqual(tier(100), "Roast")

    def test_299_points_is_still_roast(self):
        self.assertEqual(tier(299), "Roast")

report(TestTier)
Output
test_100_points_is_roast           FAIL
    + Roast
test_299_points_is_still_roast     ok
2 tests: 1 passed, 1 failed

The failure message shows both values: the function returned 'Bean' where 'Roast' was expected. That is the key advantage of assertEqual over a bare assert in unittest. The most useful assertion methods are assertEqual and assertNotEqual, assertTrue and assertFalse, assertIs and assertIsNone for identity, assertIn and assertNotIn for membership, assertAlmostEqual for floating-point numbers, assertRaises for exceptions, and assertCountEqual for comparing two collections while ignoring their order.

pytest: tests as functions

pytest takes a lighter approach. A test is just a function whose name starts with test_, in a file whose name starts with test_, and it checks results with the ordinary assert statement. There is no class to inherit from and no assertion methods to remember. When an assert fails, pytest rewrites the statement behind the scenes so the report shows the values on both sides and, for strings, lists and dictionaries, exactly where they differ.

The same tests in pytest style: plain functions and plain assert
# tests/test_loyalty.py
from ridgeline.loyalty import tier

def test_new_member_is_bean():
    assert tier(0) == "Bean"

def test_100_points_is_roast():
    assert tier(100) == "Roast"

def test_300_points_is_reserve():
    assert tier(300) == "Reserve"

To run them, open a terminal in the project folder, with the virtual environment active, and type pytest. pytest searches the folder for test files, collects the tests inside, runs them and prints a summary. A dot means a passed test and an F a failed one. Here is what Jordan saw after making the boundary slip from the previous section:

A pytest run with one failure (output shortened)
> pytest
=================== test session starts ===================
collected 3 items

tests/test_loyalty.py .F.                            [100%]

======================== FAILURES =========================
_________________ test_100_points_is_roast ________________

    def test_100_points_is_roast():
>       assert tier(100) == "Roast"
E       AssertionError: assert 'Bean' == 'Roast'
E         - Roast
E         + Bean

tests/test_loyalty.py:7: AssertionError
================= short test summary info =================
FAILED tests/test_loyalty.py::test_100_points_is_roast
=============== 1 failed, 2 passed in 0.03s ===============

The report points to the exact line, shows the failing expression and the values it compared, and ends with a one-line summary per failure. Each test is identified by a node id, the file path and test name joined by two colons, which you can paste back into the command to rerun just that test: pytest tests/test_loyalty.py::test_100_points_is_roast.

A few command-line options cover most daily use. pytest -q prints less, pytest -v prints one line per test, pytest -x stops at the first failure, pytest -k roast runs only tests whose names contain roast, and pytest --lf reruns only the tests that failed last time. pytest also runs unittest TestCase classes without any changes, so a project can move from one style to the other gradually.

A project folder tree on the left: src/ridgeline with loyalty.py and stock.py, and a tests folder with test_loyalty.py, test_stock.py and a helpers.py file. Arrows show pytest collecting the two test_ files and, inside test_stock.py, a function test_sell_reduces_stock and a class TestRestock with a method test_restock_adds_bags. helpers.py is skipped because its name does not start with test_. On the right, the collected node ids are listed.
How pytest discovers tests: files, functions and classes named by convention

How test discovery works

Both tools find tests by naming convention, so names matter. By default pytest looks for files named test_*.py or *_test.py, and inside them for functions named test_something and for classes named TestSomething that contain test_ methods and have no __init__ method. unittest's discovery, run with python -m unittest, looks for files named test*.py and for TestCase subclasses inside them. A test that does not follow the convention is silently skipped, which is a classic way to believe code is tested when it is not. If a test you just wrote does not appear in the count, check its name first.

Most projects keep tests in a folder called tests at the top of the project, beside src, with one test file per module: tests/test_loyalty.py for src/ridgeline/loyalty.py, tests/test_stock.py for stock.py, and so on. Test code is real code. It lives in version control, is reviewed like any other change, and should be as readable as the program it checks.

Configuring pytest for the ridgeline project
# pyproject.toml (the pytest section)
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"           # summarize skipped and failed tests at the end
pythonpath = ["src"]      # let tests import ridgeline from the src folder

Testing that code raises an exception

Many rules are about what a program must refuse to do. Selling more bags than are in stock must raise OutOfStock; a negative price must raise ValueError. A test for such a rule passes only if the exception is raised. In unittest you write the call inside a with self.assertRaises(...) block; in pytest the equivalent is with pytest.raises(...). Both fail the test if the block finishes without raising the expected exception, and both let you inspect the exception afterwards.

assertRaises checks that the rule is enforced, and the stock is unchanged
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

class OutOfStock(Exception):
    pass

def sell(stock, code, bags):
    if bags <= 0:
        raise ValueError("bags must be positive")
    if stock.get(code, 0) < bags:
        raise OutOfStock(f"only {stock.get(code, 0)} bags of {code}")
    stock[code] -= bags

class TestSell(unittest.TestCase):
    def test_sell_reduces_stock(self):
        stock = {"RC-104": 9}
        sell(stock, "RC-104", 2)
        self.assertEqual(stock["RC-104"], 7)

    def test_cannot_sell_more_than_stock(self):
        stock = {"RC-104": 9}
        with self.assertRaises(OutOfStock) as ctx:
            sell(stock, "RC-104", 10)
        self.assertIn("only 9 bags", str(ctx.exception))
        self.assertEqual(stock["RC-104"], 9)     # nothing was sold

    def test_zero_bags_is_rejected(self):
        with self.assertRaises(ValueError):
            sell({"RC-101": 42}, "RC-101", 0)

report(TestSell)
Output
test_cannot_sell_more_than_stock   ok
test_sell_reduces_stock            ok
test_zero_bags_is_rejected         ok
3 tests: 3 passed, 0 failed

The second test checks three things: that the right exception is raised, that its message is useful, and that the stock was not changed by the failed sale. That last check matters. A function that raises an error halfway through after already changing data leaves a mess, and only a test that looks at the data afterwards will notice.

The pytest version: pytest.raises with a match pattern
import pytest
from ridgeline.inventory import Inventory, OutOfStock

def test_cannot_sell_more_than_stock():
    inv = Inventory.from_counts({"RC-104": 9})
    with pytest.raises(OutOfStock, match="only 9 bags"):
        inv.sell("RC-104", 10)
    assert inv.count("RC-104") == 9

Comparing floating-point numbers

Floating-point arithmetic is approximate, so a test that checks 0.1 + 0.2 == 0.3 fails even though the code is correct. Money at Ridgeline is kept in whole cents or Decimal for exactly this reason, but some quantities, such as roast weights and the share of weight lost in roasting, are naturally floats. For those, compare within a tolerance. unittest has assertAlmostEqual, which by default rounds the difference to seven decimal places; pytest has pytest.approx, which compares with a relative tolerance of one part in a million.

assertAlmostEqual compares floats safely; exact equality does not
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

def weight_loss(green_kg, roasted_kg):
    """Fraction of weight lost in roasting."""
    return (green_kg - roasted_kg) / green_kg

class TestWeightLoss(unittest.TestCase):
    def test_exact_equality_is_fragile(self):
        self.assertNotEqual(0.1 + 0.2, 0.3)       # true, and surprising

    def test_typical_batch_loses_fifteen_hundredths(self):
        self.assertAlmostEqual(weight_loss(12.0, 10.2), 0.15)

    def test_within_a_chosen_tolerance(self):
        self.assertAlmostEqual(weight_loss(12.0, 10.18), 0.1517, delta=0.0001)

report(TestWeightLoss)
print(weight_loss(12.0, 10.2))
Output
test_exact_equality_is_fragile     ok
test_typical_batch_loses_fifteen_hundredths ok
test_within_a_chosen_tolerance     ok
3 tests: 3 passed, 0 failed
0.15000000000000005

The printed value shows why: 12.0 minus 10.2, divided by 12, is not exactly 0.15 in binary floating point. assertAlmostEqual accepts it because the difference is tiny. The delta argument lets you choose the tolerance explicitly, which is better when you know how precise the answer needs to be, such as a scale that reads to the nearest ten grams.

Worked problem

Turning a bug report into a test

Theo reports issue #42: when a customer buys exactly the remaining stock of a coffee, the tool refuses with 'only 9 bags of RC-104' even though 9 bags were requested. Write the test before fixing anything.

Show solution
  1. Reproduce the report as a test: stock {"RC-104": 9}, sell 9 bags.
  2. Expected behaviour from the business rule: the sale succeeds and stock becomes 0.
  3. Name the test for the behaviour: test_can_sell_the_last_bags.
  4. Run it and watch it fail, which proves it captures the bug.
  5. Look at the code: the check reads stock <= bags instead of stock < bags. Fix it and run all the tests.

Answer: A failing test that reproduces the report comes first; it proves the bug, then proves the fix, and stays in the suite so the bug cannot return.

Writing a test for every bug before fixing it is one of the most valuable habits in software. The test fails first, which proves it really reproduces the problem. It passes after the fix, which proves the fix works. And it stays in the test suite forever, so the same bug can never quietly return. A bug that comes back after being fixed is called a regression, and a suite built up this way is called a regression suite.

Try it yourself

Tests for the tiers

The setup defines a correct tier(points): Bean below 100, Roast from 100 to 299, Reserve from 300. Write a unittest.TestCase class named TestTier with test methods that check it.

Your class must pass for the correct function and fail for two broken versions the checker swaps in: one with the Roast boundary at 101, and one with the Reserve boundary at 301.

import unittest

class TestTier(unittest.TestCase):
    def test_new_member_is_bean(self):
        self.assertEqual(tier(0), 'Bean')

    def test_big_spender_is_reserve(self):
        self.assertEqual(tier(1000), 'Reserve')

result = unittest.TestResult()
unittest.defaultTestLoader.loadTestsFromTestCase(TestTier).run(result)
print(result.testsRun, 'tests,', len(result.failures), 'failures')

Try it yourself

Test the refusal

The setup defines OutOfStock and a correct sell(stock, code, bags) like the one in this lesson. Complete TestSell.test_cannot_oversell so it uses self.assertRaises(OutOfStock) to check that selling 10 bags from a stock of 9 is refused, and then checks the stock is still 9.

The checker also runs your test against a broken sell that never refuses, and against one that changes the stock before raising.

import unittest

class TestSell(unittest.TestCase):
    def test_cannot_oversell(self):
        stock = {'RC-104': 9}
        sell(stock, 'RC-104', 2)
        self.assertEqual(stock['RC-104'], 7)

result = unittest.TestResult()
unittest.defaultTestLoader.loadTestsFromTestCase(TestSell).run(result)
print(result.testsRun, 'tests,', len(result.failures) + len(result.errors), 'problems')

Try it yourself

Make the tests pass

This time the tests are written and the code is wrong. bags_from_batch(roasted_kg) should return how many full 340 g bags a roasted batch fills, and loss(green_kg, roasted_kg) the fraction of weight lost. Fix both functions so the checks pass.

def bags_from_batch(roasted_kg):
    return round(roasted_kg * 1000 / 340)

def loss(green_kg, roasted_kg):
    return (green_kg - roasted_kg) / roasted_kg

print(bags_from_batch(10.2), loss(12.0, 10.2))

Common mistakes

  • MistakeNaming a test file stock_tests.py or a function check_sell, so the runner never finds it.

    InsteadFollow the conventions: test_*.py files and test_ functions or methods.

  • MistakeComparing floats with == in a test.

    InsteadUse assertAlmostEqual or pytest.approx with a sensible tolerance.

  • MistakeCalling the function outside the with assertRaises block, so the exception escapes the test.

    InsteadPut the call that should raise inside the with block.

  • MistakeForgetting to check that data is unchanged after an operation is refused.

    InsteadAssert on the data after the with block as well as on the exception.

  • MistakeFixing a reported bug without first writing a test that reproduces it.

    InsteadWrite the failing test, watch it fail, then fix the code.

  • MistakeWriting tests that rely on running in a particular order.

    InsteadMake each test set up everything it needs; runners may reorder or run tests in parallel.

Summary and key terms

  • unittest, in the standard library, groups tests as methods of TestCase classes and checks results with assertion methods.
  • pytest uses plain test functions and the assert statement, and rewrites failed asserts to show the values compared.
  • Both find tests by naming convention: test_ files, test_ functions and methods, Test classes.
  • pytest options such as -q, -v, -x, -k and --lf control what runs and how much is printed; node ids rerun single tests.
  • assertRaises and pytest.raises test that code refuses bad requests; also check that data is left unchanged.
  • Compare floats with assertAlmostEqual or pytest.approx, never with ==.
  • Every bug report should become a failing test before the fix, building a regression suite.
unittest
The test framework in Python's standard library, based on TestCase classes.
pytest
A popular third-party test framework that uses plain functions and assert.
TestCase
The unittest base class whose test methods each make one test.
assertion method
A TestCase method such as assertEqual that checks a result and explains failures.
test discovery
Finding test files, classes and functions automatically by naming convention.
node id
pytest's name for one test: the file path and test name joined by ::.
assertRaises
A unittest context manager that passes only if the code inside raises the given exception.
pytest.approx
A pytest helper for comparing floating-point numbers within a tolerance.
regression suite
A set of tests, many written for past bugs, that guards against old bugs returning.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A colleague adds def check_refund(): to tests/test_loyalty.py and pytest reports the same number of tests as before. Why?

    Show answer

    The function name does not start with test_, so pytest does not collect it.

  2. Which pytest option reruns only the tests that failed last time?

    Show answer

    pytest --lf (last failed).

  3. Write the unittest assertion that checks tier(150) returns "Roast".

    Show answer

    self.assertEqual(tier(150), "Roast")

  4. Why does a test of a refused sale also check the stock afterwards?

    Show answer

    A function might change the data before raising; only checking the data afterwards catches that.

  5. Which assertion would you use for weight_loss(12.0, 10.2) == 0.15?

    Show answer

    assertAlmostEqual(weight_loss(12.0, 10.2), 0.15), or pytest.approx(0.15) with assert.

  6. What three things does writing a failing test for a bug report give you?

    Show answer

    Proof the bug is reproduced, proof the fix works, and protection against the bug returning.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz