One test, a table of cases
Many inputs, one rule: put the inputs and expected results in a table and loop over it. subTest makes unittest report every failing row instead of stopping at the first.
- Write a table of input and expected-result pairs and check them in one loop
- Use
self.subTest(...)so each failing case is reported with its own values - Choose the rows of a table to cover each band and each boundary of a rule
Postage depends on weight bands: up to 100 g costs 85p, up to 250 g costs 135p, up to 750 g costs 210p, and heavier letters aren't accepted. Testing that rule needs many inputs: one inside each band and one on each boundary. Writing a test method for each is repetitive. A table is clearer: a list of (input, expected) pairs, checked in one loop. Adding a case means adding a row.
A plain loop has a weakness: the first failing assert stops the test, so you see one wrong row even if three are wrong.
Run each example to see what it prints. Running examples doesn't count toward progress.
def postage(weight_g): if weight_g < 100: return 85 if weight_g < 250: return 135 if weight_g <= 750: return 210 raise ValueError(f"{weight_g} g is too heavy for a letter") CASES = [(20, 85), (100, 85), (101, 135), (250, 135), (750, 210)] def test_price_table(): for weight, pence in CASES: assert postage(weight) == pence, f"{weight} g: expected {pence}, got {postage(weight)}" try: test_price_table() print("PASS") except AssertionError as err: print("FAIL", err)
unittest's with self.subTest(name=value): marks one pass of the loop as a sub-test. A failure inside it is reported with the values you passed, and the loop carries on with the next row. pytest's equivalent is @pytest.mark.parametrize, which turns each row into its own test.
import unittest def postage(weight_g): if weight_g < 100: return 85 if weight_g < 250: return 135 if weight_g <= 750: return 210 raise ValueError(f"{weight_g} g is too heavy for a letter") CASES = [(20, 85), (100, 85), (101, 135), (250, 135), (750, 210)] class TestPostage(unittest.TestCase): def test_price_table(self): for weight, pence in CASES: with self.subTest(weight=weight): self.assertEqual(postage(weight), pence) def test_too_heavy(self): with self.assertRaises(ValueError): postage(751) def run(case): result = unittest.TestResult() unittest.defaultTestLoader.loadTestsFromTestCase(case).run(result) print("ran", result.testsRun, "| failures", len(result.failures), "| errors", len(result.errors)) for test, details in result.failures + result.errors: print(" " + test.id().split(".", 1)[1], "->", details.strip().splitlines()[-1]) run(TestPostage)
When to use it: whenever one rule should hold for many inputs: price bands, validation rules, parsing formats. Choose rows deliberately: one inside each band, and both sides of every boundary (100 and 101, 250 and 251).
Mistakes to avoid: a table with only comfortable values in the middle of each band (it can't catch < versus <=); putting the assert outside the with self.subTest(...) block (the failure isn't attached to the row); and computing the expected values with the code under test, which makes the test agree with any bug.
- A table of
(input, expected)rows checks one rule on many inputs in one loop. with self.subTest(...)reports every failing row with its values, instead of stopping at the first.- Pick rows inside each band and on both sides of every boundary; never compute expected values with the code under test.
- Next: fresh test data for every test, and stand-ins for parts you don't want a test to call.