Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 Python · first_tests.py
"""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

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:

Interactive · message by message Two tests meet a rule with one wrong character Step through failing_test.py. The runner calls each test, the test calls the rule, and the failure travels back to the runner.
Two tests meet a rule with one wrong character, step 9 of 9. Messages so far: The runner loop to A test function: test_fee_is_waived_from_499(); A test function to delivery_fee(): delivery_fee(499); delivery_fee() to A test function: 40; A test function to The runner loop: AssertionError, no message; The runner loop to A test function: …_with_a_message(); A test function to The runner loop: AssertionError and a message.The runner loopprinted 2 FAIL linesA test functionraised AssertionErrordelivery_fee()returned 40, not 0test_fee_is_waived_from_499()delivery_fee(499)40AssertionError, no message…_with_a_message()AssertionError and a message

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. Call the first test. The runner calls the first test. A test takes no arguments, because it arranges everything it needs itself.
  3. Act. The test calls the rule once, with the input that sits on the boundary of the rule, an order of exactly 499 rupees.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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".
  9. 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 tests against a rule with a bug Python · failing_test.py
"""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

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

  1. Question 1 of 2 delivery_fee(120) returns 40. What does the line assert delivery_fee(120) == 40 do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing; the program goes on to the next line

    assert condition does nothing when the condition is true and raises AssertionError when it is false. A test is a function full of such lines: it passes when none of them raises.

  2. Question 2 of 2 A test of the delivery rule fails, and its report shows an empty message. Which line would have made the failure explain itself?

    Choose one answer.

    A hint

    Look at what comes after the comma in the second test of failing_test.py.

    Show the answer: Question 2 of 2

    Answer: assert fee == 0, f"an order of ₹499 should travel free, but the fee was ₹{fee}"

    assert condition, message raises AssertionError(message) when the condition is false. The runner prints that message, so a failure names the values that were wrong instead of leaving an empty string.

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:

A unit test runs one function, an integration test adds a real database file, and an end-to-end test runs the whole program as a user does.End-to-end testA user types:python shop.py --order 650Integration testThe user sees:Fee: ₹0Unit testorders.dba real SQLite filedelivery_fee(650) == 0one function, in memorysave, read back

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.

Three kinds of test
Criterion Unit testIntegration testEnd-to-end test
Runs one function or class, in memoryyour code with a database, files or another modulethe whole program, as a user does
Speed microsecondsmillisecondsseconds or more
When it fails it points at the code that brokethe parts disagree somewhere between themsomething is wrong somewhere in the program
How many most of your testsfewera handful of key journeys
When to choose Rules, calculations and branchesQueries, file formats and module boundariesThe 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:

A unit test and an integration test of the same rule, timed Python · test_speeds.py
"""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

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

  1. Question 1 of 2 A test saves an order to an SQLite file, reads it back and then checks the fee. Which kind of test is it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An integration test

    A unit test checks one piece in memory, an integration test adds a real part such as a database file, and an end-to-end test runs the whole program as a user would. Saving and reading back an order is an integration test.

  2. Question 2 of 2 A project needs about 2,000 tests. Which mix gives quick feedback and still checks where the parts meet?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Mostly unit tests, integration tests where parts meet, and a few end-to-end tests of key journeys

    The speed difference shapes the suite: a suite of mostly unit tests is run constantly, integration tests check the places where parts meet, and a handful of end-to-end tests guard the journeys that must never break.

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:

Interactive · line by line Two tests and one shared cart Watch cart in the variables. Both tests change the same list, and the second one fails although total() is right.

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

After line 20: cart is [('pen', 20), ('pen', 20), ('notebook', 60)]; total is total(items); test_one_item is test_one_item(); test_two_items is test_two_items().cart('pen', 20)0('pen', 20)1('notebook', 60)2totaltotal(items)test_one_itemtest_one_item()test_two_itemstest_two_items()
  • 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.

Line run 20
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. cart = [] makes cart a 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.
  3. Line 4. def total(items): makes the function total. Its body runs only when it is called.
  4. Line 8. def test_one_item(): makes the function test_one_item. Its body runs only when it is called.
  5. Line 13. def test_two_items(): makes the function test_two_items. Its body runs only when it is called.
  6. Line 19. test_one_item() cart is now [('pen', 20)]. The first test adds a pen and its assert passes, with a total of 20. The pen stays in cart after the test has ended.
  7. Line 20. test_two_items() stops the program with AssertionError: [('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 a cart, in both orders, then rewritten Python · shared_cart.py
"""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

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

  1. Question 1 of 2 In arrange, act, assert, which step calls the code under test?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Act

    A clear test arranges its input, acts by calling the code once, then asserts what must be true. Each test arranges its own data, so nothing another test did can change its result.

  2. Question 2 of 2 In the first version of shared_cart.py, test_two_items runs first and test_one_item second. Which test fails?

    Choose one answer.

    A hint

    Both tests add to the same module-level cart. What does the second one find in it?

    Show the answer: Question 2 of 2

    Answer: test_one_item, because it finds the pen and notebook the other test left

    Whichever test runs second finds the first one's items still in the shared cart and fails, as the recorded run shows for both orders. The bug is in the tests: give each one its own cart.

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:

Interactive · line by line Four edge cases against a discount with two bugs The flag under cases is the case the loop reads next. Watch got against expected, and which cases reach failed.

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

After line 20: discount is discount(total); cases is [(999, 0), (1000, 50), (5000, 500), (200000, 750)]; failed is [5000, 200000]; total is 200000; expected is 750; got is 20000.discountdiscount(total)cases(999, 0)0(1000, 50)1(5000, 500)2(200000, 750)3failed500002000001total200000expected750got20000
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

failed 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.

Line run 20
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. def discount(total): makes the function discount. Its body runs only when it is called.
  3. Line 9. cases = [ # (total, expected) (999, 0), (1000, 50), (5000, 500), (200000, 750), ] makes cases a 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.
  4. Line 15. failed = [] makes failed a list of 0 items.
  5. Line 16. for total, expected in cases: takes the item at position 0 of cases: total is 999 and expected is 0.
  6. Line 17. got = discount(total) sets got to 0.
  7. Line 18. got != expected is false (got is 0 and expected is 0), so Python skips the block under if got != expected:.
  8. Line 16. for total, expected in cases: takes the item at position 1 of cases: total is 1000 and expected is 50.
  9. Line 17. got = discount(total) sets got to 50 (it was 0). 1,000 sits exactly on the first step, and the >= there is right, so the rule gives 5 %, 50, as expected.
  10. Line 18. got != expected is false (got is 50 and expected is 50), so Python skips the block under if got != expected:.
  11. Line 16. for total, expected in cases: takes the item at position 2 of cases: total is 5000 and expected is 500.
  12. Line 17. got = discount(total) sets got to 250 (it was 50).
  13. Line 18. got != expected is true (got is 250 and expected is 500), so the block under if got != expected: runs. The bug 5,000 is on the second step, but total > 5000 is false for it, so the rule gave 5 %, 250, where 10 %, 500, was expected.
  14. Line 19. failed.append(total) adds 5000 to the end of failed.
  15. Line 16. for total, expected in cases: takes the item at position 3 of cases: total is 200000 and expected is 750.
  16. Line 17. got = discount(total) sets got to 20000 (it was 250).
  17. Line 18. got != expected is true (got is 20000 and expected is 750), so the block under if got != expected: runs.
  18. Line 19. failed.append(total) adds 200000 to the end of failed. 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.
  19. Line 16. for total, expected in cases: finds no item at position 4 of cases (it has 4 items), so the loop ends.
  20. Line 20. print("failed at", failed) prints failed 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, run against a correct and a buggy discount Python · discount_edges.py
"""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

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

  1. Question 1 of 2 A rule makes delivery free from 499 rupees and refuses a negative total. Which totals belong in its tests? Choose every one that does.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • 500
    • -1
    • 498
    • 499

    For each rule, test the values just below, on and just above every boundary, plus anything the rule refuses. A total in the middle of a range adds little: it takes the same path as its neighbours.

  2. Question 2 of 2 A new rule gives 2 % cashback on a bill, but never more than 200 rupees. Which bill proves that the cap is there?

    Choose one answer.

    A hint

    Work out 2 % of each bill. Which one would get more than 200 rupees without the cap?

    Show the answer: Question 2 of 2

    Answer: A bill of 50,000 rupees

    A cap shows only where the rule would otherwise go past it. Pick a bill whose 2 % is far above 200 rupees: the 7,500 case of the lesson passed on the buggy discount because 10 % of it happened to equal the cap.

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:

Interactive · step by step Where the library rule gets today's date Step through both versions of days_overdue. The hidden-clock version reads the clock itself; the other is given the day.
Where the library rule gets today's date, step 8 of 8. Showing date.today(), The program's start, Hidden-clock version, days_overdue() (a book 3 days late: 3), A test. In focus: A test.reads it itselftodaytodaya fixed daydate.today()the real clockThe program's startreads the clock onceHidden-clock versioncalls date.today() insidedays_overdue()a book 3 days late: 3A testchooses the daypasses random.Random(7)

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. The real clock, read once. The program itself reads date.today() once, where it starts, and keeps the value to pass down.
  7. 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.
  8. 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 rule that reads the clock, and the same rule with the day passed in Python · due_dates.py
"""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

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.

Date Calculator Check the number of days between two dates before you put it into a test.

Checkpoint

Check: The clock and chance as inputs

  1. Question 1 of 2 Why is days_overdue_hidden_clock(due) hard to test?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It reads date.today() itself, so its answer changes with the day the test runs

    A function that reads the clock, the random generator or the environment by itself has hidden inputs. Pass them in as parameters, and a test can choose them.

  2. Question 2 of 2 pick_winner(entries, random.Random(7)) is called twice with the same set of entries. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It picks the same winner both times

    Randomness is an input too. A test passes a generator with a fixed seed and gets the same result on every run; the program passes an unseeded one.

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) returns True for 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 raises ValueError.

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.

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.

  1. Question 1 of 12 What does failing_test.py print?

    What does this program print? Choose one answer.

    """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)
    Show the answer: Question 1 of 12

    Answer: it prints

    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'

    Both tests check the same broken rule, so both fail. A bare assert raises an AssertionError with no message at all, which is why the first line ends with two quotes and nothing between them. Only the assert with a message after the comma says what went wrong; pytest, later in this module, adds the compared values for you.

  2. Question 2 of 12 What message does assert fee == 0 give its AssertionError when the fee is 40?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: None at all; the message is empty

    assert condition raises AssertionError() with no message when the condition is false. Add a message after a comma, assert fee == 0, f"the fee was {fee}", to make the failure explain itself.

  3. Question 3 of 12 A sign-up form checks assert age >= 18 before it creates an account. The server runs python -O app.py. What happens when a 15-year-old signs up?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The account is created, because -O skips every assert statement

    Keep assert for tests. Program code that checks input raises a real exception, such as ValueError, which runs whatever options Python starts with.

  4. Question 4 of 12 Which of these are integration tests?

    Choose every answer that is right.

    Show the answer: Question 4 of 12

    Answer:

    • Saving an order to an SQLite file and reading it back before checking the fee.
    • Reading a settings file from a temporary folder and checking the parsed values.

    An integration test runs your code together with a real part it works with, such as a database or a file. A call of one function in memory is a unit test, and running the whole program as a user would is an end-to-end test.

  5. Question 5 of 12 Which kind of test usually runs in microseconds?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: A unit test

    The more of the program a test runs, the slower it is. Unit tests run in microseconds, integration tests in milliseconds and end-to-end tests in seconds or more.

  6. Question 6 of 12 What lets the rewritten cart tests pass in any order?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Each test arranges its own empty cart inside the test

    A test that arranges its own data fails only when the code it is about is wrong. unittest gives each test a fresh object and pytest fresh fixtures, but only if the tests do not share data of their own.

  7. Question 7 of 12 A test passes when you run it on its own and fails when the whole suite runs. What should you suspect first?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Another test changes something this test depends on, such as a shared list, a file or a database row.

    A test that changes its result with the order of the suite is reading state another test left behind, as the shared cart in this lesson did. Give each test its own fresh data, and the order stops mattering.

  8. Question 8 of 12 A discount starts "from 1,000 rupees". Which total sits exactly on the boundary?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: 1,000

    A boundary is the value where the rule changes its answer. Test it, the value just below and the value just above: mistakes with < and <= show only there.

  9. Question 9 of 12 The traced program edge_table.py runs four cases against the discount with two bugs. Which line does it print at the end?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: failed at [5000, 200000]

    The case on the second step, 5,000, and the case far above the cap, 200,000, are the only ones where the buggy rule gives a different answer, so they are the ones that fail.

  10. Question 10 of 12 A function returns the average of a list of marks. Which input is most likely to find a bug that the other three would miss?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: An empty list.

    The three lists of numbers all take the same path through the code. An empty list is a boundary: dividing by its length raises ZeroDivisionError unless the function decides what the average of no marks is, and a test makes that decision visible.

  11. Question 11 of 12 Which assert tests days_overdue() for a book due the day before the leap day of 2028 and returned the day after it?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: assert days_overdue(date(2028, 2, 28), today=date(2028, 3, 1)) == 2

    With the day as a parameter, a test fixes both dates and gets the same answer on every day of the year. The program passes date.today() once, at its start.

  12. Question 12 of 12 Which change makes this function easy to test on any day?

    Read the code, then choose one answer.

    from datetime import date
    
    
    def is_weekend():
        return date.today().weekday() >= 5
    Show the answer: Question 12 of 12

    Answer: def is_weekend(day): return day.weekday() >= 5, called as is_weekend(date.today()) by the program.

    The function reads the clock itself, so its result depends on the day the test runs. With the day as a parameter, a test can pass a date that is a Saturday and one that is a Monday, and get the same answers on every day of the week; the program itself still passes in the real date, once.

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

  1. Question 1 of 2 In an interview: unit tests of save_order() pass with the database replaced by a list, yet the app fails on the real database because a column name in the SQL is misspelt. Which test was missing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An integration test that runs the query against a real database file

    Unit tests check one piece in memory, so they cannot see a wrong query. An integration test with a real database file catches it where the parts meet, and its failure names the query.

  2. Question 2 of 2 In an interview: where would you test that an order of exactly 499 rupees travels free?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In a unit test of delivery_fee(499), because the rule lives in one function

    A rule inside one function belongs in a unit test: it runs in microseconds and its failure names the code that broke. Keep the slower kinds of test for what a unit test cannot see.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A test runs your code with an input you chose and asserts what the result must be, so a plain function with assert is already a test, and a message after the comma makes its failure explain itself.
  • Keep assert for tests, because python -O removes 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.