I fixed one bug, and two old ones came back
See the problem
Section titled “See the problem”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.
Define the task
Section titled “Define the task”- 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.
Pause and think
Section titled “Pause and think”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.
Understand the idea
Section titled “Understand the idea”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") == 129950pytest finds every test, runs each one on its own, and reports each failure in detail:
$ pytestFAILED test_price.py::test_parse_price[£5.5-550] - assert 505 == 5501 failed, 4 passed in 0.02sThree tools cover most tests:
| Tool | Use it for |
|---|---|
@pytest.mark.parametrize | One test, many cases: a table of inputs and answers. |
with pytest.raises(ValueError, match="…") | Checking that bad input raises the right error. |
| A fake | Passing in a fake for email, time or the network, which records what was asked of it. |
Climb the ladder
Section titled “Climb the ladder”Here c is the number of cases.
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.
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.
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: pytestimport pytestfrom 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: pytestimport pytestfrom 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.
Write it yourself
Section titled “Write it yourself”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- Not run yet:
check(the right parse_price) passes→None - Not run yet:
check(a parse_price that forgets commas) fails→ raisesException - Not run yet:
check(a parse_price that reads “5” pence as 5p) fails→ raisesException - 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
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.
Compare
Section titled “Compare”| Way | Knows the answers | Reports every failure | Shows what came back | Use it when |
|---|---|---|---|---|
| Okay: print | No | No | Yes | Exploring, once |
| Better: hand-made checks | Yes | No | Only if you wrote it in | A script with no test tools |
| Best: pytest | Yes | Yes | Yes | Any code you keep |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- 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_errortells 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_pathand 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.
Say it in the interview
Section titled “Say it in the interview”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 plainassert, which pytest rewrites to show both sides when they fail. Forparse_priceI’d useparametrizewith a table of inputs and pence, including the edges that broke before, like commas. Empty input getspytest.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. Thenpytestruns in CI on every push.”
Likely follow-ups
- What is a fixture? A function marked
@pytest.fixturethat builds test data or set-up; tests ask for it by naming it as an argument.tmp_pathis a built-in one. - Mock or fake? A fake is a small working fake you write.
unittest.mockbuilds 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.
Practice
Section titled “Practice”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.
1. Leap years (easy)
Section titled “1. Leap years (easy)”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- Not run yet:
check_leap(the right is_leap) passes→None - Not run yet:
check_leap(every 4th year is a leap year) fails→ raisesException - Not run yet:
check_leap(forgets the 400 rule) fails→ raisesException
Try your own input
Show the Okay, Better and Best answers
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.
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 yearWhere it breaks: the version that forgets the 400 rule still passes, because 2000 is never tried.
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 400When Best isn’t best: for rules with many inputs, property-based testing with the hypothesis package tries thousands of years for you.
2. Check the error (medium)
Section titled “2. Check the error (medium)”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- Not run yet:
check_ratio(the right ratio) passes→None - Not run yet:
check_ratio(lets ZeroDivisionError through) fails→ raisesException - Not run yet:
check_ratio(returns 0 for b = 0) fails→ raisesException
Try your own input
Show the Okay, Better and Best answers
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.
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 passesWhere it breaks: a version that returns 0 raises nothing, so the try finishes quietly and the check passes.
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.
3. Test it without sending email (hard)
Section titled “3. Test it without sending email (hard)”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- Not run yet:
check_welcome(the right send_welcome) passes→None - Not run yet:
check_welcome(sends to the wrong address) fails→ raisesException - Not run yet:
check_welcome(sends twice) fails→ raisesException - 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
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.
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 onlyWhere it breaks: a second email, the text, and the no-email case are never checked.
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 sentWhen 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.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- File
test_x.py, functiondef test_y():, plainassert. pytestruns them all;pytest -k nameruns some;-xstops at the first failure.@pytest.mark.parametrize(("a", "b"), [(1, 2), …])for tables.with pytest.raises(ValueError, match="…"):for errors.tmp_pathfor files; pass fakes for email, time and the network.
Go deeper
Section titled “Go deeper”- pytest: get started.
- How to use fixtures.
unittest.mock, for when you can’t pass a fake in.- Before this: Functions. Next: pandas basics.
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