Why we test
Change one line deep in your program and three distant callers can quietly break, and nothing tells you, until a user does. Re-reading the code by eye does not scale, and "it worked when I wrote it" is a promise the code cannot keep on its own. A test is a machine-checked claim that a function still behaves the way you intend: it runs your expectations automatically, every time, and shouts the moment one is violated.
Tests are not bureaucracy bolted on at the end. Written alongside the code, they are the fastest way to know, within seconds, whether a change was safe. This lesson uses plain Python's assert, with no frameworks, so you can see exactly what a test is.
flowchart LR W["write a function"] --> T["write asserts"] T --> R["run them"] R -->|"all pass"| OK["ship / move on"] R -->|"one fails"| F["fix the code"] F --> R style OK fill:#15803d,color:#fff style F fill:#b45309,color:#fff
The assert statement
An assert is a single line that says this must be true. Write assert condition, and Python checks it: if the condition is truthy nothing happens and the test passed; if it is falsy Python raises AssertionError and stops. Add a comma and a message, assert n > 0, "n must be positive", and that message appears in the failure, which is far more useful than a bare error.
assert square(4) == 16
assert square(4) == 16, "square of 4 should be 16"
Read an assert as a tiny specification: "I claim square(4) equals 16, and I want the program to argue if it does not." A file full of passing asserts is a file full of claims the machine has confirmed.
def square(n):
return n * n
assert square(4) == 16
assert square(0) == 0
assert square(-3) == 9
print("square: all tests passed")
Bundling asserts into test functions
Scattered asserts are hard to run and hard to read. The smallest organising step is to group the asserts for one function under a named test_* function, then call it. The name documents what is being checked; grouping keeps related checks together; and when one fails, the function name in the traceback tells you which area broke.
def test_add():
assert add(2, 3) == 5
assert add(0, 0) == 0
test_add()
This is the seed of every test suite in the wild. Frameworks like pytest automate the discovery and running, but underneath they are calling functions exactly like this one and collecting their asserts.
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
test_add()
print("add: all tests passed")
flowchart TD
A["assert f(x) == expected"] --> B{"f(x) == expected?"}
B -->|"yes"| P["silent: the test passes"]
B -->|"no"| E["AssertionError names the line and message"]
E --> DBG["you know exactly what broke"]
style P fill:#15803d,color:#fff
style E fill:#b91c1c,color:#fff
Reading a failure
When an assert fails, the traceback points straight at the guilty line and prints your message. That is the whole point of testing made concrete: instead of debugging from a wrong final output, you start from a precise statement of which expectation broke and why. A failing test is not a nuisance; it is a free, specific bug report.
Cultivate the habit of reading the message before changing anything. Was the input unusual? Is the expected value itself wrong? Sometimes the test is the thing that is incorrect, and fixing the code to match a bad expectation would only embed the bug. Trust the test only once you trust its claim.
What makes a test worth writing
A strong test follows a small rhythm: arrange the input, act by calling the function, assert about the result. Test one behaviour per assert so a failure points at a single broken expectation. Most importantly, cover the edges, the empty list, the single element, the negative number, the boundary value, because those are where bugs hide. A test that only checks the happy path lulls you into false confidence.
Keep tests independent, too. Each should set up its own data and not rely on an earlier test having run. A suite where tests can run in any order is one you can trust, and one where a single failure does not cascade into a wall of misleading errors.
def first_or_default(items, default=None):
if items:
return items[0]
return default
def test_first():
assert first_or_default([1, 2, 3]) == 1
assert first_or_default([], "empty") == "empty"
assert first_or_default([], None) is None
test_first()
print("first_or_default: edge cases pass")
Tests catch regressions
The payoff of having tests arrives the day you refactor. You restructure a function for speed or clarity, run the suite, and the tests confirm the behaviour never changed. Without them every refactor is an act of faith; with them it is a measured change you can make fearlessly.
A test that fails after your edit has just saved you from shipping a bug, which is exactly the scenario that justifies the time spent writing it. New bugs become new tests, so the same mistake can never recur unnoticed, and your suite grows a little stronger every time something goes wrong.
Write clamp(n, lo, hi) that returns n pinned inside the range [lo, hi]: below lo it returns lo, above hi it returns hi, otherwise n. The tests below lock in all three cases, including the boundaries.
def clamp(n, lo, hi):
# below lo -> lo; above hi -> hi; otherwise n
pass
Two guards: one for below lo, one for above hi.
If neither fires, n is already inside the range, so return it unchanged.
def clamp(n, lo, hi):
if n < lo:
return lo
if n > hi:
return hi
return n
What does this print? The asserts pass silently, then the computed value is used.
def add(a, b):
return a + b
assert add(2, 3) == 5
total = add(10, 5)
assert total == 15
print(total)
add(2, 3) is 5, so the first assert passes silently.
total becomes 15, the second assert passes, and print(total) shows 15.
A test suite for max_value(nums) checks [1, 5, 3] and [7] and both pass, but it never checks an all-negative list. What is the weakness?
Edge cases like an all-negative list are exactly where functions that initialise a running maximum to zero break. Skipping them means the suite confirms the happy path but cannot detect that regression, so the bug ships.
The tests below catch a real bug: max_value([-5, -2, -9]) returns 0 instead of -2, because the running maximum is wrongly initialised to 0. Fix the initial value so every test, including the all-negative case, passes.
def max_value(nums):
best = 0
for n in nums:
if n > best:
best = n
return best
Initialising best to 0 ignores any value below zero.
Start best at nums[0], the first real element, so negatives are compared fairly.
def max_value(nums):
best = nums[0]
for n in nums:
if n > best:
best = n
return best
Write remove_duplicates(items) that returns a new list with duplicates dropped, keeping the first occurrence of each item in order. The tests include the edge cases (empty, all-same, no duplicates) that your function must handle.
def remove_duplicates(items):
seen = set()
result = []
# for each item: if not seen before, remember it and append
return result
Track what you have already kept in a set.
Append only when the item is not already in seen, then add it to seen.
def remove_duplicates(items):
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
Recap
- An assert checks a claim: a truthy condition passes silently, a falsy one raises
AssertionError. Add a message so failures explain themselves. - Group related asserts into
test_*functions and call them; this is the germ of every real test suite. - Write arrange-act-assert tests, one behaviour each, and always cover the edge cases (empty, single, boundary).
assertcan be stripped bypython -O, so use it for tests and internal checks only, never for validation or permissions.
That closes the course's core skill set: you can now read, write, organise, and verify real Python. The project that follows asks you to combine all of it.