Why this matters
So far you have checked your programs by running them and looking at the output. That works once. But programs change: you fix a bug, add a feature, clean up a function, and something that used to work quietly breaks. A test is code that checks other code automatically, so you can re-check everything in a second after every change. Professional programmers spend a large share of their time writing tests, not because they enjoy paperwork, but because tests are what let them change code without fear. learnprog itself checks your exercises with tests; in this module you write them.
What a test is
A test runs a piece of code with a known input and compares the actual result with the expected result:
input --> code under test --> actual result
|
expected result -- compare --> pass / fail
The smallest useful unit is a function, which is one reason the earlier modules pushed you toward functions that take arguments and return values instead of reading input and printing. A function like that is trivial to test; a script that mixes input(), logic and print() is not.
assert
Python's built-in tool for a check is the assert statement:
assert condition, "message shown if it fails"
If the condition is true, nothing happens. If it is false, Python raises AssertionError with the message:
def area(width, height):
return width * height
assert area(3, 4) == 12
assert area(0, 5) == 0
assert area(2, 2) == 5, "2 by 2 should be 4, this assert is wrong on purpose"
print("all good")
Traceback (most recent call last):
File "demo.py", line 6, in <module>
assert area(2, 2) == 5, "2 by 2 should be 4, this assert is wrong on purpose"
^^^^^^^^^^^^^^^
AssertionError: 2 by 2 should be 4, this assert is wrong on purpose
The first failing assert stops the program, so all good never prints. The traceback shows which check failed. Underneath, assert cond, msg compiles to roughly if __debug__ and not cond: raise AssertionError(msg).
Grouping asserts into test functions
Collect the checks for one function into a test function, conventionally named test_<thing>:
def is_even(n):
return n % 2 == 0
def test_is_even():
assert is_even(0)
assert is_even(2)
assert not is_even(3)
assert is_even(-4)
assert not is_even(-1)
test_is_even()
print("test_is_even passed")
Each assert is a test case: one input with one expected outcome. Test functions take no arguments and return nothing: they either complete (pass) or raise AssertionError (fail). That convention is exactly what testing tools such as pytest look for, and what the next lesson's unittest formalizes.
Choosing test cases
The hard part of testing is not the syntax but deciding what to check. Good tests are chosen to expose likely bugs.
Typical cases confirm the ordinary path works: average([2, 4, 6]) == 4.
Edge cases sit at boundaries, where most bugs live:
- empty input:
"",[],0 - exactly one item
- the first and last positions (off-by-one errors)
- negative numbers and zero
- very large values
- duplicates, or input that is already sorted, or sorted backwards
- results that are not whole numbers (an accidental
//instead of/)
Error cases check that bad input is rejected the way the function promises: the right exception, not a wrong answer. You check those with try/except:
def test_average_empty():
try:
average([])
except ValueError:
pass # the promised error: good
else:
assert False, "average([]) should raise ValueError"
The else branch of a try runs only if no exception occurred, which here means the function failed to complain.
Rules with exceptions deserve one case per branch. For the leap-year rule (divisible by 4, except centuries, except every 400 years) you need at least four cases: 2024, 2023, 1900 and 2000. A test suite that only checks 2024 passes even for the naive year % 4 == 0.
Here is a useful way to judge your tests: imagine a plausible bug, and ask whether some test would fail. "What if I forgot to sort?" Then you need an unsorted input. "What if I modified the caller's list?" Then you need a check that the argument is unchanged afterwards. This is exactly how the exercises in this lesson grade you: they plant bugs and see whether your tests notice. (Professionals call this idea mutation testing.)
Tests as specification and safety net
Tests do more than catch mistakes:
- They document behavior.
assert median([4, 1, 3, 2]) == 2.5tells a reader exactly what the function does with even-length input, more precisely than prose. - They make refactoring safe. You can rewrite a function's insides completely and know within a second whether it still behaves the same.
- Regression tests pin down bugs. When you find a bug, first write a test that fails because of it, then fix the code. The test stays forever, so the bug cannot quietly return.
- Writing tests first (test-driven development, TDD) forces you to decide what a function should do before deciding how. Try it on the next function you write: tests first, then code until they pass.
assert is not input validation
assert statements can be switched off. Running python3 -O program.py ("optimize") compiles the program with the built-in constant __debug__ set to False, and every assert is removed from the bytecode. So:
def set_age(age):
assert age >= 0 # WRONG: disappears under -O
...
def set_age(age):
if age < 0: # RIGHT: always runs
raise ValueError(f"age must be non-negative, got {age}")
...
Use assert in tests, and inside your own code only for internal sanity checks, conditions that can only be false if you made a programming mistake ("this list should be sorted by now"). Never use it to check user input, file contents or anything from outside.
Common mistakes
- Asserting a tuple.
assert (x == 1, "x should be 1")puts parentheses around both parts, making a non-empty tuple, which is always true. Python 3.13 warnsSyntaxWarning: assertion is always true, perhaps remove parentheses?. Writeassert x == 1, "x should be 1". - Testing floats with
==.assert 0.1 + 0.2 == 0.3fails. Usemath.isclose(a, b). - Tests that cannot fail.
assert median(data) == median(data)checks nothing. A good test has an expected value you worked out independently, by hand or from the specification, not by running the code under test. - Only testing the happy path. Most real bugs are in edge cases.
- Printing instead of asserting.
print(is_leap(1900))requires a human to read and judge the output every time; an assert judges it for you.
Recap
- A test runs code on a known input and compares actual with expected output; functions that take arguments and return values are easy to test.
assert cond, msgraisesAssertionErrorwhencondis false; group asserts intotest_...functions.- Choose cases deliberately: typical cases, edge cases, error cases, and one case per branch of a rule. Ask "which plausible bug would this catch?"
python3 -Ostrips asserts: never use them to validate input.
Going further: write a test-first version of a small function, for example clamp(value, low, high): write test_clamp with five asserts before writing clamp. Then read the assert section of the Python Language Reference ("Simple statements").