Add a test suite to real code
The capstone: untested code to professionally tested — units, integration, fixtures, mocking, a test-first bug fix, coverage, and a required CI quality gate. A repo safe to change.
Learning objectives
- Add a real suite to untested code.
- Combine every TQ technique.
- Fix a bug test-first.
- Gate the repo on tests + coverage.
code/proj-tq-suite/. Python blocks run offline; config files are ready to drop into a project.1 · The untested code essential
inventory.pyclass Inventory:
def __init__(self): self._stock = {}
def add(self, sku, qty):
if qty <= 0: raise ValueError("qty must be positive")
self._stock[sku] = self._stock.get(sku, 0) + qty
def remove(self, sku, qty):
if self._stock.get(sku, 0) < qty: raise ValueError("insufficient stock")
self._stock[sku] -= qty
def level(self, sku): return self._stock.get(sku, 0)
inv = Inventory(); inv.add("A", 10); inv.remove("A", 3)
print("level:", inv.level("A")) # 7
level: 7
This is the module we are going to test — a tiny warehouse stock tracker. Right now it has zero tests, so nobody knows if it really works or whether a future edit will quietly break it. Read it once so the tests later make sense: it is just a class that remembers how much of each item (a sku, i.e. a product code) you have.
self._stock = {}is an empty dictionary — a lookup table that maps eachskuto its quantity. The leading underscore is a convention meaning "internal, please don't poke at it directly".addfirst guards against nonsense: ifqtyis zero or negative it raises aValueError— Python's way of refusing bad input loudly instead of storing garbage. Otherwise it adds to the current amount (.get(sku, 0)means "the current count, or 0 if we've never seen this sku").removeguards the other direction: you cannot take out more than you have, so it raises"insufficient stock"when asked to.leveljust reports how much of a sku is on hand (0 if unknown). The last three lines are a quick manual check: add 10, remove 3, expect 7.
What the output means: level: 7 — the manual sanity check at the bottom worked. But a single hand-run like this is not a test suite: it only checks one happy path and nothing re-runs it automatically.
Try this: Predict what inv.remove("A", 99) would do before reading on. (It would hit the "insufficient stock" guard and raise.) That guard is exactly the kind of behaviour a real suite must pin down.
2 · Cover behavior, edges, exceptions intermediate
test_inventory.pyfrom inventory import Inventory # in pytest: fixtures + pytest.raises
def fresh(): i = Inventory(); i.add("A", 10); return i
assert fresh().level("A") == 10
i = fresh(); i.remove("A", 3); assert i.level("A") == 7
assert fresh().level("Z") == 0
for bad in (0, -5):
try: fresh().add("A", bad); assert False
except ValueError: pass
try: fresh().remove("A", 999); assert False
except ValueError as e: assert "insufficient" in str(e)
print("full suite passes")
full suite passes
Now we test the module properly. A good suite checks three things: the normal case ("behavior"), the awkward boundaries ("edges"), and the error paths ("exceptions"). This file runs as plain assert statements so you can see the logic; the comment notes how the same ideas become pytest fixtures and pytest.raises in a real project.
from inventory import Inventorypulls in the class from the file we just read — your tests live beside the code and import it, they don't copy it.def fresh():is a tiny helper that builds a known starting state (a new inventory with 10 of "A") for every test. Reusing it keeps each check independent — this is the idea a pytest fixture formalises.- The three
assertlines check behavior + an edge: a fresh level is 10; after removing 3 it's 7; and an unknown sku "Z" reports 0 instead of crashing. Anassertsilently passes when true and blows up when false. - The
for bad in (0, -5):loop checks the exception path: adding a bad quantity must raise. Thetry / except ValueError: passmeans "we expected this error, so catching it counts as success"; theassert Falseright after the call is a trap that fires only if no error was raised. - The final block does the same for over-removing, and also checks the message contains
"insufficient"— so a future rename of the error can't silently slip through.
What the output means: full suite passes prints only if every assert held. If any one failed, Python would stop at that line with an AssertionError and you'd never reach the print — that's the suite catching a regression.
Try this: Break the code on purpose: change level to return self._stock.get(sku, 1) and re-run. The "unknown sku is 0" assert now fails — proof the test is actually guarding that behaviour.
3 · Fix a bug test-first advanced
bugfix.pyinv = Inventory()
inv.add("A", 10); inv.remove("A", 10) # down to 0
inv.add("A", 5) # previously reported broken
assert inv.level("A") == 5 # RED first, then confirm GREEN
print("bug fixed with a permanent regression test")
bug fixed with a permanent regression test
This is the test-first (TDD) bug-fix workflow in miniature. The rule: before you touch the code, first write a test that reproduces the bug and therefore fails ("RED"). Then you fix the code until the same test passes ("GREEN"). The test stays forever as a regression test so the bug can never silently return.
- The setup recreates the exact scenario from the bug report: add 10, remove all 10 so the stock hits 0, then
add("A", 5)— the step a user claimed was broken. assert inv.level("A") == 5is the whole point: it states the correct expected result. Run this first — if the bug is real it fails (RED), which proves your test can actually catch it.- You then fix
inventory.pyand run again; when the assert holds, you're GREEN. Here the current code already behaves correctly, so it passes straight away.
What the output means: bug fixed with a permanent regression test — the assert passed, so the reported behaviour is correct and this test now permanently guards it.
Try this: Temporarily make the bug real: in add, change the fallback to self._stock.get(sku, 100). Re-run — the assert fails (RED). Undo it and watch it go GREEN. That RED-then-GREEN cycle is the heart of TDD.
4 · Professional — coverage professional
cov.shpytest --cov=inventory --cov-report=term-missing
# Name Stmts Miss Cover Missing
# inventory.py 12 0 100%
A passing suite tells you the lines you tested work — it says nothing about the lines you forgot. Coverage answers that: it runs your tests while watching which lines of the module actually execute, then reports what got missed.
pytest --cov=inventoryruns the test suite and measures coverage of theinventorymodule specifically (not the test files themselves).--cov-report=term-missingprints the summary and the line numbers of any statements no test reached — the "Missing" column tells you exactly where to add a test.- The commented lines below the command are the report pytest prints: 12 statements, 0 missed,
100%covered, nothing in "Missing".
What the output means: 100% with an empty Missing column means every line of inventory.py was exercised by the suite. 100% is a nice signal, but remember it proves lines ran, not that every case was checked well.
Try this: Delete one of the exception tests from section 2, then re-run coverage. The guard line inside add now shows up under "Missing" and the percentage drops — coverage just told you what you stopped testing.
5 · Tech-lead — the CI quality gate tech-lead
quality.ymlname: quality
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install pytest pytest-cov
- run: pytest --cov=inventory --cov-fail-under=90 # fails if coverage < 90%
# mark this check REQUIRED on main -> no red PR merges
The final step makes the suite mandatory. This is a GitHub Actions workflow — a recipe a server runs automatically on every pull request (PR). If the tests fail or coverage is too low, the PR is blocked from merging. That turns "we should run tests" into "you cannot merge broken code".
on: [pull_request]is the trigger: this job runs every time someone opens or updates a PR — nobody has to remember to run it.- The
steps:are the checklist the server follows on a fresh Ubuntu machine: check out the code, install Python 3.12, thenpip installthe test tools (pytestand the coverage pluginpytest-cov). - The last run is the gate:
pytest --cov=inventory --cov-fail-under=90runs the suite and exits with an error if coverage falls below 90%. A failing exit turns the PR check red. - The final comment is the human step done once in GitHub settings: mark this check Required on the main branch, so a red result actually blocks the merge button.
What the output means: On a healthy PR the job goes green and merging is allowed. If a test breaks or coverage dips under 90%, the job goes red and — because the check is Required — the merge is blocked until it's fixed.
Try this: Lower the threshold thought-experiment: change --cov-fail-under=90 to 100. Now even one untested line blocks every PR. Teams tune this number to balance safety against friction — that trade-off is the tech-lead's call.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Before changing or testing code you don't fully know, you characterize it: plain asserts that pin the current happy-path behaviour so any later regression is caught.
Your task: Pin the lesson's Inventory happy path with plain asserts — add 10, remove 3, expect level 7 — so a regression would be caught.
Requirements:
- Use plain
assertstatements (runs as a script) - Exercise the add-then-remove happy path
- Assert the resulting level is correct
- No test framework required
- The asserts would fail on a regression
💡 Hint: This is milestone 1 — characterize the 'before' code; a couple of asserts that codify the expected level are enough to catch a future break.
Show solution
Milestone 1: characterize the 'before' code with asserts (runs as a plain script). Runnable:
class Inventory:
def __init__(self): self._stock = {}
def add(self, sku, qty):
if qty <= 0: raise ValueError("qty must be positive")
self._stock[sku] = self._stock.get(sku, 0) + qty
def remove(self, sku, qty):
if self._stock.get(sku, 0) < qty: raise ValueError("insufficient stock")
self._stock[sku] -= qty
def level(self, sku): return self._stock.get(sku, 0)
inv = Inventory(); inv.add("A", 10); inv.remove("A", 3)
assert inv.level("A") == 7
print("happy path pinned")
Context: A real suite covers behaviour, edges, and exceptions. A proper unittest.TestCase pins the normal path, the unknown-key edge, and both input guards, run by the stdlib runner.
Your task: Write a unittest.TestCase covering a normal add/remove, the unknown-sku edge (level 0), and both guards (add rejecting qty≤0, remove rejecting over-withdrawal).
Requirements:
- A
TestCasewith asetUp - A normal add/remove test
- The unknown-sku edge returns level 0
- add rejects a non-positive quantity
- remove rejects withdrawing more than the stock
- Runs with the stdlib unittest runner
💡 Hint: Inline the module so it runs as-is; use assertRaises for the two guards and setUp to stock a starting item.
Show solution
Self-contained stdlib unittest — the module is inlined so it runs as-is:
import unittest
class Inventory:
def __init__(self): self._stock = {}
def add(self, sku, qty):
if qty <= 0: raise ValueError("qty must be positive")
self._stock[sku] = self._stock.get(sku, 0) + qty
def remove(self, sku, qty):
if self._stock.get(sku, 0) < qty: raise ValueError("insufficient stock")
self._stock[sku] -= qty
def level(self, sku): return self._stock.get(sku, 0)
class TestInventory(unittest.TestCase):
def setUp(self):
self.inv = Inventory(); self.inv.add("A", 10)
def test_add_remove(self):
self.inv.remove("A", 3)
self.assertEqual(self.inv.level("A"), 7)
def test_unknown_sku_is_zero(self):
self.assertEqual(self.inv.level("ZZ"), 0)
def test_add_rejects_nonpositive(self):
with self.assertRaises(ValueError):
self.inv.add("A", 0)
def test_remove_rejects_overdraw(self):
with self.assertRaises(ValueError):
self.inv.remove("A", 99)
if __name__ == "__main__":
unittest.main(verbosity=2)
Context: The TDD bug-fix loop is RED then GREEN: write a regression test that reproduces the reported bug (it would fail on the broken build), then confirm it passes on the real code, leaving the test as a permanent guard.
Your task: Reproduce a reported bug (add 10, remove 10, add 5, expect 5) as a regression test that would go RED against a broken build and GREEN against the real code.
Requirements:
- A test encodes the exact reported sequence
- It expects the correct final level (5)
- It would fail against the broken behaviour (RED)
- It passes against the real, correct code (GREEN)
- It stays as a permanent regression guard
💡 Hint: Write the failing case first from the bug report; here the real code is already correct so it goes straight to GREEN, but the test remains to prevent a recurrence.
Show solution
The TDD bug-fix loop from section 3. Runnable — here the real code is already correct so it goes straight to GREEN, and the test stays as a permanent guard:
import unittest
class Inventory:
def __init__(self): self._stock = {}
def add(self, sku, qty):
if qty <= 0: raise ValueError("qty must be positive")
self._stock[sku] = self._stock.get(sku, 0) + qty
def remove(self, sku, qty):
if self._stock.get(sku, 0) < qty: raise ValueError("insufficient stock")
self._stock[sku] -= qty
def level(self, sku): return self._stock.get(sku, 0)
class TestRefillAfterZero(unittest.TestCase):
def test_add_back_after_zero(self):
inv = Inventory()
inv.add("A", 10); inv.remove("A", 10) # down to 0
inv.add("A", 5) # the reported step
self.assertEqual(inv.level("A"), 5) # RED if broken, else GREEN
if __name__ == "__main__":
unittest.main(verbosity=2)
Context: When code emits a side effect through a collaborator (an audit sink), you isolate the collaborator with a mock so the test asserts the interaction without a real external system.
Your task: Test that Inventory's audit callback is called with the right args on each remove, using unittest.mock.MagicMock instead of a real audit system.
Requirements:
- Inventory takes an audit callback and calls it on remove
- A
MagicMockstands in for the audit sink - The test asserts the callback's call arguments
- No real audit system is involved
- Uses
assert_called_once_with(or equivalent)
💡 Hint: Inject the audit sink as a callback so a MagicMock can replace it; assert it was called once with the expected action, sku, and quantity.
Show solution
TQ3's fixtures + mocking, on stdlib. The mock stands in for the external audit sink:
import unittest
from unittest.mock import MagicMock
class Inventory:
def __init__(self, audit):
self._stock = {}; self._audit = audit
def add(self, sku, qty):
if qty <= 0: raise ValueError("qty must be positive")
self._stock[sku] = self._stock.get(sku, 0) + qty
def remove(self, sku, qty):
if self._stock.get(sku, 0) < qty: raise ValueError("insufficient stock")
self._stock[sku] -= qty
self._audit("remove", sku, qty) # side effect to isolate
def level(self, sku): return self._stock.get(sku, 0)
class TestAudit(unittest.TestCase):
def test_remove_audits(self):
audit = MagicMock()
inv = Inventory(audit); inv.add("A", 10)
inv.remove("A", 4)
audit.assert_called_once_with("remove", "A", 4)
if __name__ == "__main__":
unittest.main(verbosity=2)
Context: Coverage tells you which lines ran. Driving it to 100% with a missing-line report surfaces untested branches — but 100% means every line ran, not that every case is checked well.
Your task: Run coverage on the module and drive it to 100% using the missing-line report to find untested branches; write the command and interpret a sample report.
Requirements:
- The coverage command targets only the module under test
- A term-missing report names the uncovered lines
- The uncovered lines are the guard branches
- Tests are added to hit those lines, reaching 100%
- Note that 100% line coverage is not proof of correctness
💡 Hint: This rung is a worked artifact (shell + report), not runnable Python; the --cov-report=term-missing output points you straight at the untested guard lines.
Show solution
Worked artifact (shell + report) — coverage is a tool invocation, not runnable Python here. From section 4:
# run the suite while measuring the inventory module only
pytest --cov=inventory --cov-report=term-missing
# sample report BEFORE closing gaps:
# Name Stmts Miss Cover Missing
# inventory.py 12 2 83% 6, 9 <- the two guard lines
# add tests that hit the raise on line 6 (add qty<=0) and line 9
# (remove over-draw), then re-run:
# inventory.py 12 0 100%
#
# 100% means every line RAN -- not that every case is checked well;
# keep the edge/exception tests from milestone 2 as the real proof.
Context: The tech-lead move is making the suite a required gate: on every pull request, tests must pass and coverage must not drop below a floor, or the PR is blocked.
Your task: Make the suite a required CI gate on every pull request — tests pass and coverage stays above a floor — by writing the GitHub Actions workflow.
Requirements:
- A workflow triggered on pull requests
- Checks out, sets up Python, installs pytest + coverage
- Runs the suite with a coverage floor that fails the job if breached
- The failing job blocks the PR
- Note the branch-protection step that marks the check required
💡 Hint: This rung is a GitHub Actions YAML artifact; --cov-fail-under makes the job fail below the floor, and branch protection makes that check required to merge.
Show solution
Worked artifact (GitHub Actions YAML) — the section-5 tech-lead gate. It runs in CI, not here:
name: quality
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install pytest pytest-cov
# --cov-fail-under makes the job FAIL (and block the PR) if
# coverage drops below the floor, so tests are truly required
- run: pytest --cov=inventory --cov-report=term-missing --cov-fail-under=100
# Then in branch protection, mark the "gate" check as required:
# no merge unless tests pass AND coverage stays at 100%.
✓ Checkpoint — you can move on when you can…
- Add a real suite to untested code.
- Combine units, fixtures, mocking, coverage.
- Fix a bug test-first.
- Enforce a required CI quality gate.
| Dimension | Meets the bar | Above the bar (staff) |
|---|---|---|
| Coverage | Coverage is measured on the module under test and is high (e.g. ≥ 90%); the Missing column is understood. | Coverage is enforced in CI (--cov-fail-under), and you can name which uncovered lines are deliberately untested and why — coverage is used as a floor, not a vanity number. |
| Edge & failure cases | Tests cover behavior, boundary/edge inputs, and the exception paths — not just the happy path. | Error paths assert on the specific exception/message, boundary classes are enumerated deliberately, and the suite would catch a regression that only breaks an edge case. |
| Fixture & mocking quality | Fixtures set up state cleanly and external calls are mocked so tests are fast and offline. | Mocks assert on interactions (not just return values), the boundary between real and mocked code is deliberate, and tests are isolated (no shared mutable state, order-independent). |
| Test-first bug fix | A bug was reproduced with a failing test before the fix, then the test went green. | The regression test is kept, named for the bug, and clearly documents the defect so it can never silently return. |
| CI quality gate | A CI workflow runs the suite on every PR and blocks merge on failure or low coverage. | The check is marked Required on main, the threshold is tuned to a defended balance of safety vs friction, and a red gate has actually stopped a bad merge. |
Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–4: a few tests. 5–7: a real suite. 8–10: staff-level — edges and failures covered, mocks that assert, a regression test for the bug, and a required CI gate. A 0 on Edge & failure cases or CI quality gate means the suite gives false confidence — fix first.
Knowledge check check yourself
The project has you fix a bug test-first. What does that mean, and why write the test before the fix?
Show answer
Why does the capstone gate the repo on a required CI check for tests and coverage, rather than just running tests locally?