Warm-up · Activity 1 of 7
// I5.1 · ~30 min · Intermediate
Testing with unittest
After this lesson you can write unittest test cases that check results and exceptions, give each test a fresh fixture with setUp, and run a suite from code and read what failed.
Lesson 1 of 5 in I5 Testing and project tooling
You will be able to
- Write a TestCase whose test_ methods use assertEqual, assertTrue and assertRaises
- Use setUp so that every test starts from a fresh fixture
- Run a suite from code and read testsRun, failures, errors and wasSuccessful()
Predict · Activity 2 of 7
Predict before you read on: how many tests does this suite run?
import unittest class TestText(unittest.TestCase): def test_upper(self): self.assertEqual("abc".upper(), "ABC") def check_lower(self): self.assertEqual("ABC".lower(), "abc") suite = unittest.TestLoader().loadTestsFromTestCase(TestText) result = unittest.TestResult() suite.run(result) print(result.testsRun)Practice · Activity 3 of 7
Fill in the assert method so that the test passes exactly when int("ten") raises ValueError.
with self.____(ValueError): int("ten")with self.(ValueError):Practice · Activity 4 of 7
setUp counts its calls on the class. What does this print?
import unittest class TestCounter(unittest.TestCase): calls = 0 def setUp(self): TestCounter.calls += 1 self.items = [] def test_a(self): self.items.append(1) self.assertEqual(len(self.items), 1) def test_b(self): self.items.append(2) self.assertEqual(len(self.items), 1) def test_c(self): self.assertEqual(self.items, []) suite = unittest.TestLoader().loadTestsFromTestCase(TestCounter) result = unittest.TestResult() suite.run(result) print(TestCounter.calls, result.wasSuccessful())Practice · Activity 5 of 7
A suite ran three tests: one passed, one assertEqual failed, one raised KeyError. Match each part of the result to its value.
Brain teaser · Activity 6 of 7
Brain teaser. add() has a bug. What does this print?
import unittest def add(a, b): return a - b # bug class TestAdd(unittest.TestCase): def test_add(self): self.assertTrue(add(2, 3), 5) result = unittest.TestResult() unittest.TestLoader().loadTestsFromTestCase(TestAdd).run(result) print(result.wasSuccessful())Apply · Activity 7 of 7
Mini-task. Here is word_count(text), which returns len(text.split()). Write a TestCase with at least four tests: a normal sentence, the empty string, extra spaces, and a number instead of text (split does not exist on int, so AttributeError). Run the suite and print testsRun and wasSuccessful().
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Testing a shopping basket
A Basket stores item prices in cents. Its tests share a fixture from setUp, check totals with assertEqual and exceptions with assertRaises. remove() has a bug: it ignores an item that is not there instead of raising KeyError. The program runs the suite into a TestResult and prints what it found, including the failing test. Fix remove() with del self.items[name] and run it again.
main.py
import unittest
class Basket:
"""A shopping basket: item names mapped to prices in cents."""
def __init__(self) -> None:
self.items: dict[str, int] = {}
def add(self, name: str, cents: int) -> None:
if cents < 0:
raise ValueError(f"negative price: {cents}")
self.items[name] = cents
def remove(self, name: str) -> None:
self.items.pop(name, None) # bug: a missing item is ignored silently
def total(self) -> int:
return sum(self.items.values())
class TestBasket(unittest.TestCase):
def setUp(self) -> None:
# Runs before every test: each test gets a new basket with one item.
self.basket = Basket()
self.basket.add("tea", 350)
def test_total(self) -> None:
self.basket.add("cake", 425)
self.assertEqual(self.basket.total(), 775)
def test_remove(self) -> None:
self.basket.remove("tea")
self.assertEqual(self.basket.total(), 0)
def test_negative_price(self) -> None:
with self.assertRaises(ValueError):
self.basket.add("refund", -100)
def test_remove_missing(self) -> None:
with self.assertRaises(KeyError):
self.basket.remove("coffee")
suite = unittest.TestLoader().loadTestsFromTestCase(TestBasket)
result = unittest.TestResult()
suite.run(result)
print("tests run:", result.testsRun)
print("failures:", len(result.failures))
print("errors:", len(result.errors))
for test, trace in result.failures:
print("FAIL:", test.id())
print(" reason:", trace.strip().splitlines()[-1])
print("all passed:", result.wasSuccessful())
Run it with
python main.pyOutput
tests run: 4
failures: 1
errors: 0
FAIL: __main__.TestBasket.test_remove_missing
reason: AssertionError: KeyError not raised
all passed: False- test_remove sees a total of 0 after removing tea: the cake from test_total is not in its basket, because setUp built a new one.
- The failing test is named by test.id(): module, class and method.
- The last line of the traceback, "KeyError not raised", is the message assertRaises gives when the block ends normally.
- failures is 1 and errors is 0: the bug makes an assert method fail, it does not crash the test.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
Tests that catch bugs
apply_discount is correct. Your job is its tests: add at least three test methods to TestDiscount. Check that 50 percent off 80.0 is 40.0, and that a percent above 100 and a negative percent both raise ValueError. The checks run your tests against the real function, which must pass, and against three broken versions, which your tests must catch.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
A new test is a new method in the class whose name starts with test, for example def test_half_price(self) -> None:.
Hint 2
For the value: self.assertEqual(apply_discount(80.0, 50), 40.0).
Hint 3
For each error case: with self.assertRaises(ValueError): and, indented under it, the call that must raise.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import unittest
def apply_discount(price: float, percent: float) -> float:
"""Returns price reduced by percent, rounded to cents."""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0-100, got {percent}")
return round(price * (100 - percent) / 100, 2)
class TestDiscount(unittest.TestCase):
def test_no_discount(self) -> None:
self.assertEqual(apply_discount(80.0, 0), 80.0)
def test_half_price(self) -> None:
self.assertEqual(apply_discount(80.0, 50), 40.0)
def test_percent_too_high(self) -> None:
with self.assertRaises(ValueError):
apply_discount(80.0, 120)
def test_negative_percent(self) -> None:
with self.assertRaises(ValueError):
apply_discount(80.0, -5)
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestDiscount)
unittest.TextTestRunner(verbosity=2).run(suite)
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import unittest
def apply_discount(price: float, percent: float) -> float:
"""Returns price reduced by percent, rounded to cents."""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0-100, got {percent}")
return round(price * (100 - percent) / 100, 2)
class TestDiscount(unittest.TestCase):
def test_no_discount(self) -> None:
self.assertEqual(apply_discount(80.0, 0), 80.0)
# Add three more tests:
# - 50 percent off 80.0 is 40.0
# - a percent above 100 raises ValueError
# - a negative percent raises ValueError
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestDiscount)
unittest.TextTestRunner(verbosity=2).run(suite)
test_main.py
import unittest
import main
def run_against(implementation):
saved = main.apply_discount
main.apply_discount = implementation
try:
result = unittest.TestResult()
unittest.TestLoader().loadTestsFromTestCase(main.TestDiscount).run(result)
finally:
main.apply_discount = saved
return result
def subtracts_percent(price, percent):
if not 0 <= percent <= 100:
raise ValueError(percent)
return round(price - percent, 2)
def no_upper_limit(price, percent):
if percent < 0:
raise ValueError(percent)
return round(price * (100 - percent) / 100, 2)
def no_lower_limit(price, percent):
if percent > 100:
raise ValueError(percent)
return round(price * (100 - percent) / 100, 2)
def test_correct_code_passes():
"""Your tests pass on the correct apply_discount"""
result = run_against(main.apply_discount)
failing = [test.id() for test, _ in result.failures + result.errors]
assert result.wasSuccessful(), f"these tests fail on correct code: {failing}"
assert result.testsRun >= 4, f"{result.testsRun} tests ran; write at least 4"
def test_catches_wrong_formula():
"""Your tests catch a formula that subtracts the percent as money"""
result = run_against(subtracts_percent)
assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, 50) returns 30.0; check a real discount"
def test_catches_missing_upper_limit():
"""Your tests catch a missing check for a percent above 100"""
result = run_against(no_upper_limit)
assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, 120) returns a price; check that it raises ValueError"
def test_catches_missing_lower_limit():
"""Your tests catch a missing check for a negative percent"""
result = run_against(no_lower_limit)
assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, -5) returns a price; check that it raises ValueError"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
A one-line test report
Write summarize(case), which takes a TestCase class, runs all its tests and returns one line such as "2 run, 1 failed, 0 errors: FAILED". Use OK instead of FAILED when every test passed. Load the tests with unittest.TestLoader().loadTestsFromTestCase and run them into a unittest.TestResult. Run prints the report for TestDemo.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
suite = unittest.TestLoader().loadTestsFromTestCase(case), then result = unittest.TestResult() and suite.run(result).
Hint 2
result.failures and result.errors are lists: count them with len().
Hint 3
status = "OK" if result.wasSuccessful() else "FAILED", then build the line with an f-string.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import unittest
def summarize(case: type[unittest.TestCase]) -> str:
suite = unittest.TestLoader().loadTestsFromTestCase(case)
result = unittest.TestResult()
suite.run(result)
status = "OK" if result.wasSuccessful() else "FAILED"
return f"{result.testsRun} run, {len(result.failures)} failed, {len(result.errors)} errors: {status}"
class TestDemo(unittest.TestCase):
def test_ok(self) -> None:
self.assertEqual(1 + 1, 2)
def test_wrong(self) -> None:
self.assertEqual(1 + 1, 3)
if __name__ == "__main__":
print(summarize(TestDemo))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import unittest
def summarize(case: type[unittest.TestCase]) -> str:
# 1. Load the tests of case with unittest.TestLoader().loadTestsFromTestCase.
# 2. Run the suite into a unittest.TestResult.
# 3. Return "<run> run, <failed> failed, <errors> errors: OK",
# with FAILED instead of OK when a test did not pass.
return ""
class TestDemo(unittest.TestCase):
def test_ok(self) -> None:
self.assertEqual(1 + 1, 2)
def test_wrong(self) -> None:
self.assertEqual(1 + 1, 3)
if __name__ == "__main__":
print(summarize(TestDemo))
test_main.py
import unittest
from main import summarize
class AllPass(unittest.TestCase):
def test_one(self):
self.assertEqual(len("ab"), 2)
def test_two(self):
self.assertTrue("abc".startswith("a"))
class Mixed(unittest.TestCase):
def test_pass(self):
self.assertEqual(3 * 3, 9)
def test_fail(self):
self.assertEqual(3 * 3, 6)
def test_error(self):
int("nine")
class NoTests(unittest.TestCase):
def check_something(self):
self.assertEqual(1, 2)
def test_all_pass():
"""Two passing tests give OK"""
got = summarize(AllPass)
assert got == "2 run, 0 failed, 0 errors: OK", f"summarize returned {got!r}"
def test_mixed():
"""A failure and an error are counted separately"""
got = summarize(Mixed)
assert got == "3 run, 1 failed, 1 errors: FAILED", f"summarize returned {got!r}"
def test_no_tests():
"""A class without test_ methods runs nothing"""
got = summarize(NoTests)
assert got == "0 run, 0 failed, 0 errors: OK", f"summarize returned {got!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Using the old name assertEquals
import sys
import unittest
class TestTotal(unittest.TestCase):
def test_sum(self):
self.assertEquals(sum([1, 2, 3]), 6)
suite = unittest.TestLoader().loadTestsFromTestCase(TestTotal)
result = unittest.TextTestRunner().run(suite)
sys.exit(0 if result.wasSuccessful() else 1)
What Python prints
AttributeError: 'TestTotal' object has no attribute 'assertEquals'. Did you mean: 'assertEqual'?Why, and the fix
Old tutorials use assertEquals, an alias that newer Python versions no longer have. The method is assertEqual, without the s. The runner reports the test as an error rather than a failure, because the test crashed before it could compare anything.
Calling the function inside assertRaises
import sys
import unittest
class TestParse(unittest.TestCase):
def test_bad_number(self):
self.assertRaises(ValueError, int("x"))
suite = unittest.TestLoader().loadTestsFromTestCase(TestParse)
result = unittest.TextTestRunner().run(suite)
sys.exit(0 if result.wasSuccessful() else 1)
What Python prints
ValueError: invalid literal for int() with base 10: 'x'Why, and the fix
int("x") runs before assertRaises is even called, so the exception escapes and the test is an error. Either pass the function and its arguments separately, self.assertRaises(ValueError, int, "x"), so assertRaises makes the call, or use the with form: with self.assertRaises(ValueError): int("x").
A typo in TestCase
import unittest
class TestUpper(unittest.Testcase):
def test_upper(self):
self.assertEqual("abc".upper(), "ABC")
What Python prints
AttributeError: module 'unittest' has no attribute 'Testcase'. Did you mean: 'TestCase'?Why, and the fix
Names in Python are case-sensitive, and the class is TestCase with a capital C. The error appears as soon as the class statement runs, before any test, which is why Python can suggest the right name.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.