Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Testing & Debugging  ›  Lesson

Properties, Coverage & Honest Green

Property-Based Testing 24 minState what must hold for every input, let generated examples hunt for a counterexample, and check that green really means tested
You're building a piece ofQuarry — the test rig
This piece — round_trip(): Proves a record survives being written and read back, even with commas and backslashes in it.
Scenario A log message with a comma in it split into four fields in Quarry's vault; the rig now proves every record survives the round trip.
Your task
Build round_trip(record) for Quarry's vault: write encode(record), which escapes each field (backslash → two backslashes, then comma → backslash-comma) and joins level, stage and msg with commas, and decode(text), which reads character by character — a backslash takes the next character literally, an unescaped comma ends a field. round_trip returns decode(encode(record)), which must equal record. Example: round_trip({"level": "WARN", "stage": "yard", "msg": "a, b"}) → the same dict.

Properties, Coverage & Honest Green

In short: an example test says this input gives this output; a property says for any input, this relationship holds — a round trip, an invariant, idempotence — and you let generated examples hunt for a counterexample instead of writing them down by hand. A found failure is usually noisy; shrinking repeatedly deletes or simplifies it while it keeps failing, until what's left is the essence of the bug. Coverage shows which lines ran, which is not the same as which inputs were tried — 100% coverage with a crash on an untested input is normal, not a contradiction. And a green suite can lie in several specific, recognisable ways.

A 15-character failing message shrinking by repeated deletion — each character kept gone only if the shorter string still fails — down to one surviving comma, which is the whole bug

Quarry's vault stores each record as one line — level, stage and message joined by commas — and reads it back when the report runs. It had tests. They wrote WARN,yard,disk full and read it back, and they passed. Then a real message arrived — disk 91% full, retrying — and the vault split it into four fields instead of three. Every test was green. None of them had ever tried a comma.

State what must hold for every message, not just the ones you thought of

A few property shapes cover most of what you'll write: round trip (decode(encode(r)) == r), invariant (something about the output is always true — a sort's result is in order and has the same items as the input), idempotence (normalise(normalise(s)) == normalise(s)), and oracle (a fast, clever version agrees with a slow, obviously-correct one). Properties don't name the answer, which is why they can be checked on inputs nobody wrote down:

python

Neither property alone is enough — "ordered" is satisfied by always returning []; "same items" is satisfied by returning the input untouched. Together they pin sorting down completely. Before generating anything, see what one awkward message does to the vault's encoder:

What does this print?
record = ["WARN", "yard", "disk 91% full, retrying"]
text = ",".join(record)
print(len(text.split(",")))

You found that because you already knew to try a comma. A property test finds it without knowing — generate many random records from a seeded source (so a failure replays exactly) and stop at the first one that breaks the round trip:

python

Change the seed and the counterexample changes, but one always turns up within a handful of tries — the alphabet is deliberately small and nasty (spaces, colons, commas), because what you generate decides what you can find. That cuts both ways: a generator that can't produce the dangerous input makes every property pass:

broken — fix it

dedupe_sorted is wrong for any list with a repeated number, but the property check passes all 200 examples. The bug is in the generator, not the check. Fix it so the check can see the bug.

On your own machine the standard tool is Hypothesis (pip install hypothesis): you describe the shape of inputs with strategies and it generates, runs as an ordinary pytest test, deliberately favours edge cases, remembers failing examples between runs, and shrinks every failure before showing it to you.

from hypothesis import given, strategies as st

records = st.fixed_dictionaries({
    "level": st.sampled_from(["DEBUG", "INFO", "WARN", "ERROR"]),
    "stage": st.text(min_size=1),
    "msg": st.text(),
})

@given(records)
def test_vault_round_trip(r):
    assert decode(encode(r)) == r

Hypothesis doesn't run in this browser, so we build the shrinking step ourselves — it's the part that turns a baffling failure into an obvious one.

Shrink a 15-character failure down to one comma

A random counterexample is usually noisy: {"msg": "ab: ,b a:ba ,,a"}. Which character matters? Keep deleting one character at a time; keep the deletion only if the result still fails. When no smaller version fails, what's left is the essence of the bug.

python

Fifteen characters became one — the comma is the bug. Step through a shorter case to watch each deletion either stick or get rejected:

step through it
1def fails(msg):
2 return "," in msg
3 
4msg = "a,b"
5i = 0
6while i < len(msg):
7 smaller = msg[:i] + msg[i + 1:]
8 if fails(smaller):
9 msg = smaller
10 else:
11 i += 1
12print(msg)

If a bug fires on a fraction q of all possible inputs, n random examples miss it with probability (1 − q)ⁿ. A 1-in-100 bug needs about 300 examples to be found 95% of the time — Hypothesis runs 100 by default, which is why good generators bias towards edge cases instead of relying on luck.

Measure which lines ran — and see why that isn't enough

Coverage measures which lines executed while the tests ran. On a real project it's pytest --cov. The mechanism is a trace hook — Python calls a function you register on every line executed — and the same hook works here:

python

Line 5 — return "fail" — never ran. That's what coverage finds: code no test has ever executed. The reverse is not true — every line running doesn't mean every line is right:

The one test passes and covers every line of pct. What does pct(1, 0) do?
def pct(part, whole):
    return round(100 * part / whole)

def test_pct():
    assert pct(1, 4) == 25

test_pct()            # passes, and covers 100% of pct's lines
print(pct(1, 0))

Catch a suite that can't tell right from wrong

A test suite is only worth its ability to turn red when the code is wrong. Mutation testing makes a small deliberate change — a mutant — and sees whether any test notices; a mutant that survives marks a behaviour nobody checks:

python

The first suite looks thorough and lets two mutants live, because it never tests the boundary. A suite also goes green dishonestly in other ways worth recognising on sight: no assertion (the test calls the code and checks nothing), swallowed failure (a broad try/except around the check — the trap from lesson 1), always-true assert (assert (got == want, "msg") asserts a non-empty tuple — the message goes after the comma, no brackets), testing the double (the mock returns 3, the test asserts 3), and a generator that can't reach the bug, as above. Each one is caught by the same habit: make the test fail once on purpose.

Quick practice

What does this print?
mutants = {
    "original": lambda n: n % 2 == 0,
    "odd check": lambda n: n % 2 == 1,
    "always True": lambda n: True,
}

def survivors(suite):
    return [name for name in mutants if name != "original"
            and all(mutants[name](n) == want for n, want in suite)]

print(survivors([(4, True)]))
What does this print?
def is_sorted(xs):
    return all(a <= b for a, b in zip(xs, xs[1:]))

def test_is_sorted():
    assert is_sorted([1, 2, 3])

test_is_sorted()
print(is_sorted([]), is_sorted([5]))

🎯 Your turn

Build round_trip(record) — the property that guards Quarry's vault. Write encode(record), which joins level, stage and msg with commas but first escapes each field (a backslash becomes \\, a comma becomes \,), and decode(text), which walks the text character by character: a backslash means "take the next character literally", an unescaped comma ends a field. round_trip returns decode(encode(record)) — and it must equal record for every record:

round_trip({"level": "WARN", "stage": "yard", "msg": "disk full, retrying"})
# → {"level": "WARN", "stage": "yard", "msg": "disk full, retrying"}

The starter has the naive version that breaks on a comma. Escape the backslash before the comma, or the backslashes you add will be escaped a second time.

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-upIs it really sorted?

Write is_sorted_permutation(original, result) returning True only when result is in non-decreasing order and contains exactly the same items as original, duplicates included.

property-testingsorting
DrillFind the first counterexample

clamp_pct should always return a value between 0 and 100. Write first_failure(cases) returning the first input in cases for which that property fails, or None if it holds for all of them.

property-testingloops
BuildShrink a failing list

fails(xs) is True when Quarry's buggy total disagrees with sum. Write shrink_list(xs): if xs doesn't fail, return it unchanged. Otherwise repeatedly delete any single element whose removal still fails, until none can be deleted; then repeatedly move any element one step toward zero (x - 1 if positive, x + 1 if negative) while the list still fails. Return the result.

property-testingshrinkingloops
BossWhich mutants survive?

MUTANTS holds deliberately broken versions of is_adult(age) (which should be age >= 18). A suite is a list of [age, expected] pairs. Write surviving_mutants(suite) returning the sorted names of mutants that pass every case in the suite — the behaviours this suite doesn't check.

property-testingcoveragemutation-testing
CapstoneA property checker that shrinks

Build a tiny property-testing engine for the naive vault encoder in the starter. check_property(seed, runs): with random.Random(seed), generate up to runs records (level from LEVELS, stage from STAGES, msg of 0–8 characters from ALPHABET); the property is that naive_decode(naive_encode(r)) == r, where raising also counts as failing. At the first failure, shrink it: delete characters from msg one at a time while it still fails, then set level to LEVELS[0] and stage to STAGES[0] if it still fails with each. Return the shrunk record, or None if all runs passed. (How you generate is up to you — only the shrunk result is graded.)

property-testingshrinking
ExplainFull coverage, still broken

Quarry's percentage helper has 100% line coverage and a green suite, yet it crashed in production on an empty batch. Explain how both can be true, and what would have caught it.

round_trip.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
round_trip(record) → dictEncode a record for the vault and decode it again; the result must equal the input.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.