Property-Based Testing — The Practice

The video covered the theory. This is where you run it. Everything below executes Hypothesis in your browser — edit a test, hit run, watch it search the input space and shrink whatever it breaks. Nothing to install, and nothing you do here leaves the page.

What you'll do
  1. Break three "obviously correct" stdlib properties — warm-up on functions you already know.
  2. Hunt a planted bug in a physics sim — arm the bug with a slider, then let Hypothesis find the input that exposes it.
  3. Write your own properties against a key-value store — the closest thing here to code you'd actually ship.

Need the syntax again? Part A is a one-screen refresher.

Part A — Refresher

One screen of syntax, so you can start writing tests without rewinding the video. Skip straight to Part B if it's fresh.

from hypothesis import given
from hypothesis.strategies import text

@given(text(), text())
def test_concat_length(a: str, b: str) -> None:
    assert len(a + b) == len(a) + len(b)

Built-in strategies (text(), integers(), lists(), floats()) cover primitives. For your own types you compose one with @composite — a function handed a draw callable, which turns any strategy into a concrete value:

from hypothesis.strategies import composite, floats

@composite
def single_ball(draw) -> Ball:
    return Ball(
        x=draw(floats(0.06, 0.94)),     # draw() → a float in range
        y=draw(floats(0.06, 0.94)),
        vx=draw(floats(-2.0, 2.0)),
        vy=draw(floats(-2.0, 2.0)),
    )

@given(single_ball())                    # now usable like any strategy
def test_energy_conserved(ball: Ball) -> None:
    ...

That's how every generator in this tutorial is built: single_ball, two_balls, whole sequences of store operations. Each runner below lists the @composite generators in its file next to the tests — click one to draw sample values and see the shape of the space Hypothesis is searching.

The one-line version. Example-based tests confirm what you already know. Property-based tests find what you didn't.

Part B — Warm up on a couple of small ones

You don't need a custom system to write property-based tests. The Python standard library is full of operations that have properties you can test directly. test_simple.py has five of them — two boring-and-passing, three that look just as obvious and Hypothesis will break.

# Pass: useful, dull
@given(lists(integers()))
def test_sorted_idempotent(xs):
    assert sorted(sorted(xs)) == sorted(xs)

@given(lists(integers()))
def test_sorted_preserves_length(xs):
    assert len(sorted(xs)) == len(xs)

# Look uncontroversial — Hypothesis disagrees
@given(text())
def test_upper_lower_round_trip(s):
    assert s.upper().lower() == s

@given(text())
def test_lower_upper_round_trip(s):
    assert s.lower().upper() == s

@given(text())
def test_utf8_byte_length(s):
    assert len(s.encode("utf-8")) == len(s)

Run each of them in the runner below. The first two pass against 100 inputs every time. The bottom three each fail within the first dozen generated examples — and Hypothesis shrinks the failure to a single character that demonstrates the issue.

Why each of those three fails

Hand-writing test cases for each of these is a road to madness; Hypothesis treats every codepoint as just another point in the input space.

The point of these three. The same skill — naming a rule that has to hold — generalizes from "sorted preserves length" to "no client sees another client's data" or "energy is conserved across a wall bounce." Once you start looking for properties, you start finding them everywhere.

Part C — Watch a property fail on a physics sim

Some systems make their invariants obvious because the invariants are physics. Below is a 2D billiard sim: circular balls bouncing in a unit square with perfectly elastic collisions. Energy is conserved. Momentum is conserved (within collisions). Balls stay inside the box. Same initial state + same dt → same final state. Four properties without trying.

We planted one bug in the wall-bounce code, gated behind a dial called BUG_THRESHOLD. The rule is: when a ball hits a wall slower than the threshold, the bounce eats half its energy on that axis. Fast hits stay perfectly elastic — so the sim looks fine when everything is moving quickly, and the bug only shows on slow, grazing bounces. Drag the Bug threshold slider up and watch: a red ring marks each leaky bounce, and a dashed ghost ball follows the trajectory the bounce should have produced.

Physics sandbox
off · elastic
Bounces slower than the threshold lose 50% of their kinetic energy on that axis; faster ones stay perfectly elastic.
Balls0
Kinetic energy0
Momentum (px, py)0, 0
Elapsed (sim)0.00 s

Properties under test

  • Energy conservation — total ½ m v² stays constant.
  • Momentum conservation — within a ball-ball collision.
  • No escape — every ball stays inside the box.
  • Determinism — same state + same dt ⇒ same result.

Drag the threshold above zero. Red rings mark every leaky bounce; the dashed ghost ball shows where it should have gone.

The bug, in code

This is the entire bug, in physics.py's wall-bounce routine. A clean bounce just flips the velocity component; below the threshold it also scales it by 0.707, i.e. 0.707² ≈ 50% of the kinetic energy on that axis:

v = getattr(ball, component)      # velocity into the wall
expected = -v                     # a clean elastic bounce
if BUG_THRESHOLD > 0 and abs(v) < BUG_THRESHOLD:
    setattr(ball, component, expected * 0.707)   # slow hit: leak energy
else:
    setattr(ball, component, expected)           # fast hit: perfect

Now the part that matters: an example-based test on this sim almost never spots the bug. You'd write something like "after 1 second, ball at position X" and the absolute number shifts just enough to look plausible. The property energy_before == energy_after catches it the first time Hypothesis happens to generate a slow-approach input — usually within a couple of dozen examples.

First, predict

Before you run anything: test_physics.py holds four properties, and the bug is armed. Which ones actually break? Commit to an answer for each — the useful part of this exercise is being wrong about one of them.

test_energy_conserved_single one ball, energy before == energy after
test_energy_conserved_pair two balls, total energy conserved
test_no_escape_single the ball never leaves the unit box
test_determinism same state + same dt ⇒ same result

Now go find out. The runner below has the same Bug threshold slider as the sandbox — but this one rewrites the test file. Drag it and watch the physics.BUG_THRESHOLD = … line in the source change, then run each of the four tests and see whether your predictions hold.

Replay button. Failing rows in the example browser get a ▶ Replay button that opens a modal animating that exact case with editable initial conditions. Watching the shrunk minimal counterexample play out visually is when the lesson tends to land.

Part D — Take it deeper with a key-value store

Physics is great for visceral demos; a key-value store is closer to the systems you actually build. Four operations — insert, get, delete, select(regex) — and a surprising number of subtle properties. We'll play with it, list the invariants, then test them.

Play first

You can't write properties for a system you haven't touched. The playground below has a 10-item checklist that ticks itself off as you exercise each operation — work through it and the invariants in the next section will be obvious rather than abstract.

KV playground
Signed in as
Playground
0 / 0

Tip: Values accept JSON (preferred). If parsing fails, the raw text is stored.

Result


                    

Current State (select .*)

Now list the invariants

Before code, finish these in plain English:

  1. After insert(k, v), calling get(k) should return …
  2. After delete(k), calling get(k) should return …
  3. If I insert(k, v1) and then insert(k, v2), get(k) should return …
  4. delete twice in a row is the same as delete once. True or false?
Suggested answers
  1. v. Always. Round-trip.
  2. None. The key is gone.
  3. v2. Later insert wins.
  4. True. Delete is idempotent.

Your first property

test_warmup.py has the invariants as @given stubs. Fill in the body of test_insert_then_get — two lines — and run it. Then flip the toggle at the top of the file (store.BUGGY = True) to plant a bug. Hypothesis will find that the store silently swallows empty-string keys, and shrink the failure to k = ''.

1

In the runner above, click Edit.

2

Replace the body of test_insert_then_get with two lines:

    store.insert(k, v)
    assert store.get(k) == v
3

Click Save. Then click test_insert_then_get under "@given tests". You'll see [PASS].

4

Now uncomment the two lines near the top:

# import store
# store.BUGGY = True

Save, re-run. Failure with a minimal counterexample k=''. Expand the example browser to see every attempt grouped by Hypothesis's phase (generate, shrink, minimal).

Where to go from here

The repo has progressively richer tests against the same store. The runner below tabs across them — pick one and click any @given button, or click a @composite generator to draw sample values from it. (test_isolation and test_trace_stateful reference sockets/threads, so they won't actually run in-browser — their source is still worth reading, and test_trace_stateful's generators still draw.)

packages = ["hypothesis==6.112.1", "attrs>=22"] [[fetch]] from = "." files = ["__init__.py", "store.py", "message.py", "webapp.py", "physics.py", "client.py", "trace.py", "test_simple.py", "test_warmup.py", "test_physics.py", "test_message.py", "test_trace.py", "test_trace_stateful.py", "test_isolation.py", "test_containment.py", "tests_app.py"] from js import document import tests_app _simple = document.getElementById("runner-simple") if _simple is not None: tests_app.mount_runner(_simple, "test_simple", show_tabs=False, show_generators=False, title="test_simple") _physics = document.getElementById("runner-physics") if _physics is not None: tests_app.mount_runner(_physics, "test_physics", show_tabs=False, show_generators=False, title="test_physics", bug_slider=True) _warmup = document.getElementById("runner-warmup") if _warmup is not None: tests_app.mount_runner(_warmup, "test_warmup", show_tabs=False, show_generators=False, title="test_warmup") _advanced = document.getElementById("runner-advanced") if _advanced is not None: # test_simple / test_warmup / test_physics each have their own runner # earlier on the page; no need to repeat them in the tab strip. tests_app.mount_runner(_advanced, "test_message", show_tabs=True, show_generators=True, title="advanced tests", exclude_tests=("test_simple", "test_warmup", "test_physics")) import math import random from js import document, window from pyodide.ffi import create_proxy import physics from physics import Ball, step, kinetic_energy, momentum _CANVAS = document.getElementById("stage") _CTX = _CANVAS.getContext("2d") if _CANVAS is not None else None _COLORS = ["#ffa091", "#6fdff1", "#5fdd8d", "#fae500", "#cbc0ff", "#f1806f", "#8b81fe"] _PHYS = { "balls": [], "running": True, "elapsed": 0.0, "last_ts": None, "initial_energy": 0.0, "ghosts": [], "markers": [], } _GHOST_MAX_AGE = 1.5 _MARKER_MAX_AGE = 0.6 def _phys_color(ball): try: idx = _PHYS["balls"].index(ball) except ValueError: idx = 0 return _COLORS[idx % len(_COLORS)] def _phys_hook(ball, axis, exp_vx, exp_vy, act_vx, act_vy): color = _phys_color(ball) _PHYS["ghosts"].append({ "x": ball.x, "y": ball.y, "vx": exp_vx, "vy": exp_vy, "radius": ball.radius, "color": color, "age": 0.0, "max_age": _GHOST_MAX_AGE, }) _PHYS["markers"].append({ "x": ball.x, "y": ball.y, "age": 0.0, "max_age": _MARKER_MAX_AGE, }) physics.set_event_hook(_phys_hook) def _phys_random_ball(): r = 0.04 return Ball( x=random.uniform(r + 0.02, 1 - r - 0.02), y=random.uniform(r + 0.02, 1 - r - 0.02), vx=random.uniform(-0.6, 0.6), vy=random.uniform(-0.6, 0.6), radius=r, ) def _phys_free_ball(attempts=60): """Sample a ball that doesn't overlap any existing one (None if crowded).""" for _attempt in range(attempts): b = _phys_random_ball() ok = True for other in _PHYS["balls"]: dx = b.x - other.x dy = b.y - other.y if dx * dx + dy * dy < (b.radius + other.radius) ** 2 * 1.2: ok = False break if ok: return b return None def _phys_reset(n=4): _PHYS["balls"] = [] for _ in range(n): b = _phys_free_ball(attempts=20) if b is not None: _PHYS["balls"].append(b) _PHYS["elapsed"] = 0.0 _PHYS["initial_energy"] = kinetic_energy(_PHYS["balls"]) _PHYS["ghosts"] = [] _PHYS["markers"] = [] def _phys_step_ghost(g, dt): g["x"] += g["vx"] * dt g["y"] += g["vy"] * dt r = g["radius"] if g["x"] - r < 0: g["x"] = r g["vx"] = -g["vx"] elif g["x"] + r > 1: g["x"] = 1 - r g["vx"] = -g["vx"] if g["y"] - r < 0: g["y"] = r g["vy"] = -g["vy"] elif g["y"] + r > 1: g["y"] = 1 - r g["vy"] = -g["vy"] def _phys_update_overlays(dt): new_ghosts = [] for g in _PHYS["ghosts"]: g["age"] += dt if g["age"] >= g["max_age"]: continue _phys_step_ghost(g, dt) new_ghosts.append(g) _PHYS["ghosts"] = new_ghosts new_markers = [] for m in _PHYS["markers"]: m["age"] += dt if m["age"] < m["max_age"]: new_markers.append(m) _PHYS["markers"] = new_markers def _phys_hex(h): h = h.lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) def _phys_draw(): if _CTX is None: return w = _CANVAS.width h = _CANVAS.height _CTX.clearRect(0, 0, w, h) _CTX.fillStyle = "#1d0521" _CTX.fillRect(0, 0, w, h) _CTX.strokeStyle = "rgba(252,251,249,0.18)" _CTX.lineWidth = 1 _CTX.strokeRect(0.5, 0.5, w - 1, h - 1) for g in _PHYS["ghosts"]: t = g["age"] / g["max_age"] alpha = max(0.0, 1.0 - t) * 0.55 r, gg, b = _phys_hex(g["color"]) _CTX.fillStyle = f"rgba({r},{gg},{b},{alpha:.3f})" _CTX.beginPath() _CTX.arc(g["x"] * w, g["y"] * h, g["radius"] * w, 0, 2 * math.pi) _CTX.fill() _CTX.strokeStyle = f"rgba({r},{gg},{b},{alpha + 0.2:.3f})" _CTX.setLineDash([4, 3]) _CTX.lineWidth = 1 _CTX.stroke() _CTX.setLineDash([]) for m in _PHYS["markers"]: t = m["age"] / m["max_age"] alpha = max(0.0, 1.0 - t) radius_px = 6 + t * 30 _CTX.beginPath() _CTX.arc(m["x"] * w, m["y"] * h, radius_px, 0, 2 * math.pi) _CTX.strokeStyle = f"rgba(229,72,77,{alpha:.3f})" _CTX.lineWidth = 2 _CTX.stroke() for i, b in enumerate(_PHYS["balls"]): _CTX.beginPath() _CTX.arc(b.x * w, b.y * h, b.radius * w, 0, 2 * math.pi) _CTX.fillStyle = _COLORS[i % len(_COLORS)] _CTX.fill() _CTX.strokeStyle = "rgba(22,3,27,0.5)" _CTX.lineWidth = 1 _CTX.stroke() def _phys_update_stats(): document.getElementById("stat-n").textContent = str(len(_PHYS["balls"])) e = kinetic_energy(_PHYS["balls"]) e_el = document.getElementById("stat-energy") drift = "" cls = "value" if _PHYS["initial_energy"] > 0: pct = 100 * e / _PHYS["initial_energy"] drift = f" ({pct:.1f}% of initial)" if pct < 99.0: cls = "value warn" e_el.textContent = f"{e:.4f}{drift}" e_el.className = cls px, py = momentum(_PHYS["balls"]) document.getElementById("stat-momentum").textContent = f"{px:+.3f}, {py:+.3f}" document.getElementById("stat-time").textContent = f"{_PHYS['elapsed']:.2f} s" def _phys_animate(ts): if _PHYS["last_ts"] is None: _PHYS["last_ts"] = ts dt_wall = max(0.0, (ts - _PHYS["last_ts"]) / 1000.0) _PHYS["last_ts"] = ts if _PHYS["running"]: dt_wall = min(dt_wall, 0.05) sim_dt = 0.005 n_steps = max(1, int(round(dt_wall / sim_dt))) for _ in range(n_steps): step(_PHYS["balls"], sim_dt) _phys_update_overlays(sim_dt) _PHYS["elapsed"] += n_steps * sim_dt _phys_draw() _phys_update_stats() window.requestAnimationFrame(create_proxy(_phys_animate)) def _phys_on_reset(_evt=None): _phys_reset() _PHYS["running"] = True document.getElementById("btn-pause").textContent = "Pause" def _phys_on_pause(_evt=None): _PHYS["running"] = not _PHYS["running"] document.getElementById("btn-pause").textContent = ( "Resume" if not _PHYS["running"] else "Pause" ) def _phys_on_add(_evt=None): b = _phys_free_ball() if b is None: return # box too crowded for a clean spawn _PHYS["balls"].append(b) _PHYS["initial_energy"] = kinetic_energy(_PHYS["balls"]) _PHYS["elapsed"] = 0.0 def _phys_on_threshold(_evt=None): el = document.getElementById("threshold") try: value = float(el.value) except Exception: value = 0.0 physics.BUG_THRESHOLD = value label = document.getElementById("threshold-value") note = document.getElementById("threshold-note") if value <= 0: label.textContent = "off · elastic" label.className = "threshold-value" if note is not None: note.textContent = ( "Bounces slower than the threshold lose 50% of their kinetic " "energy on that axis; faster ones stay perfectly elastic." ) else: label.textContent = f"leaks below {value:.2f}" label.className = "threshold-value armed" if note is not None: note.textContent = ( f"Armed: any wall hit with |v⊥| < {value:.2f} now loses 50% of " f"its kinetic energy on that axis. Faster hits stay elastic — " f"watch the slow, grazing bounces." ) if _CANVAS is not None: document.getElementById("btn-reset").addEventListener("click", create_proxy(_phys_on_reset)) document.getElementById("btn-pause").addEventListener("click", create_proxy(_phys_on_pause)) document.getElementById("btn-add").addEventListener("click", create_proxy(_phys_on_add)) document.getElementById("threshold").addEventListener("input", create_proxy(_phys_on_threshold)) _phys_reset() _phys_on_threshold() window.requestAnimationFrame(create_proxy(_phys_animate))