What is property-based testing?
Property-based testing is just a generalisation of the sort of testing you’re already used to.1
If you look at your tests, they are often named after some very general claim like “a user should not be able to access project documents after they have been removed from the project”, and then when you look at what the actual test does, it instead tests some specific instance of that claim like “Alice creates a project named Kittens and then adds Bob to it and removes him immediately and Bob can no longer access the README for Kittens”.
This is often fine, but means you can miss variations that seem like they shouldn’t matter but do (e.g. what if a different user adds and removes bob? What if they have different permissions? What if Bob tries to remove himself?). Property-based testing is a way of bringing the two into line by writing tests that can actually check any instance of the general claim the test already says that it’s making.
To see how this works, you can think of a test as consisting of two parts:
- A scenario
- A set of checks
The scenario is “I did a thing”, and the checks are “here is what should have happened when I did that thing”. In our example above, the scenario is “Alice removed Bob from the Kittens project” and the check is that Bob can no longer access its README.
In classical example-based testing these are typically at about that level of specificity, but in other types of testing this can vary:
- Here is the exact thing I did, and here is the exact thing that should have happened.
- Here is the exact thing I did, and here are some things that should be true about the result.
- I did something like this, and this is the exact thing that should have happened.
- I did something like this, and here are some things that should be true about the result.
Concrete examples of each type:
- Here is a series of interactions with my web-application, and here’s what the screenshot of the final result should look like.
- Here is a series of interactions with my web-application, and every fetch should have returned a 200 or a redirect.
- I inserted three unique keys into my dictionary, and the result should be that the dictionary should have this exact structure.
- I inserted three unique keys into my dictionary, and the dictionary should now have size 3.
These four categories roughly correspond to:
- Snapshot testing (AKA golden master testing AKA expect testing)
- Example-based testing (what you probably think of as “normal software testing”)
- Differential testing (comparing two implementations of the same API and asserting that they get the same result)2
- Property-based testing (what this book is about)
None of these are truly distinct categories, and all of them are great in some contexts. This is important to remember: When learning about property-based testing, it’s easy to get excited and think all of your tests should be property-based tests. Resist that urge. Property-based tests are part of a complete breakfast test suite, not the whole of it.
An example
Suppose we have an LRU cache - a key/value store with some maximum capacity, such that when you hit the capacity you evict the least recently used key - and we want to check that it never exceeds its configured capacity. We might write the following example-based test:
#[test]
fn test_respects_lru_capacity() {
let mut cache = MyLRUCache::<String, i64>::new(2);
cache.put("a".to_string(), 1);
cache.put("b".to_string(), 2);
cache.put("c".to_string(), 3);
assert!(cache.size() <= 2);
}
This is a perfectly reasonable test, but notice the difference between what it promises and what it actually does. The claim of the test is that the cache never exceeds its capacity, but the test actually only shows that after one very specific sequence of operations the cache has not exceeded its capacity.
In contrast, we might write the following property-based test:
use hegel::generators as gs;
use hegel::TestCase;
#[hegel::test]
fn test_respects_lru_capacity(tc: TestCase) {
let capacity = tc.draw(gs::integers::<usize>().min_value(0));
let mut cache = MyLRUCache::<String, i64>::new(capacity);
let entries = tc.draw(
gs::vecs(gs::tuples!(gs::text(), gs::integers::<i64>()))
);
for (key, value) in entries {
cache.put(key, value);
}
assert!(cache.size() <= capacity);
}
This test generates a fully general series of put operations against the cache, and asserts that the capacity is still respected at the end.
Now, this still doesn’t actually guarantee that the cache never exceeds its capacity. For starters (more on this problem later), this is only performing put operations. More importantly though, although the space this test applies to is logically infinite, in practice this test will run some number of test cases, mostly small, and will pass if each of those test cases pass.
To learn more about what actually happens when you run this test, read Lifecycle of a Property-Based Test
-
This is not how it’s usually presented though. Property-based testing is often sold as “lightweight formal verification” and as if it was a very different thing from normal testing and there were these profound mathematical properties of your software that you pluck out of the platonic realm and place within your test suite. I think this is an extremely unhelpful way to look at it. ↩
-
Arguably differential testing is a type of property-based testing, and certainly you can and sometimes should use a property-based testing library to do differential testing, but it’s a bit of a special category. ↩