Skip to content

I fixed one bug, and two old ones came back

  • Python · Core
  • 20 min read, about 1 h 20 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

Your shop turns price text like "£1,299.50" into pence, 129950. A customer reports that "£5.5" came out as 505 pence. You fix it, check that "£5.5" now works, and ship.

The next day, prices with commas are broken again: the fix undid an older one. Nobody noticed, because nobody re-checked the old cases. Each fix was checked by hand, once, and then forgotten.

  • Input: a function, parse_price(text), and every case it has ever got wrong.
  • Output: a check you can run in one command that tries every case and reports each one that fails.
  • Rules: a failure says what was expected and what came back. Adding a case is one line.
  • The hard part: checks that run every time, not just the day you wrote them.

Hints open one at a time:

Hint 1 of 3

Printing results means a person has to read and judge them, every time.

Hint 2 of 3

An if that raises AssertionError stops at the first failure, so later problems stay hidden.

Hint 3 of 3

pytest runs every function named test_..., and reports each failing assert with both sides.

A test is a function whose name starts with test_, in a file whose name starts with test_. It calls your code and checks the result with a plain assert:

def test_commas():
assert parse_price("£1,299.50") == 129950

pytest finds every test, runs each one on its own, and reports each failure in detail:

$ pytest
FAILED test_price.py::test_parse_price[£5.5-550] - assert 505 == 550
1 failed, 4 passed in 0.02s

Three tools cover most tests:

ToolUse it for
@pytest.mark.parametrizeOne test, many cases: a table of inputs and answers.
with pytest.raises(ValueError, match="…")Checking that bad input raises the right error.
A fakePassing in a fake for email, time or the network, which records what was asked of it.

Here c is the number of cases.

Okay

Print and read

The idea: Print the result for each case, and check the numbers by eye.

Quick, and you see real output.

from price import parse_price
# Run it and read the output. Is 129950 right? You have to work it out each time.
print(parse_price("£1,299.50"))
print(parse_price("£5.5"))
print(parse_price("£3"))
Time
O(c)
Space
O(1)
Why
One call per case, plus a person reading each line.

Where it breaks: a person must know the right answer and read every line, every time. Nobody does, so it’s run once and forgotten.

Better

Checks that raise

The idea: Compare each result with the answer, and raise AssertionError if it’s wrong.

The waste in Okay: a human doing a computer’s job.

Now the script itself knows the answers.

Highlighted lines are new in this rung.

from price import parse_price
def run_checks() -> None:
if parse_price("£1,299.50") != 129950:
raise AssertionError("commas") # stops here: the checks below never run
if parse_price("£5.5") != 550:
raise AssertionError("one pence digit")
if parse_price("£3") != 300:
raise AssertionError("no pence")
run_checks()
Time
O(c)
Space
O(1)
Why
One call per case.
What changed from the previous rung
from price import parse_price
# Run it and read the output. Is 129950 right? You have to work it out each time.
print(parse_price("£1,299.50"))
print(parse_price("£5.5"))
print(parse_price("£3"))
def run_checks() -> None:
if parse_price("£1,299.50") != 129950:
raise AssertionError("commas") # stops here: the checks below never run
if parse_price("£5.5") != 550:
raise AssertionError("one pence digit")
if parse_price("£3") != 300:
raise AssertionError("no pence")
run_checks()

Where it breaks: it stops at the first failure, so you fix one and find the next, one run at a time. The message is whatever you remembered to write, without the value that came back.

Best

pytest

The idea: Test functions with plain assert, a parametrize table for the cases, and pytest.raises for errors.

The waste in Better: one failure hides the rest, and messages are hand-written.

Each case runs on its own, so you see every failure at once, with both sides of the comparison. A new bug report becomes one more line in the table.

Highlighted lines are new in this rung.

# Saved as test_price.py, run with: pytest
import pytest
from price import parse_price
@pytest.mark.parametrize(
("text", "pence"),
[
("£1,299.50", 129950),
("£5.5", 550),
("£3", 300),
(" £0.99 ", 99),
],
)
def test_parse_price(text: str, pence: int) -> None:
assert parse_price(text) == pence # pytest shows both sides when it fails
def test_empty_price_is_an_error() -> None:
with pytest.raises(ValueError, match="empty price"):
parse_price("£")
Time
O(c)
Space
O(1)
Why
One call per case.
What changed from the previous rung
# Saved as test_price.py, run with: pytest
import pytest
from price import parse_price
def run_checks() -> None:
if parse_price("£1,299.50") != 129950:
raise AssertionError("commas") # stops here: the checks below never run
if parse_price("£5.5") != 550:
raise AssertionError("one pence digit")
if parse_price("£3") != 300:
raise AssertionError("no pence")
@pytest.mark.parametrize(
("text", "pence"),
[
("£1,299.50", 129950),
("£5.5", 550),
("£3", 300),
(" £0.99 ", 99),
],
)
def test_parse_price(text: str, pence: int) -> None:
assert parse_price(text) == pence # pytest shows both sides when it fails
run_checks()
def test_empty_price_is_an_error() -> None:
with pytest.raises(ValueError, match="empty price"):
parse_price("£")

When Best isn’t best: for a quick experiment in a notebook, a few asserts are enough. Tests are worth it once code is kept and changed.

pytest doesn’t run in the browser, but its heart does: plain assert. Write check(parse_price), which asserts every case. The tests pass in the right parse_price, which must pass, and three buggy ones, which must each make check raise.

The right answers: "£1,299.50" is 129950, "£5.5" is 550, "£3" is 300, and "£" raises ValueError.

Hint 1 of 3

One assert per case: assert parse_price("£5.5") == 550.

Hint 2 of 3

For the error: try the call, pass on ValueError, and raise AssertionError in an else block.

Hint 3 of 3

Each bug fails a different case. Cover all four.

Tests that catch every bug

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: check(the right parse_price) passes → None
  2. Not run yet: check(a parse_price that forgets commas) fails → raises Exception
  3. Not run yet: check(a parse_price that reads “5” pence as 5p) fails → raises Exception
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show an answer
check.py
def check(parse_price) -> None:
assert parse_price("£1,299.50") == 129950
assert parse_price("£5.5") == 550
assert parse_price("£3") == 300
try:
parse_price("£")
except ValueError:
pass
else:
raise AssertionError("an empty price should raise ValueError")

In a real project this is test_price.py, as in the Best rung, and pytest does the try and else for you with pytest.raises.

WayKnows the answersReports every failureShows what came backUse it when
Okay: printNoNoYesExploring, once
Better: hand-made checksYesNoOnly if you wrote it inA script with no test tools
Best: pytestYesYesYesAny code you keep
  • Tests that only check the happy path pass broken code. The bugs live at the edges: empty input, zero, one item, the boundary of a rule.
  • Name tests after the behaviour: test_empty_price_is_an_error tells you what broke without opening the file.
  • Each test must stand alone. Tests that share a list or a file pass or fail depending on order. Use tmp_path and fresh data.
  • Don’t test with the real world: sending email or calling a payment API from a test is slow, flaky and sometimes costly. Pass in a fake.
  • Watch it fail first: a new test that has never failed might not be testing anything. Break the code on purpose once.

Questions to ask first

  • Which cases have broken before, or worry the team most?
  • What does the code touch outside itself: files, the network, the clock?

A 60-second walkthrough

“I’d write pytest tests: functions named test_... with plain assert, which pytest rewrites to show both sides when they fail. For parse_price I’d use parametrize with a table of inputs and pence, including the edges that broke before, like commas. Empty input gets pytest.raises(ValueError, match=...). Every bug report becomes a row. Anything that talks to the outside, like email, takes the sender as an argument so tests pass in a fake that records calls. Then pytest runs in CI on every push.”

Likely follow-ups

  • What is a fixture? A function marked @pytest.fixture that builds test data or set-up; tests ask for it by naming it as an argument. tmp_path is a built-in one.
  • Mock or fake? A fake is a small working fake you write. unittest.mock builds one for you and records calls; use it when passing a fake in isn’t possible.
  • How much coverage is enough? Coverage shows untested lines, not untested cases. Aim tests at rules and edges, not at a percentage.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

In each, you write the checks. The tests pass in a right version, which your checks must pass, and buggy versions, which your checks must catch by raising.

Write check_leap(is_leap). A year is a leap year if it divides by 4, except years that divide by 100, unless they also divide by 400.

Hint 1 of 3

2024 is a leap year and 2023 isn’t. That catches nothing tricky.

Hint 2 of 3

Each part of the rule needs its own case.

Hint 3 of 3

1900 is not a leap year. 2000 is.

Leap years

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: check_leap(the right is_leap) passes → None
  2. Not run yet: check_leap(every 4th year is a leap year) fails → raises Exception
  3. Not run yet: check_leap(forgets the 400 rule) fails → raises Exception
Try your own input
Show the Okay, Better and Best answers
Okay

The easy cases

The idea: Check 2024 and 2023.

It checks the main rule.

def check_leap(is_leap) -> None:
assert is_leap(2024)
assert not is_leap(2023)
Time
O(1)
Space
O(1)
Why
Two calls.

Where it breaks: both buggy versions get 2024 and 2023 right, so it catches neither.

Better

Add 1900

The idea: Add a year that divides by 100.

The waste in Okay: it only tests the case nobody gets wrong.

Highlighted lines are new in this rung.

def check_leap(is_leap) -> None:
assert is_leap(2024)
assert not is_leap(2023)
assert not is_leap(1900) # divisible by 100: not a leap year
Time
O(1)
Space
O(1)
Why
Three calls.
What changed from the previous rung
def check_leap(is_leap) -> None:
assert is_leap(2024)
assert not is_leap(2023)
assert not is_leap(1900) # divisible by 100: not a leap year

Where it breaks: the version that forgets the 400 rule still passes, because 2000 is never tried.

Best

One case per rule

The idea: Add 2000, so each part of the rule has a case.

The waste in Better: one part of the rule is never checked.

Highlighted lines are new in this rung.

def check_leap(is_leap) -> None:
assert is_leap(2024)
assert not is_leap(2023)
assert not is_leap(1900) # divisible by 100: not a leap year
assert is_leap(2000) # unless divisible by 400
Time
O(1)
Space
O(1)
Why
Four calls.
What changed from the previous rung
def check_leap(is_leap) -> None:
assert is_leap(2024)
assert not is_leap(2023)
assert not is_leap(1900) # divisible by 100: not a leap year
assert is_leap(2000) # unless divisible by 400

When Best isn’t best: for rules with many inputs, property-based testing with the hypothesis package tries thousands of years for you.

ratio(a, b) returns a / b, and raises ValueError("b must not be 0") when b is 0. Write check_ratio(ratio). One buggy version returns 0 instead of raising; another lets ZeroDivisionError through.

Hint 1 of 3

except Exception: pass hides every error, and is happy when nothing is raised at all.

Hint 2 of 3

except ValueError: pass catches the right error, but what if no error comes?

Hint 3 of 3

Add an else: block that raises AssertionError. That runs only when the call didn’t raise.

Check the error

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: check_ratio(the right ratio) passes → None
  2. Not run yet: check_ratio(lets ZeroDivisionError through) fails → raises Exception
  3. Not run yet: check_ratio(returns 0 for b = 0) fails → raises Exception
Try your own input
Show the Okay, Better and Best answers
Okay

except Exception: pass

The idea: Call ratio(1, 0) inside a try that ignores any error.

The test runs to the end.

def check_ratio(ratio) -> None:
assert ratio(6, 3) == 2
try: # noqa: SIM105 (the rung shows the try a learner would write)
ratio(1, 0)
except Exception: # the flaw: this hides every error, and every result
pass
Time
O(1)
Space
O(1)
Why
Two calls.

Where it breaks: it can’t fail. Any error is swallowed, and so is no error at all.

Better

except ValueError

The idea: Only catch the error you expect.

The waste in Okay: a check that passes whatever happens.

A ZeroDivisionError now escapes and fails the check.

Highlighted lines are new in this rung.

def check_ratio(ratio) -> None:
assert ratio(6, 3) == 2
try: # noqa: SIM105 (the rung shows the try a learner would write)
ratio(1, 0)
except ValueError:
pass # but if nothing is raised, the test still passes
Time
O(1)
Space
O(1)
Why
Two calls.
What changed from the previous rung
def check_ratio(ratio) -> None:
assert ratio(6, 3) == 2
try: # noqa: SIM105 (the rung shows the try a learner would write)
ratio(1, 0)
except Exception: # the flaw: this hides every error, and every result
pass
except ValueError:
pass # but if nothing is raised, the test still passes

Where it breaks: a version that returns 0 raises nothing, so the try finishes quietly and the check passes.

Best

else: fail

The idea: Check the message in except, and raise AssertionError in else.

The waste in Better: “no error” counts as a pass.

This is what pytest.raises(ValueError, match="must not be 0") does for you.

Highlighted lines are new in this rung.

def check_ratio(ratio) -> None:
assert ratio(6, 3) == 2
try:
ratio(1, 0)
except ValueError as e:
assert "must not be 0" in str(e)
else:
raise AssertionError("ratio(1, 0) should raise ValueError")
# With pytest: with pytest.raises(ValueError, match="must not be 0"): ratio(1, 0)
Time
O(1)
Space
O(1)
Why
Two calls.
What changed from the previous rung
def check_ratio(ratio) -> None:
assert ratio(6, 3) == 2
try: # noqa: SIM105 (the rung shows the try a learner would write)
try:
ratio(1, 0)
except ValueError:
pass # but if nothing is raised, the test still passes
except ValueError as e:
assert "must not be 0" in str(e)
else:
raise AssertionError("ratio(1, 0) should raise ValueError")
# With pytest: with pytest.raises(ValueError, match="must not be 0"): ratio(1, 0)

When Best isn’t best: in a real test file, use pytest.raises. It’s shorter, and its failure message is clearer.

send_welcome(user, send) should call send(email, "Welcome, <name>!") once for a user with an email, and not at all for a user whose email is "". Write check_welcome(send_welcome) using a fake send that records its calls. Three buggy versions: one sends twice, one sends to the wrong address, and one sends even with no email.

Hint 1 of 3

Passing print as send runs the code, but checks nothing.

Hint 2 of 3

Record each call in a list, then compare the whole list, not just the first item.

Hint 3 of 3

Add a second user with email "" and check that nothing was sent.

Test it without sending email

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: check_welcome(the right send_welcome) passes → None
  2. Not run yet: check_welcome(sends to the wrong address) fails → raises Exception
  3. Not run yet: check_welcome(sends twice) fails → raises Exception
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

print as send

The idea: Run send_welcome with print standing in for send.

No real email is sent, and crashes show up.

def check_welcome(send_welcome) -> None:
send_welcome({"name": "Ana", "email": "ana@example.com"}, print) # runs, checks nothing
Time
O(1)
Space
O(1)
Why
One call.

Where it breaks: nothing is checked, so every buggy version passes.

Better

Record, check the first

The idea: A lambda appends each address to a list; assert the first one.

The waste in Okay: it watches, but never judges.

The wrong address is now caught.

Highlighted lines are new in this rung.

def check_welcome(send_welcome) -> None:
sent = []
send_welcome({"name": "Ana", "email": "ana@example.com"}, lambda to, text: sent.append(to))
assert sent[0] == "ana@example.com" # looks at the first email only
Time
O(1)
Space
O(1)
Why
One call, one check.
What changed from the previous rung
def check_welcome(send_welcome) -> None:
send_welcome({"name": "Ana", "email": "ana@example.com"}, print) # runs, checks nothing
sent = []
send_welcome({"name": "Ana", "email": "ana@example.com"}, lambda to, text: sent.append(to))
assert sent[0] == "ana@example.com" # looks at the first email only

Where it breaks: a second email, the text, and the no-email case are never checked.

Best

Compare every call

The idea: Record (to, text) pairs, assert the whole list, and test a user with no email.

The waste in Better: it checks a piece of what happened.

Comparing the whole list catches extra calls, wrong text and wrong addresses in one line.

Highlighted lines are new in this rung.

def check_welcome(send_welcome) -> None:
sent = []
def fake_send(to: str, text: str) -> None: # records calls instead of sending
sent.append((to, text))
send_welcome({"name": "Ana", "email": "ana@example.com"}, fake_send)
assert sent == [("ana@example.com", "Welcome, Ana!")] # exactly one, exactly this
sent.clear()
send_welcome({"name": "Bo", "email": ""}, fake_send)
assert sent == [] # no address: nothing sent
Time
O(1)
Space
O(1)
Why
Two calls, two checks.
What changed from the previous rung
def check_welcome(send_welcome) -> None:
sent = []
send_welcome({"name": "Ana", "email": "ana@example.com"}, lambda to, text: sent.append(to))
assert sent[0] == "ana@example.com" # looks at the first email only
def fake_send(to: str, text: str) -> None: # records calls instead of sending
sent.append((to, text))
send_welcome({"name": "Ana", "email": "ana@example.com"}, fake_send)
assert sent == [("ana@example.com", "Welcome, Ana!")] # exactly one, exactly this
sent.clear()
send_welcome({"name": "Bo", "email": ""}, fake_send)
assert sent == [] # no address: nothing sent

When Best isn’t best: if the code calls a module directly instead of taking send as an argument, unittest.mock.patch swaps it during the test. Passing it in, as here, is simpler.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choiceWhich function will pytest run as a test, in test_shop.py?
  2. Spot the bugThis test passes even when f() raises nothing. Why?
    def test_bad_input():
        try:
            f(-1)
            assert False
        except Exception:
            pass
  3. Multiple choiceYou want to run one test against 10 input and answer pairs. What fits?
  4. Multiple choicesend_welcome(user, send) emails a user. How should its test check it?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • File test_x.py, function def test_y():, plain assert.
  • pytest runs them all; pytest -k name runs some; -x stops at the first failure.
  • @pytest.mark.parametrize(("a", "b"), [(1, 2), …]) for tables.
  • with pytest.raises(ValueError, match="…"): for errors.
  • tmp_path for files; pass fakes for email, time and the network.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started