Lesson 9 of 10
Right now you test by running the program and looking at the output. That works until the program has twenty functions and you change one of them. Automated tests are code that checks your code — you run them in a second and they tell you exactly what broke.
The idea, at its simplest
def add(a, b):
return a + b
assert add(2, 3) == 5
assert add(-1, 1) == 0
print("all good")
assert raises AssertionError if the condition is false and does nothing if it's true. That's
already a test. Real test frameworks just organise thousands of these and report nicely.
unittest
unittest is in the standard library — nothing to install.
import unittest
def add(a, b):
return a + b
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(2, 3), 5)
def test_negative(self):
self.assertEqual(add(-1, -1), -2)
The rules:
- Test class inherits from
unittest.TestCase. - Every test method's name starts with
test_— that's how they get discovered. - Each method makes assertions with
self.assert....
Save that as test_math.py and run it from your terminal:
python -m unittest
unittest walks the folder, finds files named test_*.py, finds TestCase classes inside
them, and runs every test_ method.
The assertion methods
| Method | Passes when |
| --- | --- |
| assertEqual(a, b) | a == b |
| assertNotEqual(a, b) | a != b |
| assertTrue(x) / assertFalse(x) | x is truthy / falsy |
| assertIsNone(x) / assertIsNotNone(x) | x is None / isn't |
| assertIn(a, b) | a in b |
| assertAlmostEqual(a, b) | floats are close enough |
| assertRaises(Error) | the block raises that error |
| assertIsInstance(x, T) | isinstance(x, T) |
Use the specific one. assertEqual(total, 100) prints 100 != 95 when it fails;
assertTrue(total == 100) prints only False is not true.
assertAlmostEqual exists because floats don't compare exactly — 0.1 + 0.2 != 0.3.
Testing that something raises
import unittest
def divide(a, b):
if b == 0:
raise ValueError("cannot divide by zero")
return a / b
class TestDivide(unittest.TestCase):
def test_raises_on_zero(self):
with self.assertRaises(ValueError):
divide(1, 0)
The test passes when the error is raised. Error paths need tests as much as happy paths — usually more, since nobody exercises them by hand.
setUp and tearDown
setUp runs before every test method, tearDown after. Use them for shared fixtures:
import unittest
class TestCart(unittest.TestCase):
def setUp(self):
self.cart = []
def test_starts_empty(self):
self.assertEqual(len(self.cart), 0)
def test_can_add(self):
self.cart.append("mouse")
self.assertEqual(len(self.cart), 1)
Each test gets a fresh self.cart. That matters: tests must not depend on each other or on
the order they run in. One test leaking state into the next is the classic reason a suite
"passes locally but fails in CI".
What pytest adds
pytest is third-party (pip install pytest) and it's what most projects actually use. Same
job, less ceremony — plain functions and plain assert:
# test_math.py
from mymath import add, divide
import pytest
def test_add():
assert add(2, 3) == 5
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)
Run it with pytest. No classes, no self.assertEqual — pytest rewrites assert so a failure
still shows you both sides.
Two features worth knowing about:
import pytest
@pytest.fixture
def cart():
return []
def test_starts_empty(cart):
assert len(cart) == 0
@pytest.mark.parametrize("a,b,expected", [(2, 3, 5), (-1, 1, 0), (0, 0, 0)])
def test_add_many(a, b, expected):
assert a + b == expected
A fixture is setUp as a reusable function — ask for it by name in the parameter list.
parametrize runs the same test once per row of data, and reports each row separately.
pytest also runs unittest test classes unchanged, so migrating is painless.
The exercises below use
unittestbecause it's in the standard library and runs right here in your browser.pytestneeds installing, so try it in a real project — the concepts transfer exactly.
Running tests inside a script
Normally you run tests from the terminal. To run them here, load the class into a suite and run
it yourself — that's all python -m unittest does under the hood.
Try it yourself
What a failure looks like
One test below is deliberately wrong. Notice the suite still runs every other test — a failure doesn't stop the run.
Fix the expected total to 200 and re-run — everything passes. That loop of red, fix, green
is what testing feels like in practice.
What to test
You can't test everything, so aim at what breaks:
- The happy path — the normal case works.
- The edges — empty list, zero, one item, the maximum, negative numbers.
- The errors — bad input raises what you promised it would.
- Every bug you fix — write the test that fails first, then fix it. That bug never comes back.
And structure each test as arrange, act, assert: set up the data, run the thing, check the
result. One behaviour per test, and a name that says what it checks —
test_negative_price_rejected beats test_2.
Don't test the standard library, and don't chase 100% coverage. Coverage measures which lines ran, not whether they're correct.
Checkpoint
- A test is code that checks your code;
assertis the whole idea in one keyword. unittest: subclassTestCase, name methodstest_*, assert withself.assertEqualand friends. Run withpython -m unittest.assertRaisestests the error path;assertAlmostEqualtests floats.setUpruns before each test so tests stay independent.pytestdoes the same with plain functions, plainassert, fixtures, andparametrize.- Test the happy path, the edges, the errors, and every bug you've ever fixed.
Questions about this lesson
Log in to ask a question.