PYTHON / TESTING AND TOOLING
Why tests, and what to test first
Decide which code deserves tests first and turn risky branches, boundaries, and bug reports into concrete input/expected cases.
What you will learn
- Rank code for testing by consequence of a wrong answer, not by file order
- Write boundary cases (0, 1, empty, the exact threshold) before mid-range cases
- Turn every bug report into a failing input/expected pair before fixing the code
- Skip tests that only re-verify the standard library or a thin delegating wrapper
Understanding Why tests, and what to test first
The payoff of a test is rarely 'proving the code works today' — you can do that by calling the function once in a REPL. The payoff is that it notices when behaviour changes tomorrow: after a refactor, a dependency upgrade, or a colleague's 'small tweak'. So the mental model is insurance: each test buys protection against one specific silent failure, and you pay a premium in maintenance every time the code moves. That means you should buy insurance where the potential loss is largest, not spread it evenly over every line.
Rank candidates by consequence times likelihood of being wrong. Pure functions that contain branching, arithmetic, rounding, parsing, validation, or date handling score highest: they are cheap to call, need no setup, and have many ways to quietly return the wrong number. Low-value candidates are `__repr__` methods, one-line wrappers that just forward arguments, and code whose behaviour is really the library's behaviour — asserting that `json.dumps` produces a string tests CPython's test suite, not yours.
Once you have picked a function, start at its edges rather than in the comfortable middle. Bugs cluster where a comparison operator or an index decides between two branches: the empty list, the first element, zero, a negative number, the exact threshold value. The second rich source of first tests is your bug tracker: a reported failure is a real input someone actually produced, so record it as a case, watch it fail, then fix the code. That case stays forever as proof the same regression cannot come back unnoticed.
def tier_discount(quantity):
"""Percent discount: 0 below 10 units, 5 from 10 units up."""
if quantity <= 0:
raise ValueError("quantity must be positive")
if quantity > 10: # off-by-one: 10 units should already qualify
return 5
return 0
# First cases worth writing: the threshold and its neighbours, not the middle.
cases = [(1, 0), (9, 0), (10, 5), (11, 5), (500, 5)]
failures = 0
for quantity, expected in cases:
actual = tier_discount(quantity)
if actual == expected:
print(f"ok quantity={quantity:<3} -> {actual}%")
else:
failures += 1
print(f"FAIL quantity={quantity:<3} -> {actual}% (expected {expected}%)")
print(f"{len(cases) - failures} passed, {failures} failed")Tests pay for themselves by catching silent behaviour changes, so the first ones belong on branchy, high-consequence logic exercised at its boundary values.
Worked examples
A bug report becomes the first test
Shows how one reported crashing input turns into a permanent case that documents the intended behaviour.
def average(values):
return sum(values) / len(values)
def average_fixed(values):
if not values:
return 0.0
return sum(values) / len(values)
def run(fn, values):
try:
return fn(values)
except ZeroDivisionError as exc:
return f"crash: {type(exc).__name__}"
print("reported input, old code:", run(average, []))
print("reported input, new code:", run(average_fixed, []))
print("normal input still works:", run(average_fixed, [2, 4, 9]))Example explained
Line 1`sum([]) / len([])` is `0 / 0`, so the empty list is the boundary case nobody tried by hand.
Line 2`run` catches the exception so the failing case can be recorded and printed instead of stopping the script.
Line 3The third line is just as important: it proves the fix for the edge case did not change the normal path.
Line 4Both inputs are now cases you keep, so a future rewrite of `average_fixed` cannot silently reintroduce the crash.
Test your rule, not the standard library
Separates a worthless assertion about json.dumps from valuable assertions about your own normalisation rules.
import json
def to_payload(user):
# Our rules: names are trimmed and lowercased; blank email becomes None.
return {"name": user["name"].strip().lower(), "email": user.get("email") or None}
# Low value: this only checks that json.dumps serialises a dict.
print(json.dumps(to_payload({"name": " Ada "}), sort_keys=True))
# High value: these pin down decisions we made and could accidentally change.
assert to_payload({"name": "ADA"})["name"] == "ada"
assert to_payload({"name": " Ada "})["name"] == "ada"
assert to_payload({"name": "Ada", "email": ""})["email"] is None
print("rules hold")Example explained
Line 1The `json.dumps` line exercises code maintained and tested elsewhere; if it broke, your test would be the least of your problems.
Line 2The three asserts encode choices a reader cannot guess from the signature: trimming, lowercasing, and blank-to-None.
Line 3`or None` turns `""` into `None`, which is exactly the subtle behaviour worth locking down with `is None`.
Line 4`sort_keys=True` is only there to make the printed dict order deterministic for this example.
Record current behaviour before refactoring
Captures a messy function's outputs for chosen inputs so a rewrite can be compared against them instead of against memory.
import re
def old_slugify(title):
out = ""
for ch in title.lower():
if ch.isalnum():
out += ch
elif out and not out.endswith("-"):
out += "-"
return out.strip("-")
def new_slugify(title):
return re.sub(r"\W+", "-", title.lower()).strip("-")
samples = ["Hello World", "Python 3.12 rocks!", "user_name here"]
for s in samples:
old, new = old_slugify(s), new_slugify(s)
flag = "same" if old == new else "DIFFERENT"
print(f"{s!r} -> old {old!r} | new {new!r} | {flag}")
changed = sum(1 for s in samples if old_slugify(s) != new_slugify(s))
print(f"{changed} of {len(samples)} recorded outputs changed")Example explained
Line 1The first two samples agree, which is why testing only obvious inputs would have approved the rewrite.
Line 2`\W` treats `_` as a word character, so the regex keeps the underscore while the loop replaced it with `-`.
Line 3Recording old outputs for inputs containing punctuation, digits and underscores is what makes the divergence visible.
Line 4The final count is the signal you want before a refactor: any non-zero number means behaviour, not just code, changed.
Important notes
A test that has never failed has proven nothing yet; make it fail once, on purpose, to confirm it actually reaches the branch you care about.
Plain `assert` statements are fine for exploring cases, but they are stripped when Python runs with `-O`, which is one reason real suites use a test runner instead.
Common mistakes
Testing only comfortable mid-range inputs like `tier_discount(25)`, which passes with both `>` and `>=`, leaving the off-by-one at 10 to reach production.
Computing the expected value with the same expression the function uses (`expected = quantity * rate`), so the test agrees with the bug and can never fail.
Fixing a reported bug directly in the code without first recording the failing input, so nothing prevents the same regression from returning after the next refactor.
Try it yourself
Change, predict, then run
Write `late_fee(days_late)` returning 0 for 0 days, 5 for 1 through 7 days, and 15 beyond that, plus a list of (input, expected) pairs covering every boundary and a loop printing pass/fail. Then change one comparison to the wrong operator and confirm at least one case fails.
Open the Python workspaceCheck your understanding
You have 30 minutes to add the first tests to a payment module. Which choice protects you most against future silent breakage?
- Input/expected pairs for the fee-rounding function at and around each threshold amount
- A test asserting that importing the module raises no exception
- A test asserting that Decimal('1.00') + Decimal('2.00') == Decimal('3.00')
- A test asserting that Payment.__repr__ returns a string
Show answer
Fee rounding is your own branchy logic where a wrong answer costs real money, and the thresholds are exactly where an operator mistake hides. The Decimal assertion is tempting because it looks numeric and financial, but it exercises the standard library, which your edits cannot break, so it will pass forever regardless of what you change.