Caches are small, stateful and easy to get subtly wrong. An LRU cache has to update recency on reads as well as writes, evict exactly the least recently used entry when full, handle overwriting an existing key without evicting anything, and keep its size bookkeeping consistent through deletes. Each of those is simple in isolation. Bugs live in sequences: a get that forgets to refresh recency only matters if a put later triggers an eviction; a delete that leaves a stale entry in the recency list only matters when that key is re-inserted at capacity.
Example tests check the sequences someone thought to write. A Hypothesis RuleBasedStateMachine generates the sequences, runs them against the real cache and a deliberately simple model at the same time, and checks after every step that the two agree. When they diverge, Hypothesis shrinks the sequence to the shortest series of operations that reproduces the difference.
Prerequisites
hypothesis >= 6.100,pytest >= 8.0.- The state machine basics from Stateful and model-based testing.
Solution
# test_lru_machine.py
from hypothesis import strategies as st
from hypothesis.stateful import Bundle, RuleBasedStateMachine, consumes, invariant, rule
from myapp.cache import LRUCache
CAPACITY = 3
keys_st = st.text(alphabet="abcdef", min_size=1, max_size=2)
values_st = st.integers()
class LRUMachine(RuleBasedStateMachine):
keys = Bundle("keys")
def __init__(self):
super().__init__()
self.cache = LRUCache(capacity=CAPACITY)
self.model: dict[str, int] = {}
self.order: list[str] = [] # least recent first
def _touch(self, k):
if k in self.order:
self.order.remove(k)
self.order.append(k)
@rule(target=keys, k=keys_st, v=values_st)
def put(self, k, v):
self.cache.put(k, v)
if k not in self.model and len(self.model) == CAPACITY:
evicted = self.order.pop(0)
del self.model[evicted]
self.model[k] = v
self._touch(k)
return k
@rule(k=keys)
def get_known(self, k):
expected = self.model.get(k)
assert self.cache.get(k) == expected
if expected is not None:
self._touch(k)
@rule(k=keys_st)
def get_arbitrary(self, k):
expected = self.model.get(k)
assert self.cache.get(k) == expected
if expected is not None:
self._touch(k)
@rule(k=consumes(keys))
def delete(self, k):
self.cache.delete(k)
self.model.pop(k, None)
if k in self.order:
self.order.remove(k)
@invariant()
def size_within_capacity(self):
assert len(self.cache) <= CAPACITY
@invariant()
def contents_match_model(self):
assert dict(self.cache.items()) == self.model
TestLRU = LRUMachine.TestCase
Why this works
The model is small enough to be obviously right: a dict for contents and a list for recency, with eviction taking the head of the list. It would be far too slow for production, which is exactly why nobody would have "optimised" it into bugs. The real cache — a dict plus a doubly linked list, or an OrderedDict with move_to_end — is compared against it after every operation, so any divergence is caught at the step that caused it rather than many steps later.
The two invariants divide the checking. size_within_capacity catches bookkeeping bugs even when contents happen to look right. contents_match_model catches eviction of the wrong key, stale values after overwrite, and entries that survive a delete. Because invariants run after every rule, a bug introduced by any operation is detected immediately, and the shrunk counterexample ends at the step that broke the state.
Bundles: operating on keys that exist
Without the keys Bundle, get and delete would draw arbitrary keys from keys_st. With a small alphabet that still works sometimes, but most gets would miss and most deletes would be no-ops, so the interesting paths — reading an existing entry, which refreshes recency — would be exercised rarely. The Bundle fixes the distribution: put returns the key and targets it into keys, and get_known draws only from keys that were inserted at some point.
consumes(keys) on delete removes the drawn key from the Bundle, so later steps do not keep drawing a key the test knows is gone. Keeping get_arbitrary alongside get_known preserves coverage of misses, including keys that were evicted — which are still in the Bundle, because eviction happens inside the cache, not through a rule. That mix is deliberate: get_known on an evicted key is one of the most valuable checks the machine makes, since it asks "does the cache agree this key is gone?".
Bugs this machine finds
Three classic LRU bugs, and the shrunk sequences Hypothesis reports for them:
- Reads do not refresh recency.
put(a) put(b) put(c) get_known(a) put(d) get_known(a)— the model evictedb, the cache evicteda. Four puts and two gets, and the cause is visible at a glance. - Overwrite evicts.
put(a) put(b) put(c) put(a)at capacity three — the cache evictedbto make room for a key it already held.size_within_capacitypasses;contents_match_modelfails. - Delete leaves a ghost in the recency list.
put(a) delete(a) put(b) put(c) put(d) put(e)— the cache's linked list still helda, eviction removed the ghost instead ofb, and size went to four.size_within_capacityfails.
Each shrinks to a sequence under ten steps, even when the first failing run contained fifty. That is the practical difference between this approach and a hand-written sequence test: the machine found the sequence, and shrinking made it readable. See shrinking long rule sequences for how to help shrinking when it does not get this far on its own.
Extending the machine to a TTL cache
Many production caches evict by age as well as by size, and time is where cache bugs become hardest to reproduce by hand. The state machine extends cleanly, provided time is something the test controls rather than something that passes.
Inject a clock into the cache — a callable returning the current time — and give the machine its own counter. Add one rule, advance(seconds), that moves the counter forward by a generated amount, from zero up to somewhat more than the TTL. The model records the insertion time for each key and treats an entry as absent once now - inserted >= ttl. Everything else stays the same: get_known asks both implementations for the key, and contents_match_model compares the live entries.
The interesting sequences now mix size and age. A key inserted just before capacity is reached and read just before its TTL expires should survive one eviction and then disappear on its own. An overwrite should reset the timer — or should it? That question is exactly what the machine forces into the open: the model has to choose, and once the choice is written down in the model, the real cache must match it. Teams often discover that two parts of their codebase assumed different answers.
Two practical details matter. First, keep the time values integers or exact fractions, because comparing float timestamps at the boundary makes the model and the cache disagree on rounding rather than on behaviour. Second, include advance(0) and advance(ttl) as explicit possibilities with st.sampled_from mixed into the duration strategy, because the boundary — an entry read at exactly its expiry time — is where off-by-one errors in > versus >= hide. A random duration strategy alone would land on the exact boundary only occasionally; sampling it explicitly means every run of the machine exercises it several times, and a comparison bug surfaces on the first run after it is introduced rather than weeks later.
> that should have been >=.Edge cases and failure modes
- Model with the same bug. If the model evicts on overwrite too, the machine agrees with a buggy cache. Keep the model naive and review it against the specification, not the implementation.
- Key space too large. With
st.text()unbounded, arbitrary keys never collide and overwrite paths are never exercised. A small alphabet makes collisions common. - Capacity too large. At capacity 100, eviction needs 101 distinct puts and rarely happens within the default step count. Test with small capacities; the logic is the same.
- TTL caches. Time-based expiry needs a controllable clock injected into both cache and model, with an
advance_timerule. - Thread-safe caches. A state machine tests sequential semantics. Concurrency needs separate tools; the machine still guards the single-threaded logic.
Frequently Asked Questions
What is a Bundle in a Hypothesis state machine?
A Bundle is a named collection of values produced by earlier rules. Rules can add to it with target= and draw from it as an argument, which lets later steps operate on keys or objects that earlier steps actually created instead of on random values that almost never exist.
What is the difference between an invariant and a rule assertion?
A rule assertion checks the result of one operation. An @invariant runs after every step and checks a property of the whole state, such as the cache never exceeding capacity, so it catches corruption introduced by any operation.
How simple should the model be? As simple as possible while still predicting observable behaviour. For an LRU cache, a dict plus a list recording recency order is enough. If the model becomes as complex as the implementation, bugs can be copied into both.
Related
- Stateful and Model-Based Testing — RuleBasedStateMachine fundamentals.
- Debugging RuleBasedStateMachine Failures — reading step output.
- Shrinking Long Rule Sequences into Readable Repros — when shrinking stalls.
- Modeling REST APIs as State Machines — the same technique over HTTP.
← Back to Stateful and Model-Based Testing