AI EngineeringZero to ProductionHome·About·Contact
Software Testing · Project TQ

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.

⏱️ ~3.5 hours🏗️ Capstone project🎯 Intermediate→Tech-lead
What you'll buildTake an untested module to professionally tested: units, edges, exceptions, fixtures, mocking, a test-first bug fix, coverage, and a required CI quality gate. The deliverable is a repo where every change is guarded by a fast, trusted suite.

Learning objectives

  • Add a real suite to untested code.
  • Combine every TQ technique.
  • Fix a bug test-first.
  • Gate the repo on tests + coverage.
▶ Runnable companionThe code here is also saved under code/proj-tq-suite/. Python blocks run offline; config files are ready to drop into a project.

1 · The untested code essential

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · inventory.py (the 'before', runs)
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
▶ How this works

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.

  1. self._stock = {} is an empty dictionary — a lookup table that maps each sku to its quantity. The leading underscore is a convention meaning "internal, please don't poke at it directly".
  2. add first guards against nonsense: if qty is zero or negative it raises a ValueError — 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").
  3. remove guards the other direction: you cannot take out more than you have, so it raises "insufficient stock" when asked to.
  4. level just 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

Python · the suite (runs as plain asserts)
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
▶ How this works

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.

  1. from inventory import Inventory pulls in the class from the file we just read — your tests live beside the code and import it, they don't copy it.
  2. 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.
  3. The three assert lines 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. An assert silently passes when true and blows up when false.
  4. The for bad in (0, -5): loop checks the exception path: adding a bad quantity must raise. The try / except ValueError: pass means "we expected this error, so catching it counts as success"; the assert False right after the call is a trap that fires only if no error was raised.
  5. 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

Python · reproduce then fix (runs)
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
▶ How this works

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.

  1. 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.
  2. assert inv.level("A") == 5 is 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.
  3. You then fix inventory.py and 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

shell · measure it
cov.shpytest --cov=inventory --cov-report=term-missing
# Name           Stmts  Miss  Cover  Missing
# inventory.py      12     0   100%
▶ How this works

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.

  1. pytest --cov=inventory runs the test suite and measures coverage of the inventory module specifically (not the test files themselves).
  2. --cov-report=term-missing prints the summary and the line numbers of any statements no test reached — the "Missing" column tells you exactly where to add a test.
  3. 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

config · required gate on every PR
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
▶ How this works

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".

  1. on: [pull_request] is the trigger: this job runs every time someone opens or updates a PR — nobody has to remember to run it.
  2. The steps: are the checklist the server follows on a fresh Ubuntu machine: check out the code, install Python 3.12, then pip install the test tools (pytest and the coverage plugin pytest-cov).
  3. The last run is the gate: pytest --cov=inventory --cov-fail-under=90 runs the suite and exits with an error if coverage falls below 90%. A failing exit turns the PR check red.
  4. 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.

The finished deliverableA repo where every function is tested, edges + exceptions covered, external calls mockable, coverage enforced, and CI blocks any PR that breaks a test or drops coverage. That repo is safe to change — the entire point of testing, and what a lead delivers.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Read the module, predict behaviourBeginner

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 assert statements (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")
Exercise 2 · Cover behaviour, edges, and exceptionsIntermediate

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 TestCase with a setUp
  • 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)
Exercise 3 · Fix a bug test-first (RED then GREEN)Advanced

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)
Exercise 4 · Isolate collaborators with a mockExpert

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 MagicMock stands 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)
Exercise 5 · Measure coverage and close the gapsProfessional

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.
Exercise 6 · A required CI quality gateIndustry scenario

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.
📋 Staff-level self-scoring — is this suite one the team can trust to change code?
DimensionMeets the barAbove the bar (staff)
CoverageCoverage 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 casesTests 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 qualityFixtures 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 fixA 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 gateA 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

✓ Knowledge check

The project has you fix a bug test-first. What does that mean, and why write the test before the fix?

Show answer
You first write a failing test that reproduces the bug, then change the code until the test passes. Writing it first proves the test actually catches the bug (it fails before the fix) and permanently guards against the bug returning as a regression.
✓ Knowledge check

Why does the capstone gate the repo on a required CI check for tests and coverage, rather than just running tests locally?

Show answer
A required CI quality gate makes the suite non-optional: no change merges unless the tests pass and coverage holds, so the repo stays safe to change for everyone. Local-only runs can be skipped or forgotten, which is how untested code creeps back in.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in