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
Open a real project's test folder and you'll notice something eerie: the good tests all look the same. Not because everyone copied each other, but because a readable test has a natural shape, and once you've debugged a few messy ones you converge on it. The messy tests are the ones where setup, the call, and the check are tangled together across fifteen lines — when one fails at 2am you can't tell what was being verified, let alone why it broke. The clean ones you can read in three seconds. That shape has a name: Arrange, Act, Assert.
Think of it like a physics experiment written up in a lab notebook. First you set up the apparatus (arrange your inputs and objects). Then you run the one experiment (act — call the function under test exactly once). Then you record whether the result matched your prediction (assert). A good scientist never runs three experiments in one breath and never eyeballs the result — and a good test does the same, isolating a single behaviour so that when the assert fails, the failure is the diagnosis.
Here's the precise definition and why each beat earns its place. Arrange builds the world the test needs: the input values, the object under test, any preconditions — but no logic you're trying to verify. Act is the single line that exercises the behaviour under test; keeping it to one call is what lets a reader instantly see the subject of the test. Assert states the expectation as a machine-checkable claim, so the test decides pass/fail on its own rather than asking a human to squint at printed output. The tool that makes the assert bite is Python's assert statement: it does nothing when its condition is true and raises AssertionError when it's false, which is exactly the behaviour a test runner like pytest hooks into to report a red result.
The common mistake is smearing the three beats together — arranging inside the assert, or acting twice and checking once so you can't tell which call was wrong. The moment a test does more than one act, ask whether it's really two tests wearing one coat. AAA isn't bureaucracy; it's the discipline that turns a failing test from a mystery into a signpost.
assert either passes silently or raises AssertionError — which is the red the test runner reports.
Scan the top row left to right — that is the order the three beats run in a single test — then follow the two arrows dropping out of the Assert box.
- The blue box (Arrange) sets up inputs and the object under test; the amber box (Act) is the one call you are verifying — note it is deliberately alone, because a second Act would blur what the test is about.
- The white box (Assert) states the expected result as a claim. The two arrows below it are the only two outcomes a Python
assertcan produce. - The green box is the payoff: a true condition makes
assertdo nothing, so the test passes silently. The red box is a false condition raisingAssertionError— the failure a runner like pytest turns into a red result.
In short: three beats, one Act, and a final claim whose only outcomes are a silent pass or an AssertionError.
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
Here's a pattern that repeats in almost every post-mortem you'll ever read. The code shipped with a test. The test passed. Weeks later it fell over — on an empty list, on a quantity of zero, on the very first day of the month, on a discount of exactly 100%. The test wasn't wrong; it just tested the one comfortable input the author had in mind (the "happy path") and none of the awkward ones at the edges of the valid range. Real bugs don't live in the middle of the range. They lurk at the boundaries.
Picture the set of inputs your function accepts as a fenced field. The happy path is somewhere in the safe green middle. But the bugs pile up along the fence line: the smallest legal value, the largest, the exact threshold where an if flips from one branch to the other, zero, an empty collection, a single-element collection. These are the places where an off-by-one slips in, where < should have been <=, where a divide-by-length blows up because the length was 0. Testers call this boundary-value analysis, and it's the highest-yield habit in the whole discipline: a handful of boundary tests catch more bugs than a hundred random middle-of-the-range ones.
So the technique is deliberate: for every function, ask "what are the extremes and the special values here?" and write an assertion for each. For apply_discount the boundaries are the ends of the legal percentage range — 0% (should change nothing) and 100% (should zero the price) — plus a zero price (a different axis of edge), plus an input that forces the rounding to actually do something. Notice that last one drags in a second, sharper lesson: floating-point numbers are not exact. 9.99 * 0.9 may land on 8.9910000000001 rather than a clean 8.99, so comparing floats with == is a trap that fails intermittently. The fix is math.isclose, which asks "close enough, within a tiny tolerance?" — the correct question for anything computed in floating point.
The mistake to avoid is treating edge cases as an afterthought you'll "add later." Later never comes, and the boundary is precisely where your one happy-path test gave you false confidence. Write the edges with the happy case, one clear assertion each, so a failure names exactly which boundary broke.
if flips branches; a zero price and a rounding case (which needs isclose, not ==) are the other high-yield edges.
Read the green bar as the whole valid range of discount percentages, 0 on the left to 100 on the right; then look at the markers hanging off it.
- The green bar is the happy path — the comfortable middle values a first test usually picks. It is deliberately calm because that is where bugs almost never are.
- The two red dots at each end are the boundaries: 0% (should change nothing) and 100% (should zero the price). These are where an off-by-one or a
<-vs-<=slip lives. - The lower boxes are two further edges on different axes: a zero price (red), and the amber rounding case where
9.99 × 0.9lands on8.991…— proof that floats neediscloserather than an exact==.
In short: write one assertion at each boundary and special value, not just one in the safe middle — that is boundary-value analysis.
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
Beginners test only the sunny day. They check that apply_discount(100, 20) returns 80 and move on, quietly assuming nobody will ever pass a discount of 150% or a negative price. But someone always does — a typo in a config file, a corrupted upstream message, a user fat-fingering a form — and the interesting question is what your code does then. Does it raise a clear error you can catch and log, or does it silently return garbage that poisons everything downstream? The answer is part of your function's contract, which means it deserves a test just as much as the happy path.
Here's the mental flip that trips people up the first time: for a normal test, an exception means failure. For an exception test, an exception is success — and the failure is when no exception fires. It's like testing a smoke alarm. You don't test it by confirming it stays quiet in clean air; you test it by holding a lit match under it and checking that it screams. A test that expects a ValueError passes precisely when the ValueError is thrown, and fails if the code sails through without complaint.
The bare-metal way to express that is a try/except: call the function, and if the expected exception is caught, good; if control falls past the try with no exception, raise an AssertionError yourself because the code failed to complain. That's exactly what the runnable helper below spells out, so you can see the machinery. In practice you don't write that boilerplate — pytest gives you pytest.raises as a context manager: with pytest.raises(ValueError): apply_discount(100, 150) passes only if the block raises ValueError. You should also pin down the message, not just the type, with pytest.raises(ValueError, match="between 0 and 100") — match takes a regular expression checked against the error text.
The subtle mistake is asserting only the exception type and ignoring its message. Two different bugs can both raise ValueError; if your test doesn't check which one, it can pass on the wrong error entirely. Lock in both the type and a distinctive fragment of the message, and your test proves the guard fired for the reason you intended.
Code should fail correctly on bad input. In pytest you use pytest.raises; the underlying idea is a try/except assertion, shown here runnable.
Start at the top box — the single call under test — then follow the two branches down; they are the only two things that call can do.
- The top blue box is the deliberately-bad call (
apply_discount(100, 150), an illegal 150%). Everything below is what happens next. - The solid left branch is "raises": the guard fired, so control lands in the green PASS box — but only if it is the right exception type with the right message fragment.
- The dashed right branch is "returns normally": the code stayed silent when it should have complained, so it falls into the red FAIL box. This is the inversion — here, no error is the bug.
In short: an exception test passes when the expected error is raised and fails when nothing is — the mirror image of an ordinary test.
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
A plain function is easy to test because it has no memory. Give it the same inputs and it gives you the same output, every time — nothing sticks. A class is a different animal. An object remembers: a shopping cart accumulates items, a bank account carries a balance, a connection holds a session. The behaviour you care about isn't "what does one method return" but "is the object's state still correct after a whole sequence of operations." That shift — from testing a snapshot to testing a story — is why stateful testing gets its own section.
Think of it like testing a vending machine rather than a calculator. A calculator you probe one sum at a time. A vending machine you have to drive through a scenario: insert a coin (balance goes up), insert another (it accumulates), press a button (balance goes down and a snack drops), then press again with no money (it should refuse and leave the balance untouched). Each step depends on the state left by the last one, and the bug you're hunting — a coin lost, a balance that goes negative — only shows up in the sequence, never in a single call.
So the recipe is: arrange a fresh object, act by making a series of calls that mutate it, then assert on the accumulated state. Two disciplines make this reliable. First, verify the invariant — the property that must hold no matter what, like "a bank balance never goes negative" or "the cart's total quantity equals the sum of what was added." Invariants are the real contract; individual return values are just evidence. Second — and this is the one people botch — each test must start from a clean object. If two tests share a mutated instance, one can pass or fail depending on which ran first, and you've built a flaky suite. In unittest the setUp method runs before every test and is the idiomatic place to mint a fresh instance; in pytest a fixture does the same job. Creating a brand-new object (as the lab does with Cart() for the empty-case check) is the same instinct in miniature.
The classic mistake is asserting only the final number and never checking that a rejected operation left state unchanged — e.g. that an over-limit withdrawal both raises and leaves the balance exactly where it was. A guard that raises but has already corrupted the state is worse than no guard at all, and only a state-after-failure assertion will catch it.
Read the top row left to right as time passing — each box is one method call, and the blue arrows carry the object's state to the next step.
- The white box is a fresh object (balance 0), the discipline that keeps each test isolated. The two green boxes are successful mutations: deposit raises the balance to 100, withdraw drops it to 70.
- The red box is an over-limit
withdraw(80)— rejected, because it would push the balance negative and break the invariant. - The amber box below is the payoff assertion most people forget: after that rejection the balance is still 70. A guard that raises but has already changed the state would be caught right here.
In short: arrange a fresh object, act with a sequence of calls, then assert both the running balance and that a rejected operation left state untouched.
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
Watch what happens to a test suite that isn't cared for. It starts fast and trusted. Over a year it grows: a few tests hit the real network, one reads the system clock, a couple leave files lying around that the next test trips over. Now the suite takes eight minutes and fails "randomly" once in every ten runs. So the team does the rational-but-fatal thing — they stop trusting it. Failures get re-run until they go green. And the day a failure is real, nobody notices, because a red suite has become background noise. FIRST is the checklist that stops a suite from decaying into that.
FIRST is a mnemonic — Fast, Isolated, Repeatable, Self-validating, Timely — and each letter is really a defense against one specific way suites rot. Think of them as the five habits of a suite people actually run:
Read the green row left to right as the mnemonic F-I-R-S-T, then drop straight down each arrow to the red box it defends against.
- The green top row is the five habits of a healthy suite — Fast, Isolated, Repeatable, Self-validating, Timely — with a one-word reminder of what each demands.
- Each dashed arrow points from a principle to the red box directly below it: the specific way the suite decays if you skip that habit (a slow suite nobody runs, tests that pass alone but fail together, flaky tests, eyeballed output, untested legacy).
- The payoff is the one-to-one pairing: FIRST is not five vague ideals but five targeted defenses — Repeatable (the middle shield) is the one to guard hardest, because flaky tests train the whole team to ignore red.
In short: each FIRST letter blocks one named failure mode — keep all five shields up and the suite stays trusted.
- Fast — a unit test should finish in milliseconds. Speed isn't vanity; it's what lets you run the suite on every save. The moment tests take minutes, you run them less, and untested changes slip through. (Slow usually means it's talking to a real database or network — that's an integration test, covered in TQ5, not a unit test.)
- Isolated — a test shares no state with any other and doesn't care what order things run in. It must pass alone and alongside the whole suite. The tell-tale bug is "passes on my machine, fails in CI" caused by leftover global state or a shared file.
- Repeatable — same result every run, on any machine, at any time. This is where the clock, random numbers, timezones, and network calls betray you. The cure is to inject those inputs (pass in the hour instead of calling
datetime.now()) or mock them (TQ3), so the test is deterministic. - Self-validating — the test decides pass or fail by itself, with assertions. If a human has to read the output and judge it "looks right," it isn't a test, it's a demo — and nobody re-checks it under deadline.
- Timely — written close to the code, ideally before it (TDD, TQ4). Tests deferred to "after the release" become the untested legacy that everyone is scared to change.
The principle worth internalising above the rest is Repeatable, because its violation is the most corrosive. A flaky test — one that passes and fails on identical code — is genuinely worse than no test: it trains the whole team to ignore red, which is the one signal testing exists to give. If you take one habit from this section, make it "never let a test depend on time, randomness, or the network without controlling it."
| 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
There's a failure mode that only bites once you're the one reviewing others' code. A pull request arrives with tests, the coverage number ticks up, the CI is green — and every one of those tests is worthless. One asserts nothing. Another asserts True. A third sleeps for two seconds "to let things settle." They all pass, so they all give the team a warm feeling of safety that is completely unearned. A missing test is an honest gap you can see; a bad test is a lie that hides in plain sight. Catching those is the tech-lead's job, and it's the theme of this section.
The instinct of a junior reviewer is to check "are there tests?" The instinct you're building here is to check "do these tests actually test anything?" Think of it like a food-safety inspector who doesn't just confirm the kitchen has a fire alarm, but confirms the alarm has a battery in it. A test with no assertion is an alarm with no battery — bolted to the wall, inspected, approved, and utterly silent when the fire comes.
Because these smells are mechanical, you don't have to rely on tired human eyes to catch them — you can automate the check and run it in CI, so the obvious offenders bounce before a human even looks. The runnable lab below is a miniature linter that flags the common test smells: no assertion at all (proves nothing), assert True (always passes, meaningless), time.sleep (slow and flaky — mock time instead, per TQ3), and too many assertions in one test (a sign it's really several tests fused together and will be painful to diagnose when it fails). A production-grade version would parse the code into an AST — an abstract syntax tree, Python's structured representation of the source via the ast module — rather than scan raw text, so it can reliably tell a real self.assertEqual(...) or pytest.raises from the word "assert" appearing in a comment. Exercise 6 in the ladder builds exactly that AST version.
The mistake to guard against is treating a coverage percentage as proof of quality. Coverage tells you which lines ran during the tests, not whether anything was checked — an assertion-less test can execute a whole module and report 100% coverage while proving nothing at all. Make test quality an explicit review criterion, back it with an automated smell-check, and you close the gap between "green" and "actually safe."
assert True, time.sleep, too many asserts) are checked automatically in CI — clean tests pass through, offenders are flagged before a human wastes review time.
Follow the flow left to right: a test comes in, runs the gauntlet of automated checks in the middle, then exits down one of two branches.
- The blue box on the left is any test entering review — the linter only sees its source text.
- The amber middle box is the automated smell-check you wire into CI. It asks the four mechanical questions the lab implements: is there no assertion, an
assert True, atime.sleep, or more than eight asserts? - A clean test follows the upper arrow to the green box and reaches a human reviewer; a test that trips any check follows the red arrow to the red box and is bounced before anyone wastes time on it. The footnote is the trap: a coverage % only proves a line ran, not that anything was asserted.
In short: let a CI linter catch the mechanical smells automatically, so human review is spent on the judgement calls a machine can't make.
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?