ЁЯПл The SchoolтА║ЁЯУЭ TestingтА║ЁЯО▓ рдзрдбрд╛ 09 тАФ Property-based testing: рдПрдХ рдирд┐рдпрдо, рдПрдХ seed рдЖрдгрд┐ shrinking
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯО▓ рдзрдбрд╛ 09 тАФ Property-based testing: рдПрдХ рдирд┐рдпрдо, рдПрдХ seed рдЖрдгрд┐ shrinking

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 09 ┬╖ рдорд╛рдЧреАрд▓: lesson-08-e2e-pyramid ┬╖ рдкреБрдвреАрд▓: lesson-10-coverage-mutation


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ08, рдЖрдгрд┐ рддреНрдпрд╛рд╢рд┐рд╡рд╛рдп property-based testing: рдкреНрд░рддреНрдпреЗрдХ рдкреНрд░рд╢реНрди рд╕реНрд╡рддрдГ рдирд┐рд╡рдбрдгреНрдпрд╛рдРрд╡рдЬреА рддреБрдореНрд╣реА рдкреНрд░рддреНрдпреЗрдХ input рд╕рд╛рдареА рдЦрд░рд╛ рдЕрд╕рд╛рдпрд▓рд╛рдЪ рд╣рд╡рд╛ рдЕрд╕рд╛ рдирд┐рдпрдо (рдПрдХ property) рд╕рд╛рдВрдЧрддрд╛, рдПрдХрд╛ seeded generator рд▓рд╛ рд╢рдВрднрд░ inputs рддрдпрд╛рд░ рдХрд░реВ рджреЗрддрд╛, рдЖрдгрд┐ рдПрдЦрд╛рджрд╛ fail рдЭрд╛рд▓рд╛ рдХреА рддреНрдпрд╛рд▓рд╛ рдЕрдЬреВрди fail рд╣реЛрдгрд╛рд▒реНрдпрд╛ рд╕реЛрдкреНрдпрд╛ input рдкрд░реНрдпрдВрдд shrink рдХрд░рддрд╛. Rng, forall() рдЖрдгрд┐ shrink() exam/lab.py рдордзреНрдпреЗ; property exam/demo.py рдордзреНрдпреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдПрдХреЗрдХ рдкреНрд░рд╢реНрди рд▓рд┐рд╣реВрди рдХрддрд░рд┐рдирд╛ рдХрдВрдЯрд╛рд│рд▓реА рдЖрд╣реЗ. рдореНрд╣рдгреВрди рддреА рддреНрдпрд╛рдРрд╡рдЬреА рдПрдХ рдирд┐рдпрдо рд▓рд┐рд╣реВрди рдареЗрд╡рддреЗ:

".5 рдиреЗ рд╕рдВрдкрдгрд╛рд▒реНрдпрд╛ рдЧреБрдгрд╛рд▓рд╛ рдкреБрдврдЪреНрдпрд╛ рдкреВрд░реНрдг рд╕рдВрдЦреНрдпреЗрдЗрддрдХрд╛рдЪ рддреЛрдЪ grade рдорд┐рд│рд╛рдпрд▓рд╛ рд╣рд╡рд╛." (рдХрд╛рд░рдг рдЕрд░реНрдзреЗ рдЧреБрдг рд╡рд░ round рд╣реЛрддрд╛рдд.)

рдордЧ рддреА рддреЛ рдирд┐рдпрдо рдПрдХрд╛ рдкреНрд░рд╢реНрди-рдпрдВрддреНрд░рд╛рдХрдбреЗ ЁЯО▓ рджреЗрддреЗ. рдпрдВрддреНрд░ рдПрдХ рдкреВрд░реНрдг рд╕рдВрдЦреНрдпрд╛ n рдирд┐рд╡рдбрддреЗ тАФ 0 рддреЗ 99 рдордзрд▓реА рдХреЛрдгрддреАрд╣реА тАФ рдЖрдгрд┐ рддрдкрд╛рд╕рддреЗ: n + 0.5 рд▓рд╛ n + 1 рдЗрддрдХрд╛рдЪ grade рдорд┐рд│рддреЛ рдХрд╛? рд╣реЗ рддреЗ 100 рд╡реЗрд│рд╛ рдХрд░рддреЗ.

9рд╡реНрдпрд╛ рдкреНрд░рдпрддреНрдирд╛рдд рддреЗ 74 рдирд┐рд╡рдбрддреЗ: 74.5 рд▓рд╛ B рдорд┐рд│рддреЛ, рдкрдг 75 рд▓рд╛ A. рдирд┐рдпрдо рдореЛрдбрд▓рд╛! ЁЯЪи

рдЖрддрд╛ рдпрдВрддреНрд░ рдПрдХ рд╣реБрд╢рд╛рд░ рдЧреЛрд╖реНрдЯ рдХрд░рддреЗ. рддреЗ рд╡рд┐рдЪрд╛рд░рддреЗ: "рдирд┐рдпрдо рдореЛрдбрдгрд╛рд░реА рдЕрдЬреВрди рд╕реЛрдкреА рд╕рдВрдЦреНрдпрд╛ рдЖрд╣реЗ рдХрд╛?" рддреЗ 0 рд╡рд╛рдкрд░реВрди рдкрд╛рд╣рддреЗ, рдордЧ 74 рдЪрд╛ рдЕрд░реНрдзрд╛, рдордЧ 64, 54, 44 тАФ рддреАрд╣реА рдореЛрдбрддреЗ! рдордЧ 44 рдкрд╛рд╕реВрди: 0, 22, 34 тАФ рддреАрд╣реА рдореЛрдбрддреЗ! 34 рдкрд╛рд╕реВрди рддреНрдпрд╛рд╣реВрди рд▓рд╣рд╛рди рдХреЛрдгрддреАрдЪ рд╕рдВрдЦреНрдпрд╛ рдирд┐рдпрдо рдореЛрдбрдд рдирд╛рд╣реА. рдореНрд╣рдгреВрди рдпрдВрддреНрд░ рддреНрдпрд╛рд▓рд╛ рд╕рд╛рдкрдбрд▓реЗрд▓реЗ рд╕рд░реНрд╡рд╛рдд рд╕реЛрдкреЗ failure рд╕рд╛рдВрдЧрддреЗ: 34.5. рдзрдбрд╛ 03 рдордзрд▓рд╛рдЪ bug, рдХрддрд░рд┐рдирд╛рдиреЗ рдПрдХрд╣реА score рди рдирд┐рд╡рдбрддрд╛ рд╕рд╛рдкрдбрд▓реЗрд▓рд╛.

рдпрдВрддреНрд░ рдЖрдкрд▓рд╛ seed тАФ рддреНрдпрд╛рдЪреНрдпрд╛ рдлрд╛рд╢рд╛рдВрдЪрд╛ рд╕реБрд░реБрд╡рд╛рддреАрдЪрд╛ рдЖрдХрдбрд╛ тАФ рд╕реБрджреНрдзрд╛ рд▓рд┐рд╣реВрди рдареЗрд╡рддреЗ, рдореНрд╣рдгрдЬреЗ рдХреЛрдгрд╛рд▓рд╛рд╣реА рдиреЗрдордХреЗ рддреЗрдЪ 100 рдкреНрд░рд╢реНрди рдкреБрдиреНрд╣рд╛ рдЪрд╛рд▓рд╡рддрд╛ рдпреЗрддрд╛рдд.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    r["ЁЯУЬ property<br/>grade(n + 0.5) == grade(n + 1)"] --> g["ЁЯО▓ seeded generator<br/>seed 3, n in 0..99"]
    g --> run["run 1..100"]
    run -->|"run 9: n = 74"| f["тЭМ 74.5 тЖТ B but 75 тЖТ A"]
    f --> s["ЁЯФН shrink<br/>74 тЖТ 44 тЖТ 34"]
    s --> m["smallest failing score 34.5"]

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрдХреГрддреА + рдПрдХ lab: https://school-edh.pages.dev/testing/lesson-diagrams.html#l09

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рддреБрдореНрд╣рд╛рд▓рд╛ рд╕реБрдЪрдгрд╛рд░реЗ рдкреНрд░рд╢реНрди рдХрд╛рдп рдЪреБрдХреВ рд╢рдХрддреЗ рдпрд╛рдЪреНрдпрд╛ рддреБрдордЪреНрдпрд╛ рдХрд▓реНрдкрдиреЗрдкреБрд░рддреЗрдЪ рдорд░реНрдпрд╛рджрд┐рдд рдЕрд╕рддрд╛рдд. Property рдирд┐рдпрдо рдПрдХрджрд╛ рд╕рд╛рдВрдЧрддреЗ, рдЖрдгрд┐ рдпрдВрддреНрд░ рддреЛ рдореЛрдбрдгрд╛рд░реЗ inputs рд╢реЛрдзрддреЗ тАФ рдЬреНрдпрд╛рдд рдХреЛрдгреАрд╣реА рд╣рд╛рддрд╛рдиреЗ рдирд┐рд╡рдбрдгрд╛рд░ рдирд╛рд╣реА рдЕрд╕реЗ inputs рд╕реБрджреНрдзрд╛ рдЕрд╕рддрд╛рдд. рд╕реНрдкрд╖реНрдЯ рдирд┐рдпрдо рдЖрдгрд┐ рдкреНрд░рдЪрдВрдб input space рдЕрд╕рд▓реЗрд▓реНрдпрд╛ code рд╡рд░ рддреЗ рд╕рд░реНрд╡рд╛рдд рдкреНрд░рднрд╛рд╡реА рдЕрд╕рддреЗ: parsers, serializers, рдкреИрд╕реЗ рдЖрдгрд┐ rounding, sorting, рддрд╛рд░рдЦрд╛рдВрдЪреЗ рдЧрдгрд┐рдд.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

exam/lab.py рдордзреАрд▓ Rng(seed) рд╣рд╛ рдПрдХ рдЫреЛрдЯрд╛ linear congruential generator рдЖрд╣реЗ; rng.int(lo, hi) lo рддреЗ hi рдордзрд▓реА рдкреВрд░реНрдг рд╕рдВрдЦреНрдпрд╛ рджреЗрддреЛ. forall(prop, gen, runs, seed) gen(rng) рд╡рд╛рдкрд░реВрди runs inputs рдХрд╛рдврддреЗ; рдЬреНрдпрд╛ рдкрд╣рд┐рд▓реНрдпрд╛ input рд╡рд░ prop(x) false рдпреЗрддреЗ, рддрд┐рдереЗ рддреЗ shrink(prop, x) рдмреЛрд▓рд╡рддреЗ рдЖрдгрд┐ run рдХреНрд░рдорд╛рдВрдХ, failing input, shrink рдХреЗрд▓реЗрд▓рд╛ input рдЖрдгрд┐ рдкрд╛рдпрд▒реНрдпрд╛ рдкрд░рдд рджреЗрддреЗ. shrink рдЖрдзреА 0, n // 2, рдордЧ n тИТ 10, n тИТ 20, тАж, рдЖрдгрд┐ n тИТ 1 рд╡рд╛рдкрд░реВрди рдкрд╛рд╣рддреЗ, рдЕрдЬреВрдирд╣реА fail рд╣реЛрдгрд╛рд░рд╛ рдкрд╣рд┐рд▓рд╛ рдЙрдореЗрджрд╡рд╛рд░ рдареЗрд╡рддреЗ, рдЖрдгрд┐ рдХреЛрдгрддрд╛рдЪ fail рд╣реЛрдд рдирд╛рд╣реА рддреЛрдкрд░реНрдпрдВрдд рд╣реЗ рдкреБрдиреНрд╣рд╛ рдкреБрдиреНрд╣рд╛ рдХрд░рддреЗ.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 exam/demo.py property
python3 - <<'EOF'
from exam.lab import forall, shrink, Rng
from exam.grades import grade, grade_v1, report_card
prop = lambda n: grade_v1(n + 0.5) == grade_v1(n + 1)
for seed in (1, 2, 3, 6, 9):
    i, x, small, steps = forall(prop, lambda r: r.int(0, 99), runs=100, seed=seed)
    print(f"seed {seed}: first failure on run {i:>2} at n={x} ┬╖ shrink path {steps}")
print("shrink from 74 by hand:", shrink(prop, 74))
avg_between = lambda rng: (lambda ms: min(ms) <= report_card("x", dict(enumerate(ms)), 90)["average"] <= max(ms))([rng.int(0, 100) for _ in range(rng.int(1, 6))])
i, x, _, _ = forall(lambda seed: avg_between(Rng(seed)), lambda r: r.int(1, 10**6), runs=200, seed=5)
print(f"'the average lies between the lowest and highest mark': {i} runs, failures {x}")
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

property рд╣реЗ рдЫрд╛рдкрддреЗ:

   grade_v1, seed 1: fails on run 25 at n=34 (34.5) ┬╖ shrinks 34 тЖТ smallest failing score 34.5
   grade_v1, seed 3: fails on run 9 at n=74 (74.5) ┬╖ shrinks 74 тЖТ 44 тЖТ 34 тЖТ smallest failing score 34.5
   grade (fixed), seed 1: 100 runs, failures: None
   grade_v1 property 'a higher score never gets a lower grade', 300 runs тЖТ failures: None

рддреБрдордЪрд╛ snippet рд╣реЗ рдЫрд╛рдкрддреЛ:

seed 1: first failure on run 25 at n=34 ┬╖ shrink path [34]
seed 2: first failure on run 47 at n=34 ┬╖ shrink path [34]
seed 3: first failure on run  9 at n=74 ┬╖ shrink path [74, 44, 34]
seed 6: first failure on run  6 at n=44 ┬╖ shrink path [44, 34]
seed 9: first failure on run  2 at n=34 ┬╖ shrink path [34]
shrink from 74 by hand: (34, [74, 44, 34])
'the average lies between the lowest and highest mark': 200 runs, failures None

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдкрд╛рдЪ рд╡реЗрдЧрд╡реЗрдЧрд│реНрдпрд╛ seeds рдирд╛ bug рд╡реЗрдЧрд╡реЗрдЧрд│реНрдпрд╛ runs рдордзреНрдпреЗ рдЖрдгрд┐ рд╡реЗрдЧрд╡реЗрдЧрд│реНрдпрд╛ scores рд╡рд░ рд╕рд╛рдкрдбрд▓рд╛, рдЖрдгрд┐ shrinking рдиреЗ рддреНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХрд╛рд▓рд╛ рдПрдХрд╛рдЪ рд╕реЛрдкреНрдпрд╛ case рдкрд░реНрдпрдВрдд рдЖрдгрд▓реЗ, 34.5. рджреБрд░реБрд╕реНрдд grade 100 runs pass рдЭрд╛рд▓реЗ. рдкрдг demo рдордзрд▓реА рджреБрд╕рд░реА property рдкрд╛рд╣рд╛: "рдЬрд╛рд╕реНрдд score рд▓рд╛ рдХрдзреАрдЪ рдХрдореА grade рдорд┐рд│рдд рдирд╛рд╣реА" рд╣реА bug рдЕрд╕рд▓реЗрд▓реНрдпрд╛ grade_v1 рд╕рд╛рдареАрд╣реА pass рдЭрд╛рд▓реА тАФ score рд╡рд╛рдврд▓реНрдпрд╛рд╡рд░ round() рдХрдзреАрдЪ рдЦрд╛рд▓реА рдЬрд╛рдд рдирд╛рд╣реА. Property рдлрдХреНрдд рддреЗрдЪ рд╢реЛрдзрддреЗ рдЬреЗ рддреА рд╕рд╛рдВрдЧрддреЗ. рдЖрдгрд┐ 100 pass рдЭрд╛рд▓реЗрд▓реЗ runs рдореНрд╣рдгрдЬреЗ 100 рдЙрджрд╛рд╣рд░рдгреЗ, рд╕рд┐рджреНрдзрддрд╛ рдирд╡реНрд╣реЗ; рджреБрд░реНрджреИрд╡реА seed рдЖрдгрд┐ рдереЛрдбреЗ runs рдЕрд╕рддреАрд▓ рддрд░ bug рд▓рдкреВрди рд░рд╛рд╣реВ рд╢рдХрддреЛ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ project рдордзреНрдпреЗ тАФ рддреАрдЪ property Hypothesis рдордзреНрдпреЗ (pip install hypothesis), pytest рдиреЗ рдЪрд╛рд▓рд╡рд▓реЗрд▓реА:

from hypothesis import given, example, strategies as st
from exam.grades import grade

@given(st.integers(min_value=0, max_value=99))
@example(34)                                   # always also try the case that bit us once
def test_half_marks_round_up(n):
    assert grade(n + 0.5) == grade(n + 1)

grade_v1 рд╡рд░ рдЪрд╛рд▓рд╡рд▓реНрдпрд╛рд╡рд░ Hypothesis shrink рдХреЗрд▓реЗрд▓рд╛ input рд╕рд╛рдВрдЧрддреЗ:

Falsifying example: test_half_marks_round_up(
    n=34,
)

Hypothesis failing examples рд╕реНрдерд╛рдирд┐рдХ .hypothesis/ directory рдордзреНрдпреЗ рд╕рд╛рдард╡рддреЗ рдЖрдгрд┐ рдкреБрдврдЪреНрдпрд╛ run рдордзреНрдпреЗ рддреЗ рдЖрдзреА рд╡рд╛рдкрд░реВрди рдкрд╛рд╣рддреЗ; @settings(max_examples=500) рдЬрд╛рд╕реНрдд examples рдорд╛рдЧрддреЗ, рдЖрдгрд┐ @seed(1234) randomness рдкрдХреНрдХреА рдХрд░рддреЗ. рдЗрддрд░ ecosystems: QuickCheck (Haskell), fast-check (JavaScript/TypeScript), jqwik (Java), proptest (Rust).

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: рдЕрд╕реЗ рдПрдХ function рд╢реЛрдзрд╛ рдЬреНрдпрд╛рдЪрд╛ рдирд┐рдпрдо рддреБрдореНрд╣реА рдПрдХрд╛ рд╡рд╛рдХреНрдпрд╛рдд рд╕рд╛рдВрдЧреВ рд╢рдХрддрд╛ тАФ serializer (round trip), рдХрд┐рдВрдорддреАрдЪреЗ рдЧрдгрд┐рдд (рдХрдзреАрдЪ negative рдирд╛рд╣реА), sort (output рдХреНрд░рдорд╛рдиреЗ рдЖрд╣реЗ рдЖрдгрд┐ рддреНрдпрд╛рдд рддреЗрдЪ items рдЖрд╣реЗрдд). рддреА property рдЖрдзреА рд▓рд┐рд╣рд╛; рд╣рд╛рддрд╛рдиреЗ рдирд┐рд╡рдбрд▓реЗрд▓реА рдЙрджрд╛рд╣рд░рдгреЗрд╣реА рдареЗрд╡рд╛.

тПня╕П рдкреБрдвреЗ

Test suite рдХрд┐рддреА рдЪрд╛рдВрдЧрд▓рд╛ рдЖрд╣реЗ? "рдХреЛрдгрддреНрдпрд╛ lines рдЪрд╛рд▓рд▓реНрдпрд╛?" рд╣реЗ рдиреЗрд╣рдореАрдЪреЗ рдЙрддреНрддрд░ тАФ coverage. Mutation testing рдПрдХ рдХрдареАрдг рдкреНрд░рд╢реНрди рд╡рд┐рдЪрд╛рд░рддреЗ: code рдЪреБрдХреАрдЪрд╛ рдЕрд╕рддрд╛ рддрд░ рдХреЛрдгрддреНрдпрд╛ test рд▓рд╛ рддреЗ рд▓рдХреНрд╖рд╛рдд рдЖрд▓реЗ рдЕрд╕рддреЗ рдХрд╛?

git checkout lesson-10-coverage-mutation

ЁЯО▓ Lesson 09 тАФ Property-based testing: a rule, a seed and shrinking

ЁЯУН You are here: Lesson 09 of 12 ┬╖ Previous: lesson-08-e2e-pyramid ┬╖ Next: lesson-10-coverage-mutation


ЁЯУж What's in this branch

Lessons 01тАУ08, plus property-based testing: instead of choosing each question, you state a rule that must hold for every input (a property), let a seeded generator invent a hundred inputs, and when one fails, shrink it to a simpler failing input. Rng, forall() and shrink() in exam/lab.py; property in exam/demo.py.

ЁЯзТ Explain like I'm 5

Katrina is tired of writing questions one by one. So she writes down a rule instead:

"A score ending in .5 must get the same grade as the next whole number." (Because halves round up.)

Then she hands the rule to a question machine ЁЯО▓. The machine picks a whole number n тАФ any from 0 to 99 тАФ and checks: does n + 0.5 get the same grade as n + 1? It does this 100 times.

On the 9th try it picks 74: 74.5 gets B, but 75 gets A. Rule broken! ЁЯЪи

Now the machine does something clever. It asks: "Is there a simpler number that also breaks the rule?" It tries 0, then half of 74, then 64, 54, 44 тАФ broken too! Then from 44: 0, 22, 34 тАФ broken too! From 34 nothing smaller breaks it. So the machine reports the simplest failure it found: 34.5. The same bug from lesson 03, found without Katrina choosing a single score.

The machine also writes down its seed тАФ the starting number of its dice тАФ so anybody can replay exactly the same 100 questions.

ЁЯЧ║я╕П Diagram

flowchart LR
    r["ЁЯУЬ property<br/>grade(n + 0.5) == grade(n + 1)"] --> g["ЁЯО▓ seeded generator<br/>seed 3, n in 0..99"]
    g --> run["run 1..100"]
    run -->|"run 9: n = 74"| f["тЭМ 74.5 тЖТ B but 75 тЖТ A"]
    f --> s["ЁЯФН shrink<br/>74 тЖТ 44 тЖТ 34"]
    s --> m["smallest failing score 34.5"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/testing/lesson-diagrams.html#l09

тЭУ What

ЁЯдФ Why

Because the questions you think of are limited by what you already imagine can go wrong. A property states the rule once, and the machine searches for inputs that break it тАФ including ones nobody would pick by hand. It is strongest on code with a clear rule and a huge input space: parsers, serializers, money and rounding, sorting, date arithmetic.

ЁЯФз How (in this repo)

Rng(seed) in exam/lab.py is a small linear congruential generator; rng.int(lo, hi) gives a whole number from lo to hi. forall(prop, gen, runs, seed) draws runs inputs with gen(rng); on the first input where prop(x) is false it calls shrink(prop, x) and returns the run number, the failing input, the shrunk input and the steps. shrink tries 0, n // 2, then n тИТ 10, n тИТ 20, тАж, and n тИТ 1, keeps the first candidate that still fails, and repeats until none does.

ЁЯзк Try it

python3 exam/demo.py property
python3 - <<'EOF'
from exam.lab import forall, shrink, Rng
from exam.grades import grade, grade_v1, report_card
prop = lambda n: grade_v1(n + 0.5) == grade_v1(n + 1)
for seed in (1, 2, 3, 6, 9):
    i, x, small, steps = forall(prop, lambda r: r.int(0, 99), runs=100, seed=seed)
    print(f"seed {seed}: first failure on run {i:>2} at n={x} ┬╖ shrink path {steps}")
print("shrink from 74 by hand:", shrink(prop, 74))
avg_between = lambda rng: (lambda ms: min(ms) <= report_card("x", dict(enumerate(ms)), 90)["average"] <= max(ms))([rng.int(0, 100) for _ in range(rng.int(1, 6))])
i, x, _, _ = forall(lambda seed: avg_between(Rng(seed)), lambda r: r.int(1, 10**6), runs=200, seed=5)
print(f"'the average lies between the lowest and highest mark': {i} runs, failures {x}")
EOF

тЬЕ Verify тАФ what you should see

property prints:

   grade_v1, seed 1: fails on run 25 at n=34 (34.5) ┬╖ shrinks 34 тЖТ smallest failing score 34.5
   grade_v1, seed 3: fails on run 9 at n=74 (74.5) ┬╖ shrinks 74 тЖТ 44 тЖТ 34 тЖТ smallest failing score 34.5
   grade (fixed), seed 1: 100 runs, failures: None
   grade_v1 property 'a higher score never gets a lower grade', 300 runs тЖТ failures: None

Your snippet prints:

seed 1: first failure on run 25 at n=34 ┬╖ shrink path [34]
seed 2: first failure on run 47 at n=34 ┬╖ shrink path [34]
seed 3: first failure on run  9 at n=74 ┬╖ shrink path [74, 44, 34]
seed 6: first failure on run  6 at n=44 ┬╖ shrink path [44, 34]
seed 9: first failure on run  2 at n=34 ┬╖ shrink path [34]
shrink from 74 by hand: (34, [74, 44, 34])
'the average lies between the lowest and highest mark': 200 runs, failures None

ЁЯПБ What you just proved

Five different seeds hit the bug at different runs and different scores, and shrinking took every one of them to the same simple case, 34.5. The fixed grade passed 100 runs. But look at the second property in the demo: "a higher score never gets a lower grade" passed for the buggy grade_v1 too тАФ round() never goes down as the score goes up. A property finds only what it states. And 100 passing runs are 100 examples, not a proof; with an unlucky seed and few runs, the bug could hide.

тЪая╕П Common mistakes

ЁЯПн In production

On a real project тАФ the same property in Hypothesis (pip install hypothesis), run by pytest:

from hypothesis import given, example, strategies as st
from exam.grades import grade

@given(st.integers(min_value=0, max_value=99))
@example(34)                                   # always also try the case that bit us once
def test_half_marks_round_up(n):
    assert grade(n + 0.5) == grade(n + 1)

Run against grade_v1, Hypothesis reports the shrunk input:

Falsifying example: test_half_marks_round_up(
    n=34,
)

Hypothesis stores failing examples in a local .hypothesis/ directory and tries them first on the next run; @settings(max_examples=500) asks for more examples, and @seed(1234) pins the randomness. Other ecosystems: QuickCheck (Haskell), fast-check (JavaScript/TypeScript), jqwik (Java), proptest (Rust).

ЁЯПн Why this matters in production: find one function where you can state a rule in a sentence тАФ a serializer (round trip), a price calculation (never negative), a sort (output is ordered and has the same items). Write that property first; keep your hand-picked examples too.

тПня╕П Next

How good is a test suite? "Which lines ran?" is the usual answer тАФ coverage. Mutation testing asks a harder question: would any test notice if the code were wrong?

git checkout lesson-10-coverage-mutation
тЖР Previouse2e pyramidNext тЖТcoverage mutation

This page is the lesson's README from the lesson-09-property-based branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.