A test is a small program that runs your program and complains if the answer is wrong. The reason to write them is not correctness today — it is the freedom to change things tomorrow.
assert
The simplest test is a claim that must hold.
def with_tax(amount, rate=0.2):
return round(amount * (1 + rate), 2)
assert with_tax(100) == 120.0
assert with_tax(100, 0.1) == 110.0
assert with_tax(0) == 0.0
print("all assertions passed")
assert with_tax(19.99) == 24.0, f"got {with_tax(19.99)} instead"An assert that holds does nothing at all; one that fails raises
AssertionError with your message. That last line fails on purpose — and the
message tells you the actual value, which a bare assertion would not.
assert is for tests, not for validation
Python removes every assert when run with the -O flag, so never use one to
check user input or enforce security. For those, raise ValueError — Day 5.
Writing tests as functions
Group related assertions into named functions, so a failure says what broke.
def with_tax(amount, rate=0.2):
return round(amount * (1 + rate), 2)
def test_default_rate():
assert with_tax(100) == 120.0
def test_custom_rate():
assert with_tax(100, 0.1) == 110.0
def test_zero():
assert with_tax(0) == 0.0
def test_rounds_to_cents():
assert with_tax(19.99) == 23.99
tests = [test_default_rate, test_custom_rate, test_zero, test_rounds_to_cents]
passed = 0
for test in tests:
try:
test()
passed += 1
print(f" PASS {test.__name__}")
except AssertionError as error:
print(f" FAIL {test.__name__}: {error or 'assertion failed'}")
print(f"{passed}/{len(tests)} passed")That loop is, in miniature, what a test framework does.
unittest
unittest ships with Python, so it is always available.
import unittest
def with_tax(amount, rate=0.2):
if amount < 0:
raise ValueError("amount cannot be negative")
return round(amount * (1 + rate), 2)
class TestWithTax(unittest.TestCase):
def test_default_rate(self):
self.assertEqual(with_tax(100), 120.0)
def test_custom_rate(self):
self.assertEqual(with_tax(100, 0.1), 110.0)
def test_rounds_to_cents(self):
self.assertAlmostEqual(with_tax(19.99), 23.99)
def test_rejects_negative(self):
with self.assertRaises(ValueError):
with_tax(-1)
# In a file you would finish with unittest.main(). This page runs inside a
# browser runtime with no script module for it to inspect, so the suite is
# loaded and run explicitly instead.
suite = unittest.TestLoader().loadTestsFromTestCase(TestWithTax)
unittest.TextTestRunner(verbosity=2).run(suite)If you skipped Inheritance: (unittest.TestCase) after the class name means
your class starts with everything TestCase already has — the assert...
methods and the hooks the runner looks for — and adds your tests to it. That’s
all you need to know to write tests this way.
Useful assertions: assertEqual, assertTrue, assertIn, assertIsNone,
assertAlmostEqual (for floats — remember Day 1), and assertRaises as a
context manager for expected errors.
setUp
setUp runs before every test method, so each test starts from a clean state.
import unittest
class Cart:
def __init__(self):
self.items = []
def add(self, name, price):
self.items.append((name, price))
def total(self):
return round(sum(price for _, price in self.items), 2)
class TestCart(unittest.TestCase):
def setUp(self):
self.cart = Cart() # a brand new cart for every test
def test_starts_empty(self):
self.assertEqual(self.cart.total(), 0)
self.assertEqual(len(self.cart.items), 0)
def test_adds_items(self):
self.cart.add("keyboard", 49.99)
self.cart.add("mouse", 25.50)
self.assertEqual(self.cart.total(), 75.49)
def test_isolated_from_other_tests(self):
self.assertEqual(len(self.cart.items), 0)
suite = unittest.TestLoader().loadTestsFromTestCase(TestCart)
unittest.TextTestRunner(verbosity=2).run(suite)Tests must not depend on each other or on their order. setUp is how you
guarantee that.
pytest
Outside the standard library, almost everyone uses pytest. There is no class
and no special assertion methods — plain functions and plain assert:
# test_tax.py
import pytest
from tax import with_tax
def test_default_rate():
assert with_tax(100) == 120.0
def test_rejects_negative():
with pytest.raises(ValueError):
with_tax(-1)
@pytest.mark.parametrize("amount,expected", [
(0, 0.0),
(100, 120.0),
(19.99, 23.99),
])
def test_various_amounts(amount, expected):
assert with_tax(amount) == expected
Install it with pip install pytest, then run pytest in your project folder;
it finds files named test_*.py and functions named test_* by itself.
What to test
Test the edges, not the middle. For a function, that usually means:
- A typical case that should obviously work.
- Zero, empty, one — the boundaries.
- Something invalid, and the error it should raise.
- Any bug you have ever fixed, so it cannot come back.
Write the test when you fix the bug
The moment a bug is fresh is the cheapest moment to capture it. A test written then is worth three written later from memory.
Before you write your own
Predict. Three checks pass. Then an assert fails — but is the function
wrong?
def cheapest(prices):
return min(prices)
results = []
for prices, expected in [([3, 1, 2], 1), ([5], 5), ([2, 2], 2)]:
results.append(cheapest(prices) == expected)
print(results)
try:
assert cheapest([4, 9]) == 9, "wanted the cheapest"
except AssertionError as error:
print("failed:", error)Check your prediction
[True, True, True]
failed: wanted the cheapestcheapest([4, 9]) is 4, correctly — it’s the test that’s wrong: it expects 9.
When a test fails, check the expectation as carefully as the code. Tests are
code too, and they have bugs.
Fix. An empty list should average to 0. Two checks pass, then the third crashes the whole run. Fix the function — not the checks — so all three pass.
def average(values):
return sum(values) / len(values)
def check(description, actual, expected):
status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
print(f" {status} {description}")
check("typical", average([2, 4, 6]), 4)
check("single", average([5]), 5)
check("empty", average([]), 0)Check your fix
def average(values):
if not values:
return 0
return sum(values) / len(values)
def check(description, actual, expected):
status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
print(f" {status} {description}")
check("typical", average([2, 4, 6]), 4)
check("single", average([5]), 5)
check("empty", average([]), 0) PASS typical
PASS single
PASS emptyThe empty case is the boundary from What to test, and it’s exactly the one that was missing. The check was right; the function had never been asked the question.
Test a function you did not write
initials("ada lovelace") should return "A.L.". Write tests covering a
normal name, a single name, an empty string, and extra whitespace — then fix
the function so they all pass. You’re done when every line says PASS.
def initials(full_name):
parts = full_name.split(" ")
return ".".join(part[0].upper() for part in parts) + "."
def check(description, actual, expected):
status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
print(f" {status} {description}")
check("normal name", initials("ada lovelace"), "A.L.")
# Add your checks hereHint 1: the idea
Tests first, fix second. Each case in the brief is one check(...) line saying
what the answer should be. Write all four before touching the function, run
them, and let the failures show you what’s wrong. For the empty string you’ll
have to decide what the right answer is — nobody has decided yet.
Hint 2: a first step
Add check("single name", initials("grace"), "G."), then the empty string, then
" ada lovelace ". When one crashes rather than printing FAIL, find out
what the function was working with: print(" ada lovelace ".split(" "))
shows you.
Show one solution
def initials(full_name):
"""Return dotted initials, e.g. 'ada lovelace' -> 'A.L.'."""
parts = full_name.split() # split() with no argument handles
if not parts: # runs of whitespace and empty input
return ""
return ".".join(part[0].upper() for part in parts) + "."
def check(description, actual, expected):
status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
print(f" {status} {description}")
check("normal name", initials("ada lovelace"), "A.L.")
check("single name", initials("grace"), "G.")
check("empty string", initials(""), "")
check("extra whitespace", initials(" ada lovelace "), "A.L.")
check("three names", initials("ada byron lovelace"), "A.B.L.")Two bugs surfaced. split(" ") on " ada " produces empty strings, and
part[0] on an empty string raises IndexError; bare split() handles both.
And the empty input needed a decision — returning "" rather than "." — which
is exactly the kind of question writing tests forces you to answer.
What you learned
assert claim, messageis the smallest possible test.- Never use
assertfor validating real input — it can be optimised away. unittestgives you test classes,setUp, and assertions likeassertRaises.pytestdoes the same with plain functions and plainassert.- Test the boundaries, the errors, and every bug you fix.