Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Testing & Debugging  ›  Capstone Project

Capstone — The Test Rig

40 minCollect, expand, inject, run in isolation, classify and report — one runner for Quarry's whole suite
You're building a piece ofQuarry — the test rig
This piece — suite_report(): Collects the suite, injects fixtures, expands parameters, filters by keyword and reports every outcome.
Scenario Before Quarry's next release, the rig runs the whole suite in one go and reports the empty-batch crash, the kept newline and the test missing its clock.
Your task
Make the rig's decorators real (test registers into TESTS, fixture registers into FIXTURES by name, parametrize attaches its rows to the function) and build suite_report(keyword): expand every registered test into IDs (name, or name[v1-v2] per parametrised row), keep IDs containing keyword, run each with row values first and fresh fixtures for the remaining parameters, and return {"passed", "failed", "errors", "skipped"} lists in registration order. A missing or crashing fixture is an error; SkipTest is skipped. Example: suite_report("rate") → passed [test_rate_of_batch, test_rate_counts_only_errors], errors [test_rate_of_empty_batch].

Capstone — The Test Rig

In short: every feature here is something you've already met — the capstone is wiring them into one loop. Collect with an explicit registry, not a guess over globals(). Expand a parametrised test into one job per row, built immediately rather than deferred into a closure. Inject fixtures fresh, after the row's own values. Run in isolation so one crash never stops the rest. Classify into passed, failed, error or skipped — with the specific exceptions caught before the general ones. Report, grouped by outcome, with an exit code CI can read. That is what pytest is on your machine; here you build a small, honest version of it, then point it at Quarry's real suite and read what it finds.

Six boxes in a row — collect, expand, inject, run alone, classify, report — each labelled with what it reuses from an earlier lesson, feeding one final report

Five lessons, five pieces: a table of parser cases, a fixture factory, a fake source, a round-trip property, a failure narrower. Each works on its own. What Quarry doesn't have yet is the thing that holds them together — one command that finds every test, gives each what it needs, runs it in isolation, and tells you in a few lines whether Quarry still works.

Register tests explicitly instead of scanning and guessing

Lesson 1 collected by scanning globals() for names starting test — that works, but it guesses. A registry is explicit: a decorator adds each test to a list the moment it's defined, and the rig runs exactly that list, in definition order.

python

Both decorators hand back the same function they were given — they only record it. That matters when decorators stack:

What does this print?
REGISTRY = []

def test(fn):
    REGISTRY.append(fn)
    return fn

def parametrize(*cases):
    def attach(fn):
        fn.cases = cases
        return fn
    return attach

@parametrize(("WARN",), ("INFO",))
@test
def test_level(level):
    pass

print(len(getattr(REGISTRY[0], "cases", ())))

Classify an outcome — and get the except order right

pytest's pytest.skip("reason") and unittest's SkipTest are both an exception the runner recognises, which puts a trap in the classifier:

What does this print?
import unittest

def outcome(fn):
    try:
        fn()
    except AssertionError:
        return "failed"
    except Exception:
        return "error"
    except unittest.SkipTest:
        return "skipped"
    return "passed"

def test_later():
    raise unittest.SkipTest("the vault encoder isn't merged yet")

print(outcome(test_later))

Put the specific classes first — SkipTest, then AssertionError, then Exception — and each lands in its own bucket.

Expand a parametrised test without the late-binding trap

A parametrised test becomes several runnable jobs, one per row. The obvious way to build them has a famous bug:

broken — fix it

Each job should check its own row, giving [True, True, False]. Instead every job checks the last row. Fix how the jobs are built.

A function body looks up outside names when it runs. All three lambdas share one case variable, and by the time they run the loop is over. A default argument is evaluated when the function is defined, so case=case freezes each row into its own job. The rig avoids the problem entirely by running each row immediately as it loops, rather than collecting jobs for later.

Here's the heart of the rig for one test ID: split the parameters between row and fixtures, build fresh fixture values, run, classify.

step through it
1import inspect
2 
3def batch():
4 return ["INFO yard: a", "ERROR vault: b"]
5 
6FIXTURES = {"batch": batch}
7 
8def test_count(expected, batch):
9 assert len(batch) == expected
10 
11row = (2,)
12names = list(inspect.signature(test_count).parameters)
13kwargs = dict(zip(names, row))
14for name in names[len(row):]:
15 kwargs[name] = FIXTURES[name]()
16try:
17 test_count(**kwargs)
18 outcome = "passed"
19except AssertionError:
20 outcome = "failed"
21print(outcome, kwargs)

zip(names, row) fills the first parameters from the row; every name left over must be a fixture. If one isn't registered, looking it up raises KeyError — before the test runs — and that is an error, not a failure: the test never got to check anything.

Report flakiness honestly, and let the exit code be the verdict

A flaky test passes and fails on the same code — usually timing, ordering or the network. The tempting fix is to rerun failures until they pass — look what that does: with r tries, a test that fails half the time shows green with chance 1 − 0.5ʳ, 94% at four tries. The suite looks healthy while the bug behind the flakiness ships. Rerun to detect flakiness, then report it; never rerun to hide it — a test that both passed and failed across runs of the same code is flagged as flaky, and that list is worth more than the green tick.

python

Your numbers will differ run to run — timing is the one measurement in a test report that's never exactly repeatable, which is why the rig reports it but never asserts on it. When pytest finishes it exits with a number CI reads: 0 if everything collected passed (skips are fine), 1 if anything failed or errored, 5 if nothing was collected at all — a suite that runs nothing proves nothing.

python

Quick practice

What does this print?
def exit_code(outcomes):
    if not outcomes:
        return 5
    if any(o in ("failed", "error") for o in outcomes):
        return 1
    return 0

runs = [["passed"], ["passed", "error"], []]
print([exit_code(r) for r in runs])
What does this print?
seen = {}
for run in [{"a": "passed", "b": "failed"}, {"a": "failed", "b": "failed"}]:
    for test_id, outcome in run.items():
        seen.setdefault(test_id, set()).add(outcome)

flaky = sorted(t for t, outs in seen.items() if "passed" in outs and outs & {"failed", "error"})
print(flaky)

🎯 Your turn

The starter holds three parts. Quarry's code under test: parse_line and error_rate. Quarry's suite: ten tests using @test, @fixture and @parametrize, plus a skip(reason) helper. And the rig — which is yours.

Make the decorators real (test registers, fixture registers by name, parametrize attaches its rows), then write suite_report(keyword):

  • Expand every registered test into IDs: a plain test's ID is its name; a parametrised one gives name[v1-v2] per row.
  • Keep only IDs containing keyword (an empty keyword keeps everything).
  • Run each: fill parameters from the row, the rest from fresh fixtures. A missing or crashing fixture is an error; SkipTest is skipped; AssertionError is failed; any other exception is an error.
  • Return {"passed": [...], "failed": [...], "errors": [...], "skipped": [...]}, IDs in registration order.
suite_report("rate")
# → {"passed": ["test_rate_of_batch", "test_rate_counts_only_errors"],
#    "failed": [], "errors": ["test_rate_of_empty_batch"], "skipped": []}

Then read the full report with suite_report(""). Two of Quarry's own bugs are in it — an empty batch that crashes and a newline the parser keeps — and a test that asks for a fixture nobody wrote. Those are real findings. That is what the rig is for.

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upName the bucket

Write outcome_for(exc_name) mapping what a test raised to its bucket: None (nothing raised) → "passed", "AssertionError" → "failed", "SkipTest" → "skipped", anything else → "error".

test-designconditionals
DrillA -k expression

pytest's -k takes an expression. Support a simple form: terms joined by and, each optionally prefixed not . An ID is selected when every term holds — a plain term must appear in the ID, a not term must not. Matching ignores case. An empty keyword selects everything. Write select(ids, keyword) returning the selected IDs in order.

test-designstrings
Build--durations

timings is a list of [test_id, seconds]. Write slowest(timings, n) returning the IDs of the n slowest tests, slowest first; tests with equal times are ordered by ID.

test-designsorting
BossFlaky or broken?

runs is a list of runs of the same code, each a dict from test ID to outcome ("passed", "failed", "error", "skipped"). Write classify_runs(runs) returning {"flaky": [...], "broken": [...]}: flaky tests passed in at least one run and failed or errored in another; broken tests failed or errored in every run they appear in. Both lists sorted.

test-designsetsdictionaries
CapstoneThe CI verdict

Write ci_verdict(runs) for a list of runs (each a dict ID → outcome; the last run is the one CI reports). Return {"exit_code": ..., "flaky": [...], "verdict": ...}. exit_code follows pytest for the last run: 5 if it has no tests, 1 if any test failed or errored, else 0. flaky is the sorted list of IDs that passed in some run and failed or errored in another. verdict is "red" if the exit code is 5, or if the last run has a failure or error on a test that isn't flaky; otherwise "flaky" if the flaky list is non-empty; otherwise "green".

test-designpytestsets
ExplainShould CI rerun until green?

A teammate proposes making Quarry's CI rerun every failing test up to five times and pass if any attempt passes. Argue for or against, and say what you would do instead.

suite_report.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
suite_report(keyword) → dictRun Quarry's registered suite, filtered by keyword, and group test IDs by outcome.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.