Property Testing (Lock Up, Open Up, Nothing Moved)
The School Caretaker
PyStack is the hallways of a school: it connects the filing cabinets (PyDB), the lockers (PyKV), the library index (PySearch) and every other room. Every evening the school is locked up, and every morning it's opened again. Whatever a pupil left in their locker yesterday should still be there today. That's PyStack's save and load: shut down, start up, nothing moved.
How would you check that? The usual way: put one book in one locker, lock up, open up, and see that the book is still there. That's an example test. It's useful, but it only tells you about the one book you thought of.
Now imagine a caretaker. Every night they fill lockers with thousands of different things -- a book, a sock, a note written in Greek, an empty envelope, a locker with no name on the door -- and every morning they check a single rule:
Whatever was left in the lockers last night is there this morning, exactly as it was.
That rule is a property. It doesn't say what any one locker holds. It says something that must be true for every locker.
When the caretaker finds a locker that breaks the rule, they don't just report "something went missing on the second floor". They try again with less and less -- fewer lockers, smaller things, shorter names -- checking it still goes wrong each time, until it's as simple as it can be. That boiling-down is called shrinking, and it's the best part: the smallest failing input usually points straight at the bug.
In Python, the caretaker is a library called
Hypothesis. PyStack's property tests
live in tests/test_properties.py, next to the ordinary example tests, and
the model check (more on that below) is in tests/test_model.py.
A Property from PyStack
The kv plugin lets a Pebble program keep text under a key with
kv_set("name", "Ada") and read it back with kv_get("name"). Here's the
property that says it survives a restart:
def kv_keys() -> st.SearchStrategy[str]:
"""Make any key at all: empty, spaces, emoji, accents, quotes, the lot."""
return st.text()
def kv_values() -> st.SearchStrategy[str]:
"""Make any text a Pebble program might store with ``kv_set``."""
# The kv plugin keeps every value as text (``kv_set`` calls ``str`` on
# it), so text is what a program gets back -- see kv_plugin.py.
return st.text()
def kv_contents() -> st.SearchStrategy[dict[str, str]]:
"""Make everything one kv store might hold: any keys, each with any text."""
return st.dictionaries(kv_keys(), kv_values())
@given(contents=kv_contents())
def test_kv_data_survives_a_restart(contents: dict[str, str]) -> None:
"""Whatever a program stores with ``kv_set``, the next run reads back exactly."""
with tempfile.TemporaryDirectory() as folder:
first = PyStackEnvironment(db_path=Path(folder))
for key, value in contents.items():
call_pebble(first, "kv", "kv_set", key, value)
first.shutdown()
second = PyStackEnvironment(db_path=Path(folder))
try:
listed = call_pebble(second, "kv", "kv_keys")
assert isinstance(listed, list)
assert sorted(map(str, listed)) == sorted(contents)
for key, value in contents.items():
assert call_pebble(second, "kv", "kv_get", key) == value
finally:
second.shutdown()
Line by line:
kv_keys(),kv_values()andkv_contents()are strategies: recipes for making random inputs.st.text()makes any string at all, including the empty string," ","🍕"and"Zoë's \"key\"".kv_contents()makes a whole dictionary of keys and values. The first two live intests/strategies.py, because the model check uses them too.@given(contents=kv_contents())tells Hypothesis to run the test many times (100 in a normal run), each time with a freshcontents.- The docstring says the rule in plain English. If you can't say the rule in one sentence, it probably isn't a good property yet.
tempfile.TemporaryDirectory()gives each run its own empty data folder, like a brand-newpystack_data/, deleted afterwards.first = PyStackEnvironment(...)starts the whole of PyStack: the database, every plugin, the lot. That's the school opening for the day.call_pebble(first, "kv", "kv_set", key, value)calls thekv_setfunction exactly the way a Pebble program'skv_set(key, value)does. (Writing real Pebble source for every random key would mean escaping quotes and newlines; this skips straight to the function Pebble calls.)first.shutdown()locks up: it saves every plugin into the folder.second = PyStackEnvironment(...)is the next morning: a brand-new PyStack on the same folder, with nothing carried over except the files.- The
asserts are the rule itself: the same keys are listed (sorted, so the order they happen to come out in doesn't matter), and every key gives back exactly its text. This kind of property is called a round-trip. finally: second.shutdown()tidies up even if anassertfails.
Notice what the test doesn't say. It never says "store name -> Ada
and you get Ada". It doesn't need to know any answers. It only needs a
rule that's always true.
A "starts without crashing" check on its own is passed by a plugin that does nothing, because ignoring every file never crashes. That's why the two "odd files" properties below also check what PyStack does next, and why this round-trip sits beside them: a plugin that never saves fails it the moment there is one key to read back. The other properties check different corners of PyStack:
| Rule | Test |
|---|---|
| Every value a table holds reaches Pebble as a plain Pebble value, and maps back exactly | test_every_pydb_value_reaches_pebble_and_maps_back |
| Whatever the saved engine files are called, PyStack starts and lists its own, or refuses cleanly | test_any_engine_file_name_starts_or_raises_persistence_error |
| Whatever ids the saved edges have, PyStack starts and a new edge gets an id nobody has | test_new_edges_never_reuse_a_saved_edge_id |
The first one checks the bridge between PyDB and Pebble: Pebble has no
date type, so a DATE or TIMESTAMP crosses as ISO text like 2026-10-17,
and the property checks, with Python's own fromisoformat, that the text
turns back into exactly the date it came from. The other two put odd files in the data folder, the way a curious
pupil (or a crashed editor) might, and both of them found real bugs.
Checking Against a Model
A property checks one action. But the lockers change step by step: set a key, change it, delete it, read it, lock up for the night, open up again. Bugs often hide in the order of steps -- deleting a key that was never set, or deleting one and then restarting.
So the caretaker has a second trick. They carry a notebook and write down every locker and what's in it, by hand. After every single step, they compare the lockers with the notebook. If the lockers ever disagree with the notebook, something's wrong with the school. The notebook doesn't care about nights: it just carries on.
That notebook is a model: something simple that we already trust. For
the kv plugin, the model is a plain Python dict. Here's the heart of
the check in tests/test_model.py:
class KVMachine(RuleBasedStateMachine):
"""Play random sets, deletes, reads and restarts on the kv plugin and a dict side by side."""
keys = Bundle("keys")
def __init__(self) -> None:
"""Start PyStack on an empty data folder, and an empty dict."""
super().__init__()
self.folder = Path(tempfile.mkdtemp())
self.env = PyStackEnvironment(db_path=self.folder)
self.model: dict[str, str] = {}
@rule(target=keys, key=kv_keys(), value=kv_values())
def set_new_key(self, key: str, value: str) -> str:
"""Set a fresh key in both, and remember it for later steps."""
assert self.kv("kv_set", key, value) == "ok"
self.model[key] = value
return key
@rule(key=st.one_of(keys, kv_keys()))
def delete_key(self, key: str) -> None:
"""Delete a key from both: "ok" if it was there, an error message if not."""
expected = "ok" if key in self.model else f"error: key '{key}' not found"
self.model.pop(key, None)
assert self.kv("kv_delete", key) == expected
@rule()
def restart(self) -> None:
self.env.shutdown()
self.env = PyStackEnvironment(db_path=self.folder)
@invariant()
def same_keys_and_values(self) -> None:
"""After every step, kv holds exactly the dict's keys and values."""
listed = self.kv("kv_keys")
assert sorted(map(str, listed)) == sorted(self.model)
assert all(self.kv("kv_get", key) == value for key, value in self.model.items())
TestKVModel = cast("type[unittest.TestCase]", KVMachine.TestCase)
Piece by piece:
RuleBasedStateMachineis Hypothesis's tool for testing things that change step by step. Hypothesis makes up a random story of steps and plays it.self.envandself.modelare the school and the notebook. Every story starts with an empty data folder and an empty dict.- Each
@ruleis one kind of step. It does the same thing to PyStack and to the dict. The real file has rules to set a new key and overwrite one (from Pebble and from thekv-setshell command), delete (both ways), read (both ways) and restart.self.kv(...)iscall_pebblefor thekvmodule. keys = Bundle("keys")is a bag of keys used earlier in the story. Two random strings almost never match, so without it the story would hardly ever change or delete a key that is really there.set_new_keyputs its key in the bag (target=keys).delete_keypicks either a key from the bag or a brand-new one, so it tries deleting keys that exist and keys that don't, and it checks whatkv_deletesays as well.restartis the night: lock up, open up. The dict does nothing, because a restart should change nothing.- The
@invariant()runs after every single step and compares everything: which keys there are, and every value. The real one also checks that thekv-keysshell command lists the same keys, and that the listkv_keyshands back is the caller's own copy. TestKVModel = ...TestCaseturns the machine into a test that pytest can find and run. One run plays 100 random stories, each up to 20 steps long (Hypothesis's default is 50, which finds the same bugs but takes most of the run time).
The dict is a good model because it's obviously right and it's not
the plugin's code. If we built the model by copying KVPlugin, any bug
would be in both, and they'd agree perfectly about the wrong answer.
Try It Yourself
Let's break saving on purpose and watch the caretaker find it.
Open src/pystack/plugins/kv_plugin.py, find save_state, and change its
first line, as if you'd decided "why write a file for an empty store?":
Now an empty store is never saved. Run the property tests:
Hypothesis reports this (trimmed to the interesting part):
@invariant()
def same_keys_and_values(self) -> None:
"""After every step, kv holds exactly the dict's keys and values."""
listed = self.kv("kv_keys")
assert isinstance(listed, list)
> assert sorted(map(str, listed)) == sorted(self.model)
E AssertionError: assert [''] == []
E
E Left contains one more item: ''
E Failing test case: <state machine>
E state = KVMachine()
E state.same_keys_and_values()
E keys_0 = state.set_new_key(key='', value='')
E state.same_keys_and_values()
E state.restart()
E state.same_keys_and_values()
E state.delete_key(key=keys_0)
E state.same_keys_and_values()
E state.restart()
E state.same_keys_and_values()
E state.teardown()
Read the story at the bottom like a script. Set the key ''. Lock up and
open up (the key is saved). Delete it: the store is now empty. Lock up and
open up again. The empty store wasn't saved, so yesterday's file is still
on disk, and the deleted key comes back from the dead. The notebook
knows it was deleted.
The example tests all still pass. Only the property notices. None of them deletes a key and then restarts, and nobody would think to write that test until the bug had already bitten. The round-trip property stays green too: it never deletes anything. It took the model check's random stories, with restarts in the middle, to find it -- and Hypothesis shrank dozens of steps down to these four.
Put the line back to if self._store is None: and run the tests again.
They pass.
Older Hypothesis versions
Older versions of Hypothesis print Falsifying example: instead of
Failing test case:. It means the same thing.
A Bug We Found
When we first wrote
test_any_engine_file_name_starts_or_raises_persistence_error, it failed
on PyStack's real code -- no deliberate bug needed:
E ValueError: invalid literal for int() with base 10: ''
E Failing test case: test_any_engine_file_name_starts_or_raises_persistence_error(
E stems=['engine_'],
E )
A saved search engine called engine_.json stopped PyStack from starting
at all.
Here's why. Search engines get ids like engine_0 and engine_1, saved
as engine_0.json and so on. On start-up the plugin loads every file
matching engine_*.json, then works out the next id by reading the number
off each name with int(...). But * matches anything, including nothing,
so engine_.json (or engine_old.json) is loaded too, and int("")
crashes with a ValueError.
The example tests only ever used files PyStack wrote itself, so every name
had a number. The caretaker didn't need to think of a bad name: the
strategy makes names like engine_x all the time.
The fix: only files named like the ids the plugin makes are engines. Anything else is left alone, like any other stray file in the folder, and start-up logs a warning naming each file it skipped, so nothing goes missing silently:
for path in sorted(folder.iterdir()):
if path.suffix == ".json" and _is_engine_id(path.stem): # was: every engine_*.json
self._engines[path.stem] = load_engine(path)
else:
_log.warning("search: skipping %s, which isn't a saved engine", path.name)
The shrunk input became an ordinary example test in
tests/test_plugin_persistence.py:
def test_engine_file_without_a_number_is_left_alone(self, tmp_path: Path) -> None:
"""``engine_.json`` isn't an engine PyStack saved: start-up skips it and leaves it be."""
folder = tmp_path / "search"
folder.mkdir()
stray = folder / "engine_.json"
save_engine(SearchEngine(), stray)
env = PyStackEnvironment(db_path=tmp_path)
try:
assert _run(env, "search", "print(search_list())") == "[]\n"
assert _run(env, "search", "print(search_create())") == "engine_0\n"
finally:
env.shutdown()
assert stray.exists()
Its twin found the same kind of bug in graphdb: a saved edge called e²
crashed start-up, because Python's isdigit() says "²" is a digit but
int("²") can't read it. (Then a reviewer asked about very long ids:
int() refuses more than 4,300 digits, so an edge called e111...1 with
5,000 ones crashed too. Now the strategy makes those as well. One digit
short is still a trap: after e plus 4,300 nines the next id has 4,301
digits, which Python can't write down, so ids that long don't count
towards the next number, and the counter skips any id the graph already
has.) And the model check found that deleting a key
that was never set answered "ok": PyKV's delete returns False for a
missing key, but the plugin was waiting for an error that never came.
Run It Yourself
Run just the property tests, and the model check (100 examples each, the same every time):
The --no-cov turns off the coverage check, which expects the whole
test suite to run and would fail on two files.
Run them the long way (2,000 random examples each, different every run) to hunt for rarer bugs. It takes about 3 minutes:
The normal run uses a fixed seed, so it tries the same inputs every time
and a passing build means the same thing on every computer. The thorough
run is random, so each time you run it the caretaker fills the lockers
with a brand-new pile of things. Both settings live in conftest.py at
the top of the project.
Your Turn
Write one new property for PyStack. Two environments on two different
data folders should never see each other's lockers. What's the strategy
that fills the first one's kv store? What's the assert you make on the
second one? Run it and watch it pass. Then break it on purpose -- make
KVPlugin keep its store in a module-level variable, shared by every
plugin -- and watch the caretaker find it.