Python Module 12 – Testing, debugging and code quality
Why and how to test Python code
What unit, integration and end-to-end tests check, how to write tests with plain assert, which edge cases to try, and how to make code easy to test.
What you will learn
- Write tests as plain functions with assert statements, and give a failure a message
- Explain what unit, integration and end-to-end tests each check, and why most tests should be unit tests
- Recognise a test that fails for a reason other than a bug, such as data shared with another test
- Choose edge cases at boundaries, empty input and wrong types
- Refactor code so that the clock and random numbers become inputs
Before you start
What you’ll learn · Before you start
What you will learn
- Write tests as plain functions with assert statements, and give a failure a message
- Explain what unit, integration and end-to-end tests each check, and why most tests should be unit tests
- Recognise a test that fails for a reason other than a bug, such as data shared with another test
- Choose edge cases at boundaries, empty input and wrong types
- Refactor code so that the clock and random numbers become inputs
Before you start
On this page
An online shop charges 40 rupees for delivery and waives the fee from 499 rupees up. Write that rule with one wrong
character, > where it says “from 499”, and every order of exactly 499 pays the fee, with no error anywhere. A
test is a small program that notices: it runs your code with an input you chose, such as 499, and checks that the result is the one you
expected. In Python a test can be as plain as a function holding an assert statement.
Code keeps changing, and every fix, new feature or clean-up can break something that used to work; a suite of tests notices within seconds, long before a user does. Below you can step through tests as a tiny runner runs them, measure why most tests should be small, watch a test fail for the wrong reason and pick the inputs that find bugs. The last section reshapes code that reads the clock, so that it can be tested on any day.
A test runs your code and checks one result
Here is the delivery rule with three tests and a short loop that runs them:
"""Three tests for a delivery-fee rule, and a loop that runs them."""
def delivery_fee(order_rupees):
"""Return the delivery fee in rupees: 40 below ₹499, nothing from ₹499 up."""
if order_rupees < 0:
raise ValueError(f"an order total cannot be negative: {order_rupees}")
return 0 if order_rupees >= 499 else 40
def test_small_order_pays_the_fee():
assert delivery_fee(120) == 40
def test_fee_is_waived_from_499():
assert delivery_fee(499) == 0
assert delivery_fee(5000) == 0
def test_negative_total_is_refused():
try:
delivery_fee(-1)
except ValueError:
return
raise AssertionError("delivery_fee(-1) did not raise ValueError")
# Call every function whose name starts with test_, as a test runner does.
for name, test in list(globals().items()):
if name.startswith("test_"):
try:
test()
except AssertionError as error:
print("FAIL", name, error)
else:
print("PASS", name) Output
PASS test_small_order_pays_the_fee PASS test_fee_is_waived_from_499 PASS test_negative_total_is_refused
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_tests.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each test calls the function with one input and asserts one fact about the result. assert condition does nothing
when the condition is true and raises AssertionError when it is false; the loop at the bottom calls every function
whose name starts with test_ and reports which ones raised. That loop is a tiny test
runner. Python’s unittest module and the pytest package are full-size runners,
and the next lessons of this module use them; the ideas in this lesson are the same in all three. Write tests while
you write the code, run them before every change you keep, and add one for every bug you fix, so that it cannot come
back unnoticed.
A test earns its keep on the day the code breaks. Here the same tests meet a version of the rule with one character
wrong, > where the rule says “from 499”. Step through the run: the runner calls each test, the test calls the
rule, and the failure travels back to the runner:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Find the tests. The loop at the bottom of failing_test.py goes through the module's names and takes every function whose name starts with test_. It finds two.
- Call the first test. The runner calls the first test. A test takes no arguments, because it arranges everything it needs itself.
- Act. The test calls the rule once, with the input that sits on the boundary of the rule, an order of exactly 499 rupees.
- The bug answers. The rule tests 499 > 499, which is false, so it returns the fee, 40. Delivery is free from 499, so the right answer is 0.
- assert 40 == 0. The assert compares 40 with 0. The comparison is false, so assert raises AssertionError. This assert has no message, so the error carries none.
- FAIL, and nothing more. The runner catches the error and prints FAIL, the test's name and the error's text, which is empty: ''. The name says which promise broke, but not how.
- The second test. The runner calls the second test, which asks the rule about 499 in the same way and gets the same wrong fee, 40.
- A message after the comma. This assert has a message after the comma, an f-string with the fee in it, so its AssertionError carries the words "an order of ₹499 should travel free, but the fee was ₹40".
- A failure that explains itself. The runner prints FAIL with that message. Both tests caught the same bug, but only the second report says what went wrong.
The program behind the figure prints the runner’s two lines. Press Edit, change > back to >= and run it again:
"""The same rule with a bug: an order of exactly ₹499 still pays the fee."""
def delivery_fee(order_rupees):
if order_rupees < 0:
raise ValueError(f"an order total cannot be negative: {order_rupees}")
return 0 if order_rupees > 499 else 40 # the rule says "from ₹499", so this should be >=
def test_fee_is_waived_from_499():
assert delivery_fee(499) == 0
def test_fee_is_waived_from_499_with_a_message():
fee = delivery_fee(499)
assert fee == 0, f"an order of ₹499 should travel free, but the fee was ₹{fee}"
for name, test in list(globals().items()):
if name.startswith("test_"):
try:
test()
except AssertionError as error:
print("FAIL", name, repr(str(error)))
else:
print("PASS", name) Output
FAIL test_fee_is_waived_from_499 '' FAIL test_fee_is_waived_from_499_with_a_message 'an order of ₹499 should travel free, but the fee was ₹40'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 failing_test.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both tests fail, but the first one says nothing about why: a bare assert raises an error with an empty message.
The second adds a message after the comma, and the failure explains itself. pytest goes further and prints the
values that were compared without any message, which is one reason many projects choose it.
An assert that checks a user's input
assert total >= 0 in program code stops checking when Python runs with the
-O option: A command-line option that runs Python without its assert statements and without the code that depends on __debug__. Setting the PYTHONOPTIMIZE environment variable does the same. More in “Raising exceptions and custom error classes”, which skips every assert statement, so the check silently
disappears. Program code raises a real exception instead, such as the ValueError that delivery_fee() raises for
a negative total. Keep assert for tests.
Checkpoint
Check: A test checks one result
Unit, integration and end-to-end tests differ in how much they run
Tests differ in how much of the program they run at once. The figure nests the three kinds, with the delivery rule as the example:
What each kind of test runs
Text description of the diagram
Three nested boxes show how much of a program each kind of test runs.
- The innermost box is a unit test: it calls one function, delivery_fee(650), in memory and checks that the result is 0. Nothing else takes part.
- Around it is an integration test: the same code, plus a real part of the outside world, here an SQLite file named orders.db that the order is saved to and read back from.
- The outer box is an end-to-end test: it starts the whole program the way a user would, with the command python shop.py --order 650, and checks what the user sees at the end, the line "Fee: ₹0".
The further out a test sits, the more of the program it checks at once, and the slower it runs.
A unit test checks one function or class on its own, in memory, with nothing else taking part, so it runs in microseconds and points straight at the code that broke. An integration test checks your code working with a real part of the outside world, such as a database file, a folder of files or another module of the project. An end-to-end test runs the whole program the way its users do, through its command line or web page, and checks what they would see.
| Criterion | Unit test | Integration test | End-to-end test |
|---|---|---|---|
| Runs | one function or class, in memory | your code with a database, files or another module | the whole program, as a user does |
| Speed | microseconds | milliseconds | seconds or more |
| When it fails | it points at the code that broke | the parts disagree somewhere between them | something is wrong somewhere in the program |
| How many | most of your tests | fewer | a handful of key journeys |
| When to choose | Rules, calculations and branches | Queries, file formats and module boundaries | The few journeys that must never break |
The speed difference is large enough to shape a whole test suite. This program checks the delivery rule 10,000 times in memory and 100 times through a real SQLite database file:
"""One rule checked by 10,000 unit tests and by 100 integration tests that use a real database file."""
import sqlite3
import tempfile
import time
from pathlib import Path
def delivery_fee(order_rupees):
return 0 if order_rupees >= 499 else 40
def unit_test(total):
# One function, in memory: nothing else takes part.
assert delivery_fee(total) == (40 if total < 499 else 0)
def integration_test(db_file, total):
# The order is saved to a database file and read back before the rule is checked.
db = sqlite3.connect(db_file)
with db:
db.execute("CREATE TABLE IF NOT EXISTS orders (total INTEGER)")
db.execute("INSERT INTO orders VALUES (?)", (total,))
(saved,) = db.execute("SELECT total FROM orders ORDER BY rowid DESC LIMIT 1").fetchone()
db.close()
assert delivery_fee(saved) == (40 if total < 499 else 0)
def microseconds_each(test, totals):
start = time.perf_counter()
for total in totals:
test(total)
return (time.perf_counter() - start) * 1_000_000 / len(totals)
with tempfile.TemporaryDirectory() as folder:
db_file = Path(folder) / "orders.db"
unit = microseconds_each(unit_test, range(10_000))
integration = microseconds_each(lambda total: integration_test(db_file, total), range(0, 1000, 10))
print(f"{'unit test, average:':27}{unit:12,.2f} µs")
print(f"{'integration test, average:':27}{integration:12,.2f} µs")
print(f"one integration test takes as long as about {integration / unit:,.0f} unit tests") Output
unit test, average: 0.10 µs integration test, average: 2,766.47 µs one integration test takes as long as about 28,572 unit tests
This output changes from run to run: it measures time, which differs from one run to the next and from one computer to another; the computer of this run is named below the output.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_speeds.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Writing to a disk and reading back costs thousands of times more than calling a function, so a suite of mostly unit tests finishes in a second and gets run constantly, while a suite of mostly slow tests gets run rarely and catches bugs late. Use integration tests for the places where parts meet, and keep end-to-end tests for the journeys that matter most.
Key idea
The more of the program a test runs, the slower it is and the less its failure says about where the fault is: write mostly unit tests, integration tests where parts meet, and end-to-end tests for the few journeys that must never break.
Checkpoint
Check: Unit, integration and end-to-end tests
A test that shares data with another fails for the wrong reason
A clear test has three steps: arrange the input and anything the code needs, act by calling the code once,
then assert what must be true. Name the test after the behaviour it checks, such as
test_fee_is_waived_from_499, so a failure report reads like a broken promise. And make each test fail for one
reason only: a bug in the code it is about.
These two tests break that rule. The code they test is correct, yet the second test fails. Both add to the same
list at the top of the module; step through the two calls and watch cart:
Program · one_cart.py
cart = [] # shared by both tests
def total(items):
return sum(p for _, p in items)
def test_one_item():
cart.append(("pen", 20))
assert total(cart) == 20
def test_two_items():
cart.append(("pen", 20))
cart.append(("notebook", 60))
assert total(cart) == 80, cart
test_one_item()
test_two_items()
print("both tests passed") Variables
- changed in this step
Printed so far
Error AssertionError: [('pen', 20), ('pen', 20), ('notebook', 60)] Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 7 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
cart = []makescarta list of 0 items. One list at the top of the module. Both tests will add to it, and nothing gives either test a cart of its own. - Line 4.
def total(items):makes the functiontotal. Its body runs only when it is called. - Line 8.
def test_one_item():makes the functiontest_one_item. Its body runs only when it is called. - Line 13.
def test_two_items():makes the functiontest_two_items. Its body runs only when it is called. - Line 19.
test_one_item()cartis now[('pen', 20)]. The first test adds a pen and its assert passes, with a total of 20. The pen stays incartafter the test has ended. - Line 20.
test_two_items()stops the program withAssertionError: [('pen', 20), ('pen', 20), ('notebook', 60)]. The message after the comma,cart, shows three items. That is the clue: the failure comes from the first test, not from the code under test.
The first test left its pen in the cart, so the second found three items and a total of 100 instead of 80. That is a failure for the wrong reason: the bug is in the tests, and it hides real failures behind false ones. The next program runs the same pair of tests in both orders, then rewrites them so that each arranges its own empty cart:
"""Two tests that share one cart, run in both orders; then the same tests with a cart each."""
cart = [] # one list for every test: the mistake
def add(cart, item, price):
cart.append((item, price))
def total(cart):
return sum(price for _, price in cart)
def test_one_item():
add(cart, "pen", 20)
assert total(cart) == 20
def test_two_items():
add(cart, "pen", 20)
add(cart, "notebook", 60)
assert total(cart) == 80
def run(tests):
for test in tests:
try:
test()
except AssertionError:
print(f" FAIL {test.__name__}: the cart held {cart}")
else:
print(f" PASS {test.__name__}")
print("one_item first:")
run([test_one_item, test_two_items])
cart.clear()
print("two_items first:")
run([test_two_items, test_one_item])
# Rewritten: each test arranges its own cart, so the order no longer matters.
def test_one_item_fresh():
my_cart = []
add(my_cart, "pen", 20)
assert total(my_cart) == 20
def test_two_items_fresh():
my_cart = []
add(my_cart, "pen", 20)
add(my_cart, "notebook", 60)
assert total(my_cart) == 80
print("each test with its own cart, in both orders:")
run([test_one_item_fresh, test_two_items_fresh, test_two_items_fresh, test_one_item_fresh]) Output
one_item first:
PASS test_one_item
FAIL test_two_items: the cart held [('pen', 20), ('pen', 20), ('notebook', 60)]
two_items first:
PASS test_two_items
FAIL test_one_item: the cart held [('pen', 20), ('notebook', 60), ('pen', 20)]
each test with its own cart, in both orders:
PASS test_one_item_fresh
PASS test_two_items_fresh
PASS test_two_items_fresh
PASS test_one_item_fresh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shared_cart.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Whichever test runs second fails, and the rewritten tests pass in any order, as the last four lines show. Test runners help here: unittest gives each test a fresh object, and pytest gives each test fresh fixtures, but only if the tests do not share data of their own.
Key idea
Each test arranges its own data, acts once and asserts, so that it fails only when the code it is about is wrong, whatever ran before it.
Checkpoint
Check: Each test with its own data
Bugs sit at the edges of a rule
Most bugs sit at the edges of a rule: the exact amount where a discount starts, an empty list, the largest value.
Take a discount of 5 % from 1,000 rupees and 10 % from 5,000, capped at 750, written with two bugs: > at 5,000 and
no cap. Four cases, two at each step of the rule, are enough to catch both. Step through the table of cases and
watch got against expected:
Program · edge_table.py
def discount(total): # has 2 bugs
if total > 5000:
return total * 10 // 100
if total >= 1000:
return total * 5 // 100
return 0
cases = [ # (total, expected)
(999, 0),
(1000, 50),
(5000, 500),
(200000, 750),
]
failed = []
for total, expected in cases:
got = discount(total)
if got != expected:
failed.append(total)
print("failed at", failed) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yetfailed at [5000, 200000]
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 20 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
def discount(total):makes the functiondiscount. Its body runs only when it is called. - Line 9.
cases = [ # (total, expected) (999, 0), (1000, 50), (5000, 500), (200000, 750), ]makescasesa list of 4 items. Four cases, two at each step of the rule. 999 and 1,000 sit on both sides of the first step; 5,000 sits on the second, and 200,000 far above the cap. - Line 15.
failed = []makesfaileda list of 0 items. - Line 16.
for total, expected in cases:takes the item at position 0 ofcases:totalis999andexpectedis0. - Line 17.
got = discount(total)setsgotto0. - Line 18.
got != expectedis false (gotis0andexpectedis0), so Python skips the block underif got != expected:. - Line 16.
for total, expected in cases:takes the item at position 1 ofcases:totalis1000andexpectedis50. - Line 17.
got = discount(total)setsgotto50(it was0). 1,000 sits exactly on the first step, and the>=there is right, so the rule gives 5 %, 50, as expected. - Line 18.
got != expectedis false (gotis50andexpectedis50), so Python skips the block underif got != expected:. - Line 16.
for total, expected in cases:takes the item at position 2 ofcases:totalis5000andexpectedis500. - Line 17.
got = discount(total)setsgotto250(it was50). - Line 18.
got != expectedis true (gotis250andexpectedis500), so the block underif got != expected:runs. The bug 5,000 is on the second step, buttotal > 5000is false for it, so the rule gave 5 %, 250, where 10 %, 500, was expected. - Line 19.
failed.append(total)adds5000to the end offailed. - Line 16.
for total, expected in cases:takes the item at position 3 ofcases:totalis200000andexpectedis750. - Line 17.
got = discount(total)setsgotto20000(it was250). - Line 18.
got != expectedis true (gotis20000andexpectedis750), so the block underif got != expected:runs. - Line 19.
failed.append(total)adds200000to the end offailed. The bug 200,000 gets 10 % with no cap, 20,000 instead of 750. Only a total far above the cap shows that the cap is missing. - Line 16.
for total, expected in cases:finds no item at position 4 ofcases(it has 4 items), so the loop ends. - Line 20.
print("failed at", failed)printsfailed at [5000, 200000]. That was the last line, so the program ends. Two of the four cases failed, the two that sit where the bugs change the answer. A case in the middle, such as 3,000, passes on both versions and finds nothing.
The cases at 999 and 1,000 passed; the case exactly on 5,000 and the case far above the cap failed, because of the four they are the only ones where the bugs change the answer. Before writing tests for a function, list the inputs that sit on or next to each edge: for each rule, the values just below, on and just above every boundary value: A value at the edge of a case, such as 100 and 101 when the first slab of a bill ends at 100 units. Mistakes with < and <= show only there, so tests try each edge and the value just past it. More in “Decisions with if, elif and else”; zero and empty input; the largest value you expect; a value of the wrong type; and anything the rule refuses, such as a negative total. Here is that list as a table of nine cases, run against the correct and the buggy discount:
"""An edge-case table for a discount rule, run against a correct and a buggy version.
The rule: no discount below ₹1,000; 5 % from ₹1,000; 10 % from ₹5,000, but never more than ₹750.
Totals are whole rupees, and a discount is rounded down to whole rupees.
"""
def discount(total):
if not isinstance(total, int) or isinstance(total, bool):
raise TypeError(f"total must be whole rupees (an int), not {type(total).__name__}")
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
if total >= 5000:
return min(total * 10 // 100, 750)
if total >= 1000:
return total * 5 // 100
return 0
def buggy_discount(total):
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
if total > 5000: # > where the rule says "from ₹5,000"
return total * 10 // 100 # and no cap
if total >= 1000:
return total * 5 // 100
return 0
CASES = [ # (why the case matters, total, expected discount or exception)
("empty cart", 0, 0),
("just below the first step", 999, 0),
("exactly on the first step", 1000, 50),
("just below the second step", 4999, 249),
("exactly on the second step", 5000, 500),
("where the cap starts", 7500, 750),
("far above the cap", 200000, 750),
("negative total", -1, ValueError),
("a string, not a number", "1500", TypeError),
]
def check(function):
failed = 0
for why, total, expected in CASES:
try:
got = function(total)
except Exception as error:
got = type(error)
if got != expected:
failed += 1
print(f" FAIL {why}: discount({total!r}) gave {getattr(got, '__name__', got)}, expected {getattr(expected, '__name__', expected)}")
print(f" {len(CASES) - failed} of {len(CASES)} cases pass")
print("discount:")
check(discount)
print("buggy_discount:")
check(buggy_discount) Output
discount: 9 of 9 cases pass buggy_discount: FAIL exactly on the second step: discount(5000) gave 250, expected 500 FAIL far above the cap: discount(200000) gave 20000, expected 750 7 of 9 cases pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 discount_edges.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Only two of the nine cases notice the buggy version. The case at 7,500 passes for both versions, because 10 % of 7,500 happens to equal the cap: only a total far above the cap proves the cap is there.
Discount Calculator Work out the expected discount for a total by hand before you write it into a test.Checkpoint
Check: Cases at the edges of a rule
The clock and chance become inputs a test can choose
A function that reads the clock, the random number generator or the environment by itself has hidden inputs: its result changes with the day the test runs. The usual fix is to pass those values in as parameters, so a test can choose them and the program supplies the real ones. Step through both versions of a library rule and see where each one gets today’s date:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A hidden input. days_overdue_hidden_clock(due) calls date.today() itself, so its answer for the same due date changes every day. A test cannot say in advance what it should be.
- A test that repeats the code. To test it, a test would have to work out the right answer from today's date, which repeats the code's own logic and still changes from one day to the next.
- The day becomes an argument. days_overdue(due, today) takes the day as a parameter. Nothing inside it reads the clock, so the same arguments always give the same answer.
- A leap day in between. A test fixes both days, a due day just before a leap day and a return day just after it. The answer is 2 days, on whatever day the suite runs.
- Returned early. A book returned before its due day is 0 days overdue, not a negative number. max(0, …) keeps it at 0, and this test proves it.
- The real clock, read once. The program itself reads date.today() once, where it starts, and keeps the value to pass down.
- Passed down. days_overdue() gets the real day from the program and a chosen day from each test, and works out both the same way. The last line of the run printed 3 days overdue.
- Chance is an input too. pick_winner(entries, rng) receives its random generator the same way. A test passes random.Random(7), a generator with a fixed seed, and gets the same winner on every run.
The program runs the tests of the second version, then the real call:
"""A library rule that reads the clock, and the same rule with the day passed in."""
import random
from datetime import date, timedelta
def days_overdue_hidden_clock(due):
# Hard to test: the answer changes every day, because the function reads the clock itself.
return max(0, (date.today() - due).days)
def days_overdue(due, today):
# Easy to test: the caller passes the day in, so a test can choose it.
return max(0, (today - due).days)
def test_on_the_due_day():
assert days_overdue(date(2026, 3, 1), today=date(2026, 3, 1)) == 0
def test_across_a_leap_day():
# 2028 is a leap year: 28 February to 1 March is two days.
assert days_overdue(date(2028, 2, 28), today=date(2028, 3, 1)) == 2
def test_returned_early():
assert days_overdue(date(2026, 3, 10), today=date(2026, 3, 1)) == 0
def pick_winner(entries, rng):
# Randomness is an input too: pass in the random number generator.
return rng.choice(sorted(entries))
def test_draw_is_repeatable():
entries = {"asha", "ravi", "meera", "john"}
assert pick_winner(entries, random.Random(7)) == pick_winner(entries, random.Random(7))
for name, test in list(globals().items()):
if name.startswith("test_"):
test()
print("PASS", name)
# The program itself passes the real day in, once, where it starts.
today = date.today()
print("a book due three days ago is", days_overdue(today - timedelta(days=3), today), "days overdue") Output
PASS test_on_the_due_day PASS test_across_a_leap_day PASS test_returned_early PASS test_draw_is_repeatable a book due three days ago is 3 days overdue
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 due_dates.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
days_overdue_hidden_clock(due) can only be tested by computing the right answer from today’s date inside the test,
which repeats the code’s own logic. days_overdue(due, today) takes the day as an argument, so each test fixes it:
the due day itself, a leap day in between, a book returned early. Randomness works the same way: pick_winner()
receives a random.Random object, and a test that gives it a seed: The starting value of a pseudo-random generator. The same seed gives the same stream of numbers, so random.seed(42) or random.Random(42) makes a simulation or a test repeatable. More in “Random numbers, simulations and statistics” of 7 gets the same winner
every time. The program reads the real clock once, at its start, and passes the value down. Code shaped like this,
with calculations in plain functions and the reading of clocks, files and input kept at the edges, is easier to
test and to reuse.
Checkpoint
Check: The clock and chance as inputs
Practise: tests that catch six bugs
The checks of this exercise run your tests against correct rules and against six broken versions. Every test must pass on the correct code, and every broken version must make at least one test fail.
Exercise · Easy · Python
Tests that catch six buggy versions of two rules
Write tests for two rules. The code is already written; your job is the tests that would notice if it broke.
leap_year(year)returnsTruefor a year divisible by 4, except a year divisible by 100, which is a leap year only when it is also divisible by 400. So 2024 and 2000 are leap years; 2023, 1900 and 2100 are not.discount(total)takes a total in whole rupees and returns the festival discount in whole rupees: nothing below ₹2,000; from ₹2,000, 10 % of the total, rounded down, but never more than ₹500. A negative total raisesValueError.
In test_rules.py, write at least four test functions whose names start with test_, using plain assert. One test is there already. Import the rules with from rules import discount, leap_year.
The checks run your tests twice over: against the correct rules, where every test must pass, and against six versions of the rules that each contain one bug, where at least one of your tests must fail. A test that only passes on correct code tells you nothing; a test that fails on broken code is the one that protects you. The checks name each bug when no test of yours notices it, so read them after you have written your tests.
Starter code · test_rules.py
from rules import discount, leap_year
def test_2024_is_a_leap_year():
assert leap_year(2024) is True
# Write more tests: other kinds of year for leap_year, and the steps, the cap, the rounding and the
# negative total of discount. The sample tests · test_checks.py
"""The checks of the exercise: they run your tests (test_rules.py) against the correct rules and against six
versions of them that each contain one bug.
Every test of yours must pass on the correct rules, and each buggy version must make at least one of them fail. The
rules are defined here and given to your file as the module `rules`, so `from rules import discount, leap_year`
works in the browser. On your own computer, put the two correct functions in a file named rules.py.
"""
import importlib
import sys
import types
def leap_year(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def discount(total):
if total < 0:
raise ValueError(f"a total cannot be negative: {total}")
if total < 2000:
return 0
return min(total // 10, 500)
# ---- six versions with one bug each --------------------------------------------------------------------------------
def leap_every_fourth_year(year):
return year % 4 == 0
def leap_without_the_400_rule(year):
return year % 4 == 0 and year % 100 != 0
def discount_misses_2000(total):
if total < 0:
raise ValueError(f"a total cannot be negative: {total}")
if total <= 2000:
return 0
return min(total // 10, 500)
def discount_without_the_cap(total):
if total < 0:
raise ValueError(f"a total cannot be negative: {total}")
if total < 2000:
return 0
return total // 10
def discount_rounds_up(total):
if total < 0:
raise ValueError(f"a total cannot be negative: {total}")
if total < 2000:
return 0
return min(-(-total // 10), 500)
def discount_accepts_negative(total):
if total < 2000:
return 0
return min(total // 10, 500)
rules = types.ModuleType("rules")
sys.modules["rules"] = rules
def _your_module(leap, disc):
"""A fresh import of test_rules.py that sees these two functions as rules.leap_year and rules.discount."""
rules.leap_year, rules.discount = leap, disc
sys.modules.pop("test_rules", None)
return importlib.import_module("test_rules")
def _your_tests(module):
return [(name, value) for name, value in vars(module).items() if name.startswith("test") and callable(value) and getattr(value, "__module__", "") == module.__name__]
def _failing(leap, disc):
"""The names of your tests that fail (or raise anything) with these rules."""
failed = []
for name, test in _your_tests(_your_module(leap, disc)):
try:
test()
except Exception:
failed.append(name)
return failed
def _caught(leap, disc):
return not _failing(leap_year, discount) and bool(_failing(leap, disc))
def test_at_least_four_tests():
"""test_rules.py has at least four test functions"""
count = len(_your_tests(_your_module(leap_year, discount)))
if count < 4:
raise AssertionError(f"test_rules.py has {count} test function(s): write at least four")
def test_all_pass_on_the_correct_rules():
"""every one of your tests passes on the correct rules"""
failed = _failing(leap_year, discount)
if failed:
raise AssertionError(f"these fail although the rules are correct: {', '.join(failed)}")
def test_catches_every_fourth_year():
"""a test fails when every year divisible by 4 counts as a leap year"""
if not _caught(leap_every_fourth_year, discount):
raise AssertionError("no test fails when 1900 is called a leap year")
def test_catches_the_missing_400_rule():
"""a test fails when a century divisible by 400 is not a leap year"""
if not _caught(leap_without_the_400_rule, discount):
raise AssertionError("no test fails when 2000 is not a leap year")
def test_catches_the_step_at_2000():
"""a test fails when a total of exactly ₹2,000 gets no discount"""
if not _caught(leap_year, discount_misses_2000):
raise AssertionError("no test fails when ₹2,000 gets no discount")
def test_catches_the_missing_cap():
"""a test fails when the discount can exceed ₹500"""
if not _caught(leap_year, discount_without_the_cap):
raise AssertionError("no test fails when ₹9,000 gets ₹900 off")
def test_catches_rounding_up():
"""a test fails when the discount is rounded up instead of down"""
if not _caught(leap_year, discount_rounds_up):
raise AssertionError("no test fails when ₹2,005 gets ₹201 off instead of ₹200")
def test_catches_a_negative_total():
"""a test fails when a negative total is accepted"""
if not _caught(leap_year, discount_accepts_negative):
raise AssertionError("no test fails when discount(-1) returns 0 instead of raising ValueError") A hint
Test each rule at its edges, one behaviour per test. For leap_year, take one year from each branch of the rule: 2024 (divisible by 4), 2023 (not), 1900 (a century) and 2000 (a century divisible by 400). For discount, take the totals on each side of ₹2,000 (1,999 and 2,000), one where 10 % is more than ₹500 (such as 9,000), one that shows the rounding (2,005 gives 200, not 200.5 or 201), and a negative total inside try / except ValueError, with raise AssertionError(...) after the call for the case where nothing was raised.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check below draws on all five ideas of the lesson and starts with whichever one your checkpoint answers show to be the weakest.
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between a unit test and an integration test?
A unit test checks one small piece of code, usually one function or class, on its own and in memory: it calls the code with chosen inputs and asserts on the result, with no database, files or network involved. That makes it fast, microseconds to milliseconds, and when it fails it points straight at the code that broke. An integration test checks that pieces work together, such as your code with a real database, the file system or another module, so it can catch problems no unit test sees: a wrong query, a file format the two sides disagree on, a wrong argument order between modules. It is slower and a failure says less about where the fault is. A healthy suite has many unit tests and fewer integration tests, plus a few end-to-end tests of the whole program.
An interviewer usually follows a first answer with a case. Pick the answer you would give to each:
Checkpoint
Interview round: two follow-up questions
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A test for send_reminders() passes from Monday to Friday and fails every Saturday and Sunday, although nobody changed the code. What is going on, and how would you change the code and the test so that the result no longer depends on the day the suite runs?
-
Mid to senior
Your project has 60 end-to-end tests that take 25 minutes in CI, one of them fails at random about once a week, and there are almost no unit tests. How would you rebalance the suite, and what would you measure to know that it worked?
Key takeaways
- A test runs your code with an input you chose and asserts what the result must be, so a plain function with
assertis already a test, and a message after the comma makes its failure explain itself. - Keep
assertfor tests, becausepython -Oremoves it; program code raises real exceptions instead. - Unit tests check one piece in memory and run in microseconds, integration tests add real parts such as a database file, and end-to-end tests run the whole program, so a suite has many of the first kind and few of the last.
- Each test arranges its own data, acts once and asserts, so that it fails only when the code it is about is wrong.
- Bugs sit at the edges of a rule, so test on and around every boundary, empty input, the largest value and the wrong type.
- Pass the clock and the random generator in as parameters, so that tests can choose them and the program reads the real ones once, at its start.
References
- The Python Language Reference: the assert statement (Python Software Foundation)
- Command line and environment: the -O option (Python Software Foundation)
- unittest: unit testing framework (Python Software Foundation)
- doctest: test interactive Python examples (Python Software Foundation)
- Get started with pytest (pytest-dev)
- random: notes on reproducibility (Python Software Foundation)
- datetime: date.today() (Python Software Foundation)
- sqlite3: DB-API 2.0 interface for SQLite databases (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress