Unit tests & assertions
Testing functions and classes properly: arrange-act-assert, edges, exceptions, the FIRST principles, and enforcing test quality — all runnable.
Learning objectives
- Structure tests as Arrange-Act-Assert.
- Cover edge cases and exceptions.
- Test stateful classes.
- Apply the FIRST principles and spot bad tests.
code/tq2-unit-testing/. Python blocks run offline; config files are ready to drop into a project.1 · Arrange, Act, Assert essential
aaa.pydef apply_discount(price, pct):
if not 0 <= pct <= 100: raise ValueError("pct must be 0-100")
return round(price * (1 - pct/100), 2)
# Arrange / Act / Assert
price, pct = 100.0, 20
result = apply_discount(price, pct)
assert result == 80.0
print("AAA test passed:", result)
AAA test passed: 80.0
This is the shape almost every good test follows: Arrange-Act-Assert (AAA). You set up the inputs, run the code once, then check the result. Splitting a test into these three parts keeps it easy to read and makes a failure point straight at the cause. The code under test is apply_discount, which knocks a percentage off a price.
- Arrange —
price, pct = 100.0, 20sets up the inputs the test will use. No logic here, just the starting values. - Act —
result = apply_discount(price, pct)calls the function once and stores what it hands back. A test should exercise one behaviour, so there's a single act. - Assert —
assert result == 80.0is the actual check.assertdoes nothing if the condition is true, but stops the program with an error if it's false. So a silent run means the test passed; a crash means it failed. - The
printline only runs if the assert passed, which is why seeing the message confirms success. The function itself also guards its input —raise ValueErrorrejects a percentage outside 0-100.
What the output means: You see AAA test passed: 80.0. 20% off 100.0 is 80.0, the assert held, so the final print ran. If the maths were wrong you'd get an AssertionError instead and no message.
Try this: Change the assert to assert result == 79.0 and re-run. Now it fails with AssertionError — that's a failing test, and it's exactly what you want to see when the code is actually broken.
2 · Edge cases essential
edges.pyfrom math import isclose
def apply_discount(price, pct): return round(price*(1-pct/100), 2)
assert apply_discount(50, 0) == 50.0 # no discount
assert apply_discount(50, 100) == 0.0 # full discount
assert isclose(apply_discount(9.99, 10), 8.99)
assert apply_discount(0, 20) == 0.0 # zero price
print("all edge cases pass")
all edge cases pass
One 'happy path' test isn't enough. Edge cases are the boundary and unusual inputs where bugs love to hide: the smallest value, the largest, zero, and awkward decimals. Here we hammer apply_discount with several of them in a row, one assert per case.
apply_discount(50, 0) == 50.0— the lower boundary: a 0% discount should change nothing. The commentno discountdocuments why this case matters.apply_discount(50, 100) == 0.0— the upper boundary: a 100% discount should zero the price out. Boundaries (0 and 100 here) are the classic place things break.isclose(apply_discount(9.99, 10), 8.99)usesiscloseinstead of==because decimals aren't exact in a computer (9.99 * 0.9 may come out as 8.9910000001).iscloseasks 'close enough?', which is the right test for floats.apply_discount(0, 20) == 0.0checks a zero price — a different edge from a zero percentage. Each assert isolates one idea, so a failure tells you precisely which one broke.
What the output means: all edge cases pass prints only after every assert on the way down succeeds. If any single case failed, execution would stop there and you'd never reach the print.
Try this: Add assert apply_discount(9.99, 10) == 8.991 using == instead of isclose. It may fail on float rounding — a first-hand lesson in why comparing decimals with == is risky.
3 · Testing exceptions intermediate
Code should fail correctly on bad input. In pytest you use pytest.raises; the underlying idea is a try/except assertion, shown here runnable.
exceptions.pydef apply_discount(price, pct):
if not 0 <= pct <= 100: raise ValueError("pct must be between 0 and 100")
return price * (1 - pct/100)
def assert_raises(exc, fn, *a):
try:
fn(*a)
except exc as e:
return str(e)
raise AssertionError("expected an exception, none raised")
msg = assert_raises(ValueError, apply_discount, 100, 150)
print("raised as expected:", msg)
# pytest form: with pytest.raises(ValueError, match="between 0 and 100"): apply_discount(100,150)
raised as expected: pct must be between 0 and 100
Good code doesn't just work on good input — it fails loudly on bad input. Here apply_discount is supposed to reject a percentage over 100 by raising a ValueError. So the test's job is the opposite of usual: it passes only when the code does throw an error. This block shows the idea by hand before pytest gives you a tool for it.
assert_raises(exc, fn, *a)is a little helper. It callsfn(*a)inside atryblock —*ajust means 'pass along whatever arguments were given'.- If the call raises the expected exception, the
except excbranch catches it and returns the error message. That's the success path here. - If the call doesn't raise, control falls through to
raise AssertionError("expected an exception, none raised")— the test fails, because the code should have complained and didn't. assert_raises(ValueError, apply_discount, 100, 150)feeds an illegal 150% discount and expects theValueError. The commented last line shows the real pytest form:with pytest.raises(ValueError, match=...)does all of this for you.
What the output means: raised as expected: pct must be between 0 and 100 — the function threw the error, the helper caught it, and printed its message. Catching the right error with the right message is what proves the guard works.
Try this: Call assert_raises(ValueError, apply_discount, 100, 50) with a valid 50%. Now no exception is raised, so you'll hit the AssertionError — showing the helper correctly fails when the error it expected never happens.
4 · Testing a class (state) advanced
test_class.pyclass Cart:
def __init__(self): self.items = []
def add(self, item, qty=1): self.items.append((item, qty))
def total_qty(self): return sum(q for _, q in self.items)
cart = Cart()
cart.add("book"); cart.add("pen", 3)
assert cart.total_qty() == 4
assert len(cart.items) == 2
assert Cart().total_qty() == 0
print("class behavior verified")
class behavior verified
The earlier tests checked a plain function. A class holds state — data that changes as you call its methods — so testing it means checking that the state is correct after a sequence of calls. Cart is a shopping cart that remembers the items you add.
- Arrange —
cart = Cart()creates a fresh, empty cart.__init__startsself.itemsas an empty list. - Act —
cart.add("book"); cart.add("pen", 3)makes two calls that mutate the cart. The second usesqty=3; the first relies on the defaultqty=1. - Assert —
cart.total_qty() == 4checks the summed quantity (1 + 3), andlen(cart.items) == 2checks two distinct line-items were stored. Together they verify both what was counted and what was kept. Cart().total_qty() == 0spins up a brand-new cart to confirm a fresh object starts empty. Using a separate instance keeps this check isolated from the first cart's state — an important habit when testing stateful objects.
What the output means: class behavior verified prints after all three asserts hold: the running total is 4, two items are stored, and a new cart is empty. A wrong total or count would stop the run at that assert.
Try this: Add cart.add("lamp", 2) before the asserts, then update the expected total to 6 and the length to 3. Watching the numbers move together shows how each method call changes the object's state.
5 · Professional — the FIRST principles professional
| Principle | Means | Anti-pattern prevented |
|---|---|---|
| Fast | milliseconds | a suite too slow to run |
| Isolated | no shared state/order | pass alone, fail together |
| Repeatable | deterministic | flaky tests you ignore |
| Self-validating | clear pass/fail | "check output by eye" |
| Timely | written with the code | untested legacy piling up |
6 · Tech-lead — enforce test quality tech-lead
A lead catches bad tests in review, not just missing ones. Here's a lint that flags common test smells — the kind of check you'd add to CI.
test_lint.pyimport re
def lint_test(src: str):
smells = []
if "assert " not in src:
smells.append("no assertion — test proves nothing")
if re.search(r"assert True\b", src):
smells.append("asserts True — meaningless")
if "time.sleep" in src:
smells.append("sleeps — slow/flaky; mock time instead")
if src.count("assert") > 8:
smells.append("many asserts — likely testing too much at once")
return smells
bad = """def test_thing():
do_work()
time.sleep(2)
assert True"""
print("smells:", lint_test(bad))
good = """def test_add():
assert add(2, 3) == 5"""
print("smells:", lint_test(good) or "none — clean")
smells: ['no assertion — test proves nothing', 'asserts True — meaningless', 'sleeps — slow/flaky; mock time instead']
smells: none — clean
A senior engineer reviews tests as carefully as real code, because a bad test gives false confidence. This block is a tiny 'linter' — lint_test reads the source text of a test and flags common test smells. It's the kind of automated check you'd wire into CI so reviewers don't have to catch every one by hand.
if "assert " not in src:— a test with no assertion checks nothing; it can never fail, so it's worthless. This is the most important smell to catch.re.search(r"assert True\b")flagsassert True, which is always true and therefore meaningless — it looks like a test but proves nothing.if "time.sleep" in src:flags sleeping, which makes tests slow and flaky; the fix is to fake/mock time (covered in TQ3) rather than actually wait.if src.count("assert") > 8:warns that too many asserts in one test usually means it's testing several things at once — hard to read and hard to diagnose when it fails. The functionreturn smells, a list of every problem it found.- The two samples make the point:
badsleeps and onlyassert Trues, whilegoodhas one real assertion.lint_test(good) or "none — clean"uses truthiness — an empty list is falsy, so a clean test prints the friendly fallback.
What the output means: The bad test reports three smells (no real assertion, asserts True, sleeps); the good test reports none — clean. Each string names exactly what a reviewer should push back on.
Try this: Add assert 1 == 1 to the bad string. The 'no assertion' smell disappears but 'asserts True — meaningless' logic still shows the others — proving the linter checks each smell independently.
True gives false confidence — worse than no test. Leads make test quality a review criterion and can automate the obvious smells, as above.Exercise TQ2.1 — Test a BankAccount
Context: A stateful class with an enforced invariant is the canonical unit-testing target, and running your own lint over the tests closes the loop — proving the tests actually assert something.
Your task: Write a BankAccount (deposit, withdraw, balance, raising on overdraft) with assertions covering normal operations, the overdraft exception, zero/negative amounts, and a running balance — then run a lint_test over your own tests to check for smells.
Requirements:
- Deposit and withdraw update the balance; withdraw beyond the balance raises
- Zero or negative amounts are rejected
- Assertions cover a normal sequence, the overdraft exception, invalid amounts, and the running balance across several operations
- The balance is unchanged after any rejected operation
- Run the assertion-less-test checker over your test code and confirm no test is flagged
💡 Hint: Test the object as a sequence of calls, not one call at a time — the invariant you care about is that the balance stays valid across the whole sequence.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every readable test follows the same three beats: Arrange the inputs, Act by calling the thing under test once, Assert the result. AAA makes a test's intent obvious at a glance.
Your task: Write an Arrange-Act-Assert test for apply_discount(price, pct) using stdlib unittest, with the three sections clearly separated.
Requirements:
apply_discountreturns the price reduced bypctpercent, rounded to two decimals- The test has visibly distinct Arrange, Act, and Assert steps
- Exactly one action (the call under test) sits in the Act step
- Assert with
self.assertEqualon a known result (e.g. 10% off 100 is 90.0) - Runs under
unittest.mainwith no external dependencies
💡 Hint: Keep a single call in the Act line and everything else around it — a reader should spot exactly what is being verified without parsing the whole method.
Show solution
AAA makes a test readable at a glance:
import unittest
def apply_discount(price, pct):
if not 0 <= pct <= 100:
raise ValueError("pct must be 0-100")
return round(price * (1 - pct / 100), 2)
class TestDiscount(unittest.TestCase):
def test_ten_percent_off(self):
price, pct = 100.0, 10 # Arrange
result = apply_discount(price, pct) # Act
self.assertEqual(result, 90.0) # Assert
if __name__ == "__main__":
unittest.main(verbosity=2) # OK
The three sections separate setup, the one action under test, and the check — so a reader sees exactly what's being verified.
Context: Bugs cluster at the edges — the boundary values where behavior changes. Testing 0%, 100%, and a rounding case pins down the money-critical behavior a naive float would get wrong.
Your task: Add edge-case tests for apply_discount: a 0% discount, a 100% discount, and an input that exercises the two-decimal rounding.
Requirements:
- A 0% discount leaves the price unchanged
- A 100% discount drives the price to
0.0 - A rounding case asserts the two-decimal result exactly (e.g. 10% off 9.99 is 8.99)
- Each edge lives in its own named test method
- All cases run offline under
unittest
💡 Hint: The interesting values are the extremes of the valid range plus one input whose exact result depends on rounding — those three catch the most bugs per test.
Show solution
Edges are where behavior changes — test them explicitly:
import unittest
def apply_discount(price, pct):
if not 0 <= pct <= 100: raise ValueError("pct must be 0-100")
return round(price * (1 - pct / 100), 2)
class TestEdges(unittest.TestCase):
def test_zero_percent(self): self.assertEqual(apply_discount(50, 0), 50.0)
def test_full_discount(self): self.assertEqual(apply_discount(50, 100), 0.0)
def test_rounding(self): self.assertEqual(apply_discount(9.99, 10), 8.99)
if __name__ == "__main__":
unittest.main(verbosity=2) # 3 tests OK
0% and 100% are the boundaries; the rounding case pins the money-critical behavior that a naive float would get wrong.
Context: Error paths are behavior too. A function that is supposed to reject bad input must be tested for that rejection, or the contract callers rely on is unproven.
Your task: Assert that apply_discount raises on out-of-range percentages, using assertRaises, and check the exception message as well as its type.
Requirements:
- A negative percentage raises
ValueError(asserted withwith self.assertRaises(...)) - A percentage above 100 also raises
- For one case, capture the exception and assert its message contains the valid range (e.g.
"0-100") - Both the exception type and the message are locked in
- Runs offline under
unittest
💡 Hint: assertRaises works as a context manager; bind it with as ctx when you also want to inspect str(ctx.exception).
Show solution
A raised exception is behavior you assert, not an accident:
import unittest
def apply_discount(price, pct):
if not 0 <= pct <= 100: raise ValueError("pct must be 0-100")
return round(price * (1 - pct / 100), 2)
class TestErrors(unittest.TestCase):
def test_negative_pct(self):
with self.assertRaises(ValueError):
apply_discount(100, -5)
def test_over_100(self):
with self.assertRaises(ValueError) as ctx:
apply_discount(100, 150)
self.assertIn("0-100", str(ctx.exception))
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK
Asserting both the exception type and its message locks in the contract for callers relying on the error.
Context: State is where bugs breed. A stateful object must be tested across a sequence of operations — asserting that an invariant holds throughout, not just that one call works.
Your task: Test a BankAccount across a sequence of deposits and withdrawals, asserting the invariant that the balance never goes negative even when an operation is rejected.
Requirements:
BankAccountsupports deposit and withdraw and rejects invalid amounts- Use
setUpto give each test a fresh account - One test runs a deposit-then-withdraw sequence and asserts the resulting balance
- Another test asserts an overdraw raises and leaves the balance unchanged
- The balance after a rejected withdrawal equals the balance before it
- Runs offline under
unittest
💡 Hint: The setUp method giving each test its own account is the "Isolated" in FIRST — that fresh state is what stops one test from leaking into another.
Show solution
For stateful objects, test sequences of operations, not single calls:
import unittest
class BankAccount:
def __init__(self): self.balance = 0
def deposit(self, amt):
if amt <= 0: raise ValueError("amount must be positive")
self.balance += amt
def withdraw(self, amt):
if amt > self.balance: raise ValueError("insufficient funds")
self.balance -= amt
class TestAccount(unittest.TestCase):
def setUp(self): self.acct = BankAccount() # fresh per test
def test_deposit_then_withdraw(self):
self.acct.deposit(100); self.acct.withdraw(30)
self.assertEqual(self.acct.balance, 70)
def test_overdraw_blocked(self):
self.acct.deposit(50)
with self.assertRaises(ValueError):
self.acct.withdraw(80)
self.assertEqual(self.acct.balance, 50) # unchanged after failure
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK
setUp gives each test a fresh account so tests don't leak state into each other — the "Isolated" in FIRST.
Context: Good unit tests are Fast, Isolated, Repeatable, Self-validating, and Timely (FIRST). A test that depends on the real clock or on shared module state violates FIRST and turns flaky.
Your task: Take a test that breaks FIRST — one that reads the wall clock or mutates shared state — and refactor both the code and the test so it complies.
Requirements:
- Show the offending version that depends on real time or module-level state
- Refactor the function to accept its input (e.g. an
hourparameter) instead of reading the clock - The refactored tests pass deterministically regardless of when they run
- Cover at least two branches (e.g. morning and afternoon)
- Name which FIRST properties the refactor restores
💡 Hint: Pushing the time-dependent value in as an argument removes the hidden dependency on datetime.now() — the same trick works for randomness and environment reads.
Show solution
Refactor a flaky test into a FIRST-compliant one:
import unittest
# BAD: not Repeatable (depends on wall clock), not Isolated (module state)
_cache = {}
def greet_bad():
import datetime
h = datetime.datetime.now().hour
return "Good morning" if h < 12 else "Good afternoon"
# GOOD: inject the input -> Fast, Isolated, Repeatable, Self-validating
def greet(hour):
return "Good morning" if hour < 12 else "Good afternoon"
class TestGreet(unittest.TestCase):
def test_morning(self): self.assertEqual(greet(9), "Good morning")
def test_afternoon(self): self.assertEqual(greet(15), "Good afternoon")
if __name__ == "__main__":
unittest.main(verbosity=2) # deterministic, 2 tests OK
Injecting hour removes the dependency on the real clock, so the test passes at any time of day — repeatable and fast.
Context: Tech-lead reality: a test that passes but asserts nothing is worse than no test — it is a false sense of safety. Catching "green but empty" tests is a reviewable, automatable check.
Your task: Write a lint-style checker that parses test source and flags every test_ function that contains no assertion.
Requirements:
- Parse the source into an AST rather than scanning text
- Inspect only functions whose name starts with
test_ - Flag a function when no
assertstatement appears anywhere in its body - Return the list of offending function names
- Demonstrate it on a source string with one real test and one assertion-less test
- Note that a real checker would also count
self.assert*/pytest.raisescalls
💡 Hint: Walk each function node with ast.walk and check for any ast.Assert; the AST catches the worst offenders that a naive text search would miss.
Show solution
A test with no assertion is a false sense of safety — lint for it:
import ast, textwrap
SRC = textwrap.dedent('''
def test_real():
assert add(2, 2) == 4
def test_empty():
add(2, 2) # calls but never asserts -> useless
''')
def find_assertionless(src):
tree = ast.parse(src)
bad = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
has_assert = any(isinstance(n, ast.Assert) for n in ast.walk(node))
# also count self.assert*/pytest.raises calls in real projects
if not has_assert:
bad.append(node.name)
return bad
print(find_assertionless(SRC)) # ['test_empty']
Parsing the AST catches the classic "green but empty" test. In a real suite you'd also flag self.assert*/pytest.raises calls, but the assertion check catches the worst offenders.
✓ Checkpoint — you can move on when you can…
- Structure tests AAA; cover edges.
- Test exceptions and stateful classes.
- Name the FIRST principles.
- Detect and reject bad tests as a reviewer.
Knowledge check check yourself
The lesson tests apply_discount(9.99, 10) with isclose(...) rather than ==. Why is == the wrong comparison here?
Show answer
== check can spuriously fail. isclose asks "close enough?", which is the correct way to compare floats.The lesson calls a flaky test "worse than no test." What does the F-I-R-S-T "Isolated" principle prescribe to avoid flakiness, and why does flakiness cause real harm?