Skip to Content
Course content

208: Property-Based Testing with proptest

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've all been there. You write a function, you write three or four unit tests covering the "obvious" cases, the tests pass, and you merge the PR. Then, two weeks later, a user reports a crash because they entered an emoji or a string with a length that wasn't a multiple of your chunk size.

The problem is that humans are terrible at imagining the "weird" inputs. We test the happy path and maybe one or two edge cases we remember from past traumas. Property-based testing (PBT) flips the script. Instead of picking specific inputs, you define properties—invariants that should always hold true regardless of the input—and let a tool like proptest try its hardest to break your code by throwing thousands of randomized inputs at it.

The Slicing Panic and the UTF-8 Trap

Take a look at this function. It's supposed to take a string and split it into chunks of a specific size. It looks straightforward enough:

fn chunk_string(s: &str, size: usize) -> Vec<&str> {
    let mut result = Vec::new();
    let mut start = 0;
    while start < s.len() {
        let end = start + size;
        result.push(&s[start..end]);
        start = end;
    }
    result
}

If I test this with chunk_string("hello world", 3), it works great. If I test it with "rust" and size 2, it works. I might even feel confident enough to ship it. But there are two massive bombs waiting to go off here. First, if the string length isn't perfectly divisible by size, the final &s[start..end] will attempt to slice past the end of the string, causing a panic. Second, Rust strings are UTF-8. If start or end lands in the middle of a multi-byte character (like an emoji), the program will panic immediately.

Finding the Edge Case with proptest

Instead of trying to manually brainstorm every possible weird string, we can use proptest. First, add it to your dev-dependencies. Then, we define a property: "no matter the string and no matter the size, this function should never panic, and the combined length of the chunks should equal the original length."

use proptest::prelude::*;

proptest! {
    #[test]
    fn doesnt_crash(s in ".*", size in 1..100usize) {
        chunk_string(&s, size);
    }
}

The moment I run this, proptest doesn't just tell me it failed; it performs "shrinking." It finds a massive failing input and then automatically tries to find the smallest possible input that still causes the failure. It will likely hand me something like s = "🦀" and size = 1. Since a crab emoji is 4 bytes, and I'm trying to slice at index 1, the code panics. It's a brutal, efficient way to find bugs.

Safe Slicing and Character Boundaries

To fix this, we need to stop thinking in bytes and start thinking in chars. We also need to handle the end of the string gracefully using std::cmp::min or by iterating over the characters directly. Here is the professional way to handle this:

fn chunk_string(s: &str, size: usize) -> Vec<String> {
    let mut result = Vec::new();
    let chars: Vec<char> = s.chars().collect();
    
    for chunk in chars.chunks(size) {
        let chunk_str: String = chunk.iter().collect();
        result.push(chunk_str);
    }
    result
}

I've changed the return type to Vec<String> because we are now constructing new strings from characters rather than slicing the original byte array. This completely eliminates the risk of splitting a UTF-8 character in half. Now, when we run the proptest again, it will throw thousands of random strings—null bytes, emojis, empty strings, giant blocks of text—and every single one will pass.

The real magic here isn't the fix itself, but the confidence that comes from knowing the function survived a "gauntlet" of random data. When you write a unit test, you're proving the code works for that specific input. When you write a property test, you're proving the code works for a class of inputs.




📋 Practical Task

Hardening a Custom Integer Range Splitter

You have been handed a function called split_range that is intended to take a start value, an end value, and a number of segments, returning a Vec<i32> representing the boundaries of those segments. The current implementation is buggy and panics under certain conditions (e.g., when start > end or when segments is 0).

Your Task:

  1. Implement a proptest block that attempts to crash the split_range function. Use a range of i32 for the start/end and a usize for the segments.
  2. Once proptest identifies the failing inputs (likely division by zero or subtraction overflows), rewrite the split_range function to be totally robust.
  3. The function should return an Option<Vec<i32>>: return None if the inputs are logically impossible (like segments = 0), and Some(vec) otherwise.
  4. Update your property test to ensure that for any Some result, the resulting vector always has exactly segments + 1 elements.
// The buggy starting point
fn split_range(start: i32, end: i32, segments: usize) -> Vec<i32> {
    let mut boundaries = Vec::new();
    let step = (end - start) / segments as i32;
    for i in 0..=segments {
        boundaries.push(start + (i as i32 * step));
    }
    boundaries
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.