AI EngineeringZero to ProductionHome·About·What’s new·Contact
Software Testing · Chapter TQ5

Integration tests, coverage & CI

From units to the whole picture: integration tests, honest coverage, CI gates, quality gates, and leading a testing culture — where Git, Testing, and Deployment converge.

⏱️ ~2.5 hours🧪 5 labs🎯 Advanced→Tech-lead

Learning objectives

  • Write integration tests across components.
  • Measure coverage honestly.
  • Run the suite in CI as a required gate.
  • Own quality gates and a testing culture for a team.
▶ Runnable companionThe code here is also saved under code/tq5-integration-ci/. Python blocks run offline; config files are ready to drop into a project.

1 · Integration tests essential

Here's a bug that has embarrassed more engineers than any other. Every unit test is green. You have 400 of them, all passing, all fast. You deploy on Friday. By Saturday, orders are silently saving with the total set to NULL, because your code writes a column called amount but the real database column is spelled total. Not one of your 400 unit tests caught it — because every one of them faked the database out. The pieces were individually correct. The seams between them were where the truth lived, and nobody tested a seam.

That is exactly the gap integration tests fill. Think of it like a car: unit tests check that the engine spins, the wheels turn, and the fuel pump pumps — each part on a bench, in isolation. An integration test bolts the parts together and actually drives around the block. Most catastrophic failures aren't a broken part; they're two good parts that disagree about the interface between them — a mismatched column name, a JSON field the other service never reads, a timezone one layer assumes and the other doesn't.

So the precise definition: an integration test exercises two or more real components together — your code plus the database, your service plus the queue, your API plus its client — and asserts they cooperate correctly. The word "real" is load-bearing. The moment you replace the database with a mock, you're back to a unit test, and you've stopped testing the very thing (the SQL, the schema, the driver's type coercion) that broke on Saturday.

The catch integration tests always run into is speed and setup. A real Postgres server means spinning up a container, seeding a schema, and tearing it down — slow enough that people stop running the suite, which defeats the purpose. The trick you'll use throughout this chapter sidesteps that: an in-memory SQLite database (sqlite3.connect(":memory:")). It is genuine SQL — real tables, real constraints, real aggregates — but it lives in RAM and disappears when the connection closes. You get authentic integration behavior at unit-test speed, with no external server to install and no cleanup to forget.

The lab below is the smallest possible real integration: an OrderStore class and an actual SQLite database, writing a row and reading it back. Watch that no mock appears anywhere — that absence is the whole point.

the bug lives at the seam between two good parts — only the real DB reveals it Unit test your code mock DB faked out seam never exercised → mismatch hidden Integration test your code real DB :memory: real SQL column amount vs total→ mismatch caught integration = two real components asserting they cooperate Integration tests test the seam, not the parts. A unit test faking the database can never see that your code writes amount while the schema spells it total; swapping in a real (in-memory) DB drives the actual SQL and catches the mismatch.
🗺️ How to read this diagram

Compare the two halves left and right: the same "your code" box appears in both, but what it connects to differs.

  • On the left (unit test), your code talks to an amber "mock DB" — the database is faked out. The blue arrow between them is the only thing tested, and it never touches real SQL, so a column-name disagreement stays invisible.
  • On the right (integration test), your code talks to a green "real DB" (:memory:) over genuine SQL. Because the real schema is involved, the red arrow drops to the red box — the amount-vs-total mismatch is caught.
  • The green bar across the bottom is the payoff definition: an integration test is two real components asserting they cooperate — the moment you mock one of them, you are back to a unit test.

In short: mock the database and you test one part; use a real database and you test the seam where the real bugs hide.

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 · service + real in-memory DB (runs)
integration.pyimport sqlite3
class OrderStore:
    def __init__(self, conn):
        self.conn = conn
        conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    def create(self, total):
        c = self.conn.execute("INSERT INTO orders(total) VALUES (?)", (total,))
        self.conn.commit(); return c.lastrowid
    def get(self, oid):
        return self.conn.execute("SELECT total FROM orders WHERE id=?", (oid,)).fetchone()

store = OrderStore(sqlite3.connect(":memory:"))     # integration: store + DB
oid = store.create(99.0)
assert store.get(oid)[0] == 99.0
print("integration test passed (store + db together)")
integration test passed (store + db together)
▶ How this works

A unit test checks one small piece on its own (often with the database faked out). An integration test checks that two real pieces work together — here, your OrderStore code plus a real SQLite database. If the code and the database disagree about how data is stored, only an integration test can catch it. The trick that keeps this fast is :memory:: a throwaway database that lives in RAM and vanishes when the program ends.

  1. class OrderStore wraps a database connection. Its __init__ immediately runs CREATE TABLE orders, so a fresh store always has a real table to write into.
  2. create(total) runs a real SQL INSERT, calls commit() to save it, and returns lastrowid — the id the database assigned to the new row.
  3. get(oid) runs a SELECT and fetchone() pulls back that one row as a tuple, e.g. (99.0,).
  4. store = OrderStore(sqlite3.connect(":memory:")) is the integration part: a real store talking to a real (in-memory) database. We then create an order, read it back, and assert store.get(oid)[0] == 99.0 — [0] grabs the first column (the total).

What the output means: assert raises an error and stops the program if the value is wrong. Because it matched, we reach the last line and print integration test passed (store + db together).

Try this: Change the assert to == 88.0 and re-run — you'll get an AssertionError, which is the test correctly telling you the stored value doesn't match what you expected.

2 · Coverage intermediate

You've written tests. But which lines of your code did they actually touch? Almost every engineer, at some point, discovers the answer the hard way: a bug ships in a function they were sure was tested — and when they look, the failing branch (the error path, the edge case, the else nobody wrote a test for) was never once executed by the suite. The tests were real; they just never walked down that particular hallway. Coverage is the flashlight that shows you which hallways are still dark.

Concretely: coverage is the percentage of your code that your test suite executes at least once. A coverage tool runs your tests while quietly ticking off every line (and, more usefully, every branch) as it runs. At the end it reports the fraction reached and — the part that actually matters — names the lines that were never reached. That list of untouched lines is a to-do list: "here is code you shipped that no test has ever exercised."

Now the trap, and it is the single most misunderstood idea in testing: coverage measures execution, not correctness. A line being covered means only that a test caused it to run — not that any test asserted the right thing about it. You can achieve 100% coverage with tests that assert nothing at all, or assert the wrong value. Coverage is a good gap-finder (it tells you what you definitely haven't tested) and a terrible quality certificate (it can't tell you whether what you tested is right). Think of it like checking that a security guard walked past every door — reassuring, but it doesn't prove any door was actually locked.

The healthy way to use it, and what real teams do: chase meaningful coverage of the code that matters — the core business logic, the money-touching paths, the error handling — aiming for something like 80–90% there, rather than a vanity 100% that pushes people to write pointless tests just to color a getter green. In practice you run pytest --cov (from the pytest-cov plugin) and read the term-missing report to see exactly which lines slipped through. The lab below fakes that whole mechanism in a few lines so you can watch it work: it records which branches ran, then subtracts that from every branch that could have run — and the difference is the gap the flashlight finds.

coverage = branches that ran ÷ branches that could run could run (2) discount (happy path) discount:guard (error) what a test ran ✓ covered ✗ missing ran never ran 1 of 2 branches = 50% — guard is the gap Coverage is a set difference, not a grade. Of the two branches in discount, only the happy path ran, so the error guard shows up as the missing branch — 1 of 2 = 50%. The number names the gap; it says nothing about whether the covered line was asserted correctly.
🗺️ How to read this diagram

Read it left to right: the left column is every branch that could run; the right column is the verdict a test run assigns to each.

  • The two left boxes are the two branches of discount: the happy path and the error guard. Together they are the denominator — everything coverage could reach.
  • The solid blue "ran" arrow maps the happy path to the green "covered" box; the dashed red "never ran" arrow maps the guard to the red "missing" box, because the single call never triggered the error case.
  • The amber bar at the bottom is the resulting number: 1 of 2 branches = 50%, and it points at which branch is the gap — the guard.

In short: coverage subtracts what ran from what could run; the leftover branches are your to-do list, not a certificate of correctness.

Python · what coverage measures (runs)
coverage.pyexecuted = set()
def tracked(fn_name): executed.add(fn_name)

def discount(price, pct):
    tracked("discount")
    if pct > 100:
        tracked("discount:guard"); raise ValueError
    return price*(1-pct/100)

discount(100, 10)                    # tests exercise the happy path only
all_branches = {"discount", "discount:guard"}
covered = executed & all_branches
print(f"coverage: {len(covered)}/{len(all_branches)} branches = {100*len(covered)//len(all_branches)}%")
print("missing:", all_branches - executed)   # the guard is untested!
coverage: 1/2 branches = 50%
missing: {'discount:guard'}
▶ How this works

Coverage answers one question: which lines of your code did the tests actually run? It is a gap-finder — it shows code no test ever touched. This tiny program fakes a coverage tool by recording the name of each branch as it executes, then comparing what ran against everything that could run.

  1. executed = set() is our record of which parts ran. tracked(fn_name) adds a label to that set — think of it as a checkmark saying "this line was reached".
  2. Inside discount, the normal path calls tracked("discount"). The error path (when pct > 100) calls tracked("discount:guard") — but only if we ever hit that case.
  3. We call discount(100, 10) once. Since 10 > 100 is false, the guard branch never runs, so "discount:guard" is never recorded.
  4. covered = executed & all_branches uses set intersection (&) to find which of the two possible branches actually ran, then prints a percentage.

What the output means: coverage: 1/2 branches = 50% — one of two branches ran. missing: {'discount:guard'} names the untested branch: the error guard. That is the whole point — coverage flags the risky line no test exercised.

Try this: Add a second call discount(100, 150) above the report (wrap it in try/except ValueError so it doesn't crash). Now the guard runs and coverage jumps to 2/2 = 100%.

100% coverage != correctCoverage says a line ran, not that you asserted the right thing. Aim for meaningful coverage of important logic (~80-90% on core), not a vanity 100%.

3 · Advanced — CI gate advanced

A test suite that nobody runs is worth exactly nothing. You know this because you've lived it: the team has great tests, but running them takes a manual command, so people skip it when they're in a hurry — which is always. Someone pushes a change on a deadline, forgets to run the suite, and the broken code lands on main. Monday morning, the whole team is blocked because the shared branch is red and nobody's sure who broke it. The tests existed. The discipline to run them didn't. Humans are unreliable about repetitive chores; that's not a character flaw, it's just biology.

Continuous Integration (CI) removes the human from that loop entirely. The idea: a fresh, clean machine in the cloud automatically checks out your code and runs the tests every single time anyone pushes or opens a pull request. No one can forget, because no one is asked to remember. Think of it as a tireless robot gatekeeper standing at the door to main — every change has to walk past it, and it runs the exact same checks the exact same way, on every commit, forever.

The tool you'll see here is GitHub Actions, and the thing that trips people up first is that its config is not code — it's a YAML file, a plain-text format where indentation defines structure. A line indented under another line "belongs to" it, the way bullet points nest. That's why YAML is famously whitespace-sensitive: a stray space can change which block a step lives in. The file lives at a fixed, magic path — .github/workflows/ — and GitHub watches that folder; drop a workflow file there and it activates automatically.

The mental model for the file has three layers, top to bottom: on (the trigger — when should this run, e.g. on push and pull_request), jobs (what work to do, each on a fresh virtual machine), and steps (the ordered commands inside a job — check out the code, install Python, install dependencies, run pytest). The part that turns this from "runs my tests" into a gate is quietly powerful and worth internalizing: every shell step must exit with status 0 (success) or the job fails. pytest exits non-zero the moment any test fails — so a red test automatically fails the step, which fails the job, which turns the pull request's check red and blocks the merge. You don't write any "if tests fail, block" logic; that behavior falls out of Unix exit codes for free. That is the whole trick behind a required CI gate.

on → jobs → steps; a non-zero exit anywhere fails the gate on push / PR job · runs-on ubuntu-latest (fresh VM) checkout ·setup-python pip install pytest exit code decides exit 0 merge ✓ exit ≠ 0 merge blocked pass fail no "if failed, block" code — pytest's exit status is the gate The exit code is the gate. A push or PR triggers a job on a fresh VM that runs the steps in order; when pytest finishes, exit 0 lets the merge through and any non-zero exit fails the job and turns the PR check red — no extra blocking logic required.
🗺️ How to read this diagram

Follow the flow left to right, mirroring the YAML's three layers: on → jobs → steps.

  • The blue "on" box on the far left is the trigger — a push or pull_request. Its arrow starts the job, the large white box: a fresh Ubuntu VM.
  • Inside the job, the small boxes are the ordered steps: checkout + setup-python, then pip install, feeding the amber pytest step whose exit code decides the outcome.
  • The payoff is on the right: a green "exit 0" routes to merge ✓; a red "exit ≠ 0" routes to merge blocked. That branch is the gate — and you wrote no logic for it; it falls out of Unix exit codes.

In short: the trigger fires the job, the job runs the steps, and pytest's exit status alone opens or shuts the merge.

config · tests run on every push
tests.ymlname: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt pytest pytest-cov
      - run: pytest --cov=src --cov-report=term-missing
      # a failing test fails the job -> the PR check goes red
▶ How this works

This is not Python — it's a configuration file (YAML) that tells GitHub Actions what to do automatically. CI (Continuous Integration) means: every time someone pushes code or opens a pull request, a fresh machine in the cloud checks out the code and runs your tests for you, so nobody can forget. YAML uses indentation to show nesting — the deeper a line is indented, the more it "belongs to" the line above it.

  1. on: [push, pull_request] is the trigger: run this whenever code is pushed or a pull request is opened/updated.
  2. jobs: lists the work to do. Here there's one job called test, and runs-on: ubuntu-latest says "do it on a fresh Ubuntu Linux machine".
  3. steps: are run top to bottom. The two uses: lines pull in ready-made actions — checkout downloads your code onto the machine, setup-python installs Python 3.12.
  4. The two run: lines are shell commands: first pip install the dependencies plus pytest, then pytest --cov=src to run the tests and measure coverage.

What the output means: There's no console output to read here — this file just defines the pipeline. As the last comment says, if any test fails, pytest exits with an error, which fails the job, which turns the PR's check red — blocking a broken merge.

Try this: Save this as .github/workflows/tests.yml in a real repo and push. Open the "Actions" tab on GitHub to watch it run your tests automatically — green means all passed.

4 · Professional — quality gates professional

Picture the argument that happens on every team without a written bar. A pull request comes in: tests pass, but coverage on the new code is thin and there are a couple of fresh lint warnings. One reviewer says ship it, we're behind. Another says no, that's below our standard. The truth is neither of them knows the standard, because it only exists as a vague feeling that shifts with mood, deadline pressure, and who's asking. The same PR gets waved through on Friday and blocked on Monday. That inconsistency is corrosive — it makes "quality" feel arbitrary and political instead of objective.

A quality gate ends that argument by writing the bar down as code. It's an automated pass/fail check that a pull request must clear before it can merge, combining several conditions with a logical AND: tests must be green, and coverage must sit at or above an agreed floor, and there must be no new critical lint errors. If every condition holds, the gate opens; if any fails, it stays shut and reports exactly which conditions blocked it. The analogy is airport security: it's not one guard's judgment call about whether you look trustworthy — it's a fixed checklist (boarding pass, ID, nothing flagged in the scanner) applied identically to everyone, every time.

Why bother encoding it rather than trusting good reviewers? Two reasons. First, consistency: a function that checks the numbers gives the same verdict at 2pm and 2am, for the senior and the intern, this week and next quarter — it can't be tired, rushed, or lobbied. Second, specificity: a good gate doesn't just say "no," it hands back the list of reasons ("coverage 71% below the 80% floor; 3 new lint errors"), so the author knows precisely what to fix instead of guessing what the reviewer disliked. That frees human reviewers to spend their attention on the things machines can't judge — is this the right design, is the code readable, does it solve the actual problem — while the mechanical bar enforces itself.

One nuance worth stating plainly, because it's a frequent mistake: the coverage condition should be a floor, not a demand for 100%. A floor (say 80% on core modules) blocks regressions — you can't quietly make things worse — without pushing people to write meaningless tests that chase the last few percent on trivial code. The lab below models exactly this gate as a small pure function: it accumulates a list of blocking reasons, and the PR is mergeable only when that list is empty. That same logic is what you wire into the CI YAML from §3 so the gate enforces itself on every push.

every condition must hold (logical AND) — else the gate stays shut tests pass? coverage ≥ floor? 0 new lint errors? AND gate all pass → merge ✓ reasons = [ ] any fail → blocked returns the exact failing reasons coverage is a FLOOR, not a demand for 100% A quality gate ANDs the checks and reports why. Tests-pass, coverage-at-or-above-floor, and zero-new-lint-errors all have to hold; if any fails the gate stays shut and hands back the specific reasons — an objective verdict instead of a reviewer's mood.
🗺️ How to read this diagram

Read left to right: the three conditions on the left all feed the single AND gate in the middle, which then routes to one of two outcomes on the right.

  • The three white boxes are the conditions the gate checks: tests pass, coverage at or above the floor, and no new lint errors. Their arrows all converge on the purple AND gate — every one must hold.
  • The green outcome ("all pass → merge") is reached only when the reasons list is empty; the red outcome ("any fail → blocked") returns the specific failing reasons so the author knows exactly what to fix.
  • The note along the bottom is the nuance: the coverage condition is a floor, not a demand for 100% — it blocks regressions without rewarding meaningless tests.

In short: all conditions AND together into one pass/fail, and a failure comes with the reasons attached — that is what makes the bar objective.

Python · a quality gate check (runs)
quality_gate.pydef quality_gate(results):
    failures = []
    if not results["tests_pass"]:             failures.append("tests failing")
    if results["coverage"] < results["min_coverage"]: failures.append(
        f"coverage {results['coverage']}% < {results['min_coverage']}%")
    if results["new_lint_errors"] > 0:        failures.append(
        f"{results['new_lint_errors']} new lint errors")
    return (not failures), failures

ok, why = quality_gate({"tests_pass": True, "coverage": 82, "min_coverage": 80, "new_lint_errors": 0})
print("PR mergeable:", ok)
ok2, why2 = quality_gate({"tests_pass": True, "coverage": 71, "min_coverage": 80, "new_lint_errors": 3})
print("PR mergeable:", ok2, "|", why2)
PR mergeable: True
PR mergeable: False | ['coverage 71% < 80%', '3 new lint errors']
▶ How this works

A quality gate turns "is this PR good enough to merge?" into an objective yes/no, instead of a reviewer's gut feeling. This function takes a summary of a PR's results and returns whether it passes plus a list of reasons it failed — so the feedback is specific.

  1. failures = [] starts an empty list. Each rule that is violated appends a short reason string to it; a clean PR leaves the list empty.
  2. Rule 1: if not results["tests_pass"] — if the tests didn't pass, record "tests failing".
  3. Rule 2: if coverage is below min_coverage (the floor), record a message showing both numbers. Rule 3: if there are any new lint errors, record that count.
  4. return (not failures), failures returns two things: not failures is True only when the list is empty (an empty list is "falsy"), and the list itself explains any failure.

What the output means: First call has good numbers, so PR mergeable: True. The second has coverage 71 (below the 80 floor) and 3 lint errors, so it prints False followed by the exact reasons: ['coverage 71% < 80%', '3 new lint errors'].

Try this: Lower min_coverage to 70 in the second call and re-run — the coverage complaint disappears, leaving only the lint failure. This is how a real gate is tuned per project.

5 · Tech-lead — build a testing culture tech-lead

Every engineer has watched a beautiful test suite rot. A team buys the best tools, wires up CI, hits 90% coverage — and eighteen months later the suite takes twenty minutes to run so people skip it, three tests fail "randomly" so everyone ignores red, and new features ship with no tests because "we'll add them later." Nothing was technically broken. The culture decayed, one small tolerated compromise at a time. This is the humbling lesson of leading quality: tools are necessary and completely insufficient. A gate you can bypass, a red build you learn to ignore, a slow suite you route around — the technology is fine; the discipline around it eroded.

So the tech-lead's real job isn't installing better tools — it's making the healthy path the easy path, so doing the right thing is also the path of least resistance. When the tested route is faster and smoother than the untested one, people take it without being nagged. That's the whole game, and it comes down to a handful of load-bearing habits, each defending against a specific failure mode:

Notice that this section is where the whole stack of skills converges on one artifact: the pull request. Git collaboration (DF3) delivers the change, the testing chapters (TQ1–TQ4) fill it with meaningful tests, CI and deployment (CD) gate and ship it. A codebase where every PR clears a fast, trusted, meaningful suite is one a team can keep changing quickly and safely for years — and building and defending that culture, long after the tools are installed, is the defining responsibility of a technical lead. The table and exercises below turn that culture into something measurable: signals you can score, so "how healthy is our testing?" becomes a grade and a concrete next investment rather than a vague "we should test more."

every skill converges on one artifact — the pull request — guarded by five habits Git collab (DF3) tests (TQ1–TQ4) CI & deploy (CD) pull request the one artifact required green CI → no red on main fast suite → people run it fix flaky now → red stays real tests per feature → coverage grows floor on core → risk-weighted guarded by habits hold → culture survives for years Culture is what keeps the gate honest. Git, tests, and CI all converge on the pull request; the five tech-lead habits (right) each defend against one specific way a suite rots — and only when those habits hold does the safety net survive for years, not months.
🗺️ How to read this diagram

Read left to right: three skill areas flow into one artifact in the middle, and the five habits on the right are the forces that keep that artifact trustworthy over time.

  • The three blue boxes on the left are the skills that feed a change: Git collaboration delivers it, the testing chapters fill it with tests, CI and deployment gate and ship it. Their arrows all converge on the purple pull request — the single artifact where everything meets.
  • The five white boxes on the right are the tech-lead habits, each paired with the failure mode it defends against (required CI → no red on main, fast suite → people actually run it, and so on). They are what guard the pull request from decay.
  • The green bar at the bottom is the payoff: when those habits hold, the culture — not any single tool — is what lets a team keep changing the codebase safely for years.

In short: tools get the PR to the gate; the five habits are what keep the gate meaningful long after the tools are installed.

PracticeWhy it holds the line
required green CI to mergeno broken code reaches main
fast suite (mock slow deps)people actually run it
fix/delete flaky tests nowpreserves trust in red = real
tests with every feature/fixcoverage grows with the code
coverage floor on core modulesrisk-weighted, not vanity
This is the professional endgameA codebase where every PR is gated on a fast, trusted, meaningful test suite is one a team can change quickly and safely for years. Building and defending that culture is a defining tech-lead responsibility.

Exercise TQ5.1 — Gate a repo

Context: The capstone wires everything together: a genuine integration test, coverage in CI, a composed quality gate, and proof — via a deliberately failing PR — that the gate actually blocks bad merges.

Your task: Add an integration test backed by a real in-memory DB, wire pytest --cov into CI, encode a quality_gate (tests green plus a coverage floor), make the check required, and open a PR that violates the gate to confirm it is blocked.

Requirements:

  • An integration test runs against a real in-memory sqlite3 database
  • CI runs pytest --cov with an enforced coverage floor
  • A quality_gate combines the test result and the coverage floor into one pass/fail
  • The CI check is marked required so it blocks merges
  • A deliberately gate-violating PR is opened and shown to be blocked
  • The green suite plus the gate — not reviewer goodwill — enforce the bar

💡 Hint: Prove the gate by breaking it on purpose: open a PR whose coverage dips below the floor (or whose tests fail) and confirm the required check turns the merge red.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Integration test with a real in-memory DBBeginner

Context: Integration tests exercise components together rather than in isolation. Using a real in-memory sqlite3 database gives genuine SQL behavior — constraints, aggregates — at unit-test speed, with no mocks.

Your task: Test an OrderStore against a real in-memory sqlite3 database, inserting rows and asserting an aggregate over real SQL.

Requirements:

  • Back the store with sqlite3.connect(":memory:"), not a mock
  • The store creates its table and exposes add and total operations
  • total() runs a real SUM query against the DB
  • setUp gives each test a fresh in-memory database
  • Assert the aggregate after inserting a couple of rows
  • Runs offline under unittest

💡 Hint: An in-memory DB is real SQL that vanishes when the connection closes — you get authentic integration behavior without any external server or fixture files.

Show solution

Use a real DB in memory so the integration is genuine but fast:

import unittest, sqlite3

class OrderStore:
    def __init__(self, conn):
        self.conn = conn
        conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amt REAL)")
    def add(self, amt): self.conn.execute("INSERT INTO orders (amt) VALUES (?)", (amt,))
    def total(self):
        return self.conn.execute("SELECT COALESCE(SUM(amt),0) FROM orders").fetchone()[0]

class TestOrderStore(unittest.TestCase):
    def setUp(self):
        self.store = OrderStore(sqlite3.connect(":memory:"))   # real DB, fresh
    def test_total(self):
        self.store.add(10); self.store.add(5)
        self.assertEqual(self.store.total(), 15)

if __name__ == "__main__":
    unittest.main(verbosity=2)     # exercises store + real SQL together

The in-memory DB gives real SQL behavior (constraints, aggregates) with unit-test speed — the sweet spot for integration tests.

Exercise 2 · Measure coverage honestlyIntermediate

Context: Coverage is a map of what your tests do not touch. Modeling it by hand — given which lines ran — demystifies the number and shows that high coverage still does not prove correctness.

Your task: Model line coverage offline: given a total line count and the executed lines, compute the coverage percentage and list the uncovered lines.

Requirements:

  • coverage(total_lines, executed_lines) returns a percentage and the missing lines
  • Compute covered lines as the intersection of executed lines with the valid range
  • Return the percentage rounded to one decimal place
  • Return the sorted list of lines that were never executed
  • Demonstrate it on a small example and print the gap
  • Note that 100% coverage still does not prove correctness

💡 Hint: Work with sets over the line range: covered is the intersection, missing is the difference — the gap is a to-do list, not a verdict.

Show solution

Coverage is a map of what's untested — computed here without the tool:

def coverage(total_lines, executed_lines):
    covered = len(set(executed_lines) & set(range(1, total_lines + 1)))
    pct = 100.0 * covered / total_lines
    missing = sorted(set(range(1, total_lines + 1)) - set(executed_lines))
    return round(pct, 1), missing

pct, missing = coverage(total_lines=10, executed_lines=[1,2,3,4,5,6,7,8])
print(f"{pct}% covered; missing lines: {missing}")
# 80.0% covered; missing lines: [9, 10]

The 20% gap is a to-do list, not a failure — coverage tells you where tests are missing, but 100% coverage still doesn't prove correctness.

Exercise 3 · The CI gate (worked YAML)Advanced

Context: A test suite that no one runs is worthless. Wiring the suite into CI as a required gate makes a red test block the merge automatically — tests become a gate, not a suggestion.

Your task: Write a GitHub Actions workflow (worked YAML) that installs dependencies and runs the test suite on every push and pull request, failing the build on red. (Needs a CI runner.)

Requirements:

  • Trigger on both push and pull_request
  • Check out the repo and set up a pinned Python version
  • Install the project and pytest
  • Run pytest as the test step
  • A non-zero pytest exit fails the job and blocks the merge
  • The YAML lives at .github/workflows/

💡 Hint: The gate is automatic: pytest exits non-zero on any failure, and a non-zero step fails the whole job — no extra wiring needed to block a red merge.

Show solution

Worked YAML — the minimal test gate:

# .github/workflows/ci.yml   (needs GitHub Actions)
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e . pytest
      - name: Test
        run: pytest -q          # non-zero exit fails the job = blocks merge

Because a non-zero pytest exit fails the job, a red test blocks the merge automatically — tests become a gate, not a suggestion.

Exercise 4 · Coverage gate in CI (worked YAML)Expert

Context: Coverage that is never enforced silently rots. A floor in CI — not a target of 100% — blocks regressions in coverage without incentivizing meaningless tests that chase the last percent.

Your task: Extend the CI workflow to run coverage and fail the build if it drops below a threshold, using --cov-fail-under. (Needs pytest-cov.)

Requirements:

  • Install pytest-cov alongside pytest
  • Run pytest with --cov against the source package
  • Enforce a floor with --cov-fail-under=<N> (e.g. 80)
  • Below the floor, pytest exits non-zero and fails the build
  • Use a floor rather than demanding 100% coverage
  • Report term-missing so uncovered lines are visible in the log

💡 Hint: The floor keeps the gate honest — it catches coverage regressions while leaving room to skip tests that would only pad the number.

Show solution

Worked YAML — enforce a coverage floor so it can't silently rot:

# .github/workflows/coverage.yml   (needs pytest + pytest-cov)
name: coverage
on: [push, pull_request]
jobs:
  cov:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e . pytest pytest-cov
      - name: Test with coverage floor
        run: pytest --cov=src --cov-report=term-missing --cov-fail-under=80
      # --cov-fail-under=80 exits non-zero (fails the build) below 80% coverage.

A floor (not a target of 100%) keeps the gate honest: it blocks regressions in coverage without incentivizing meaningless tests to chase the last percent.

Exercise 5 · Compose quality gatesProfessional

Context: Professional CI gates on more than tests. A single gate function that requires tests to pass, coverage to clear a floor, and no critical lint errors makes the build green only when every check passes.

Your task: Model a quality gate offline that ANDs several checks — tests pass, coverage ≥ floor, zero critical lint errors — and returns both a pass/fail and the blocking reasons.

Requirements:

  • quality_gate(results, coverage_floor) inspects a results dict
  • Fail if tests are not passing
  • Fail if coverage is below the floor
  • Fail if there are any critical lint errors
  • Return a boolean plus a list of human-readable blocking reasons
  • Demonstrate one passing and one failing set of results

💡 Hint: Accumulate reasons as you check each condition, then the overall pass is simply "no reasons" — the reasons list tells the author exactly what to fix.

Show solution

A single gate that ANDs the quality checks — modeled offline:

def quality_gate(results, coverage_floor=80):
    reasons = []
    if not results["tests_pass"]:
        reasons.append("tests failing")
    if results["coverage"] < coverage_floor:
        reasons.append(f"coverage {results['coverage']}% < {coverage_floor}%")
    if results["critical_lint"] > 0:
        reasons.append(f"{results['critical_lint']} critical lint errors")
    return (not reasons), reasons

ok  = {"tests_pass": True, "coverage": 88, "critical_lint": 0}
bad = {"tests_pass": True, "coverage": 72, "critical_lint": 2}
print(quality_gate(ok))    # (True, [])
print(quality_gate(bad))   # (False, ['coverage 72% < 80%', '2 critical lint errors'])

The build is green only when every check passes; the reasons list tells the author exactly what to fix — the gate that maps to the CI YAML above.

Exercise 6 · Lead a testing culture (health check)Industry scenario

Context: Tech-lead reality: testing culture is measurable. A scorecard over signals — flaky-test rate, suite time, coverage trend, share of PRs with tests — turns "how healthy is our testing?" into a grade and a concrete next investment.

Your task: Encode a health scorecard over testing signals that outputs a health grade and names the single top risk to address.

Requirements:

  • health(signals) scores each signal against a threshold
  • Cover flaky-test rate, median suite time, coverage trend, and percent of PRs with tests
  • Sum the passing signals into a score and map it to a named grade
  • Identify and return the single biggest risk when any signal fails
  • Demonstrate it on a realistic set of signals
  • The output is actionable — a grade plus one place to invest

💡 Hint: Award a point per healthy signal and index a grade list by the total; the top risk is whichever failing signal you surface, giving a lead a concrete target instead of "test more".

Show solution

Turn "how healthy is our testing?" into a scored, actionable answer:

def health(signals):
    score = 0
    notes = []
    if signals["flaky_rate"] <= 0.01: score += 1
    else: notes.append(("flaky tests", signals["flaky_rate"]))
    if signals["suite_seconds"] <= 300: score += 1
    else: notes.append(("slow suite", signals["suite_seconds"]))
    if signals["coverage_trend"] >= 0: score += 1
    else: notes.append(("coverage falling", signals["coverage_trend"]))
    if signals["pct_prs_with_tests"] >= 0.8: score += 1
    else: notes.append(("PRs lack tests", signals["pct_prs_with_tests"]))
    grade = ["at risk","weak","fair","good","strong"][score]
    top_risk = max(notes, key=lambda x: 1)[0] if notes else "none"
    return grade, top_risk

s = {"flaky_rate": 0.03, "suite_seconds": 120,
     "coverage_trend": +2, "pct_prs_with_tests": 0.9}
print(health(s))   # ('good', 'flaky tests')

The scorecard grades the suite and names the single biggest risk (here, flaky tests) — giving a lead a concrete place to invest rather than a vague "test more".

✓ Checkpoint — you can move on when you can…

  • Write integration tests.
  • Measure and read coverage honestly.
  • Run tests in CI as a required gate.
  • Own quality gates and a testing culture.

Knowledge check check yourself

✓ Knowledge check

The coverage lab reports 1/2 branches = 50% and names discount:guard as missing. Why does the lesson insist that 100% coverage is not the same as correctness?

Show answer
Coverage only tells you a line ran, not that you asserted the right thing about it. It's a gap-finder that flags untested branches (here the error guard), but a line can be executed with a wrong or absent assertion — so you aim for meaningful coverage of important logic (~80-90% on core), not a vanity 100%.
✓ Knowledge check

The lesson's quality_gate turns "is this PR mergeable?" into an objective check. What three conditions does it enforce, and why encode it rather than leave it to a reviewer?

Show answer
It requires tests passing, coverage at or above a minimum floor, and zero new lint errors, returning the specific failing reasons. Encoding it makes the merge decision objective and consistent instead of a reviewer's mood, and it becomes the automated CI gate that keeps broken code out of main.
© 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