What if your tests could think of edge cases for you?
Ever feel smug about your passing tests, only to have reality humble you with a single weird input?
Here's a scenario most of us know well: You write a function, add some tests that cover the cases you can think of, see those green checkmarks, and ship it. Everything looks good until a user throws something unexpected at your code.
Sound familiar? I thought so.
This post explores how property-based testing lets you stop playing whack-a-mole with bugs and start describing what 'good' looks like — then watch a computer systematically find all the creative ways to break your assumptions. Spoiler: it's really good at it.
One implementation with example-based tests
Let's see an example. I will use Python because the syntax is clean, almost pseudo-code, but you don't need to know Python to keep reading.
Let's say we implemented the following function:
import re
def create_slug(title):
"""
Convert a blog post title into a URL-friendly slug.
Business rules:
- Convert to lowercase
- Replace spaces and special chars with hyphens
- Remove multiple consecutive hyphens
- Remove leading/trailing hyphens
Examples:
create_slug("Hello World") -> "hello-world"
create_slug("My Great Post!") -> "my-great-post"
"""
if not title:
return ""
result = title.lower()
# Replace non-alphanumeric chars with hyphens
result = re.sub(r"[^a-z0-9]+", "-", result)
# Remove multiple consecutive hyphens
result = re.sub(r"-+", "-", result)
return result
The tests probably look something like this:
import unittest
class TestSlugGeneration(unittest.TestCase):
def test_basic_conversion(self):
"""Test basic title to slug conversion"""
self.assertEqual(create_slug("Hello World"), "hello-world")
self.assertEqual(create_slug("My Blog Post"), "my-blog-post")
def test_special_characters(self):
"""Test handling of special characters"""
self.assertEqual(create_slug("Hello, World"), "hello-world")
self.assertEqual(create_slug("Post #1: The Beginning"), "post-1-the-beginning")
def test_multiple_spaces(self):
"""Test multiple spaces get converted to single hyphen"""
self.assertEqual(create_slug("Hello World"), "hello-world")
self.assertEqual(create_slug("My Great Post"), "my-great-post")
def test_mixed_special_chars(self):
"""Test various special characters"""
self.assertEqual(create_slug("Hello@#$%World"), "hello-world")
self.assertEqual(create_slug("Test & Debug"), "test-debug")
def test_numbers_preserved(self):
"""Test that numbers are preserved"""
self.assertEqual(create_slug("Top 10 Tips"), "top-10-tips")
self.assertEqual(create_slug("Version 2.0 Release"), "version-2-0-release")
def test_case_conversion(self):
"""Test uppercase conversion"""
self.assertEqual(create_slug("HELLO WORLD"), "hello-world")
self.assertEqual(create_slug("CamelCase Title"), "camelcase-title")
def test_empty_string(self):
"""Test empty input"""
self.assertEqual(create_slug(""), "")
self.assertEqual(create_slug(None), "")
def test_only_alphanumeric(self):
"""Test strings that are already clean"""
self.assertEqual(create_slug("alreadyclean"), "alreadyclean")
self.assertEqual(create_slug("hello world"), "hello-world")
if __name__ == "__main__":
example_suite = unittest.TestLoader().loadTestsFromTestCase(TestSlugGeneration)
unittest.TextTestRunner(verbosity=1).run(example_suite)
These tests pass:
$ uv run test_example_based.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.000s
OK
You ship your code, and then a user reports a bug with create_slug("!Hello World")
, that leads to the result string -hello-world
(leading hyphen). Oops!
The problem isn't that you're a bad programmer -- it's that with traditional example-based testing, you can only test the cases you think of. And we (humans) are notoriously bad at thinking of edge cases.
Enter property-based testing
Property-based testing flips the script. Instead of writing specific examples, you describe properties that should always be true, and let the computer generate hundreds of test cases for you.
Let's use the hypothesis library to rewrite our examples as property-based tests:
import unittest
import re
from hypothesis import given, strategies as st, assume
class TestSlugGenerationProperties(unittest.TestCase):
@given(st.text())
def test_slug_has_no_leading_trailing_hyphens(self, title):
"""A good slug should never start or end with hyphens"""
slug = create_slug(title)
if slug: # Only check non-empty slugs
self.assertFalse(
slug.startswith("-"),
f"Slug '{slug}' starts with hyphen (from title: '{title}')",
)
self.assertFalse(
slug.endswith("-"),
f"Slug '{slug}' ends with hyphen (from title: '{title}')",
)
@given(st.text())
def test_slug_has_no_consecutive_hyphens(self, title):
"""A good slug should never have consecutive hyphens"""
slug = create_slug(title)
self.assertNotIn(
"--", slug, f"Slug '{slug}' has consecutive hyphens (from title: '{title}')"
)
@given(st.text())
def test_slug_is_lowercase(self, title):
"""All slugs should be lowercase"""
slug = create_slug(title)
self.assertEqual(
slug,
slug.lower(),
f"Slug '{slug}' is not lowercase (from title: '{title}')",
)
@given(st.text())
def test_slug_contains_only_valid_chars(self, title):
"""Slugs should only contain lowercase letters, numbers, and hyphens"""
slug = create_slug(title)
valid_pattern = re.compile(r"^[a-z0-9-]*$")
self.assertTrue(
valid_pattern.match(slug),
f"Slug '{slug}' contains invalid characters (from title: '{title}')",
)
@given(st.text())
def test_empty_title_gives_empty_slug(self, title):
"""Empty or whitespace-only titles should give empty slugs"""
if not title or title.isspace():
slug = create_slug(title)
self.assertEqual(
slug,
"",
f"Empty/whitespace title '{title}' should give empty slug, got '{slug}'",
)
@given(st.text(min_size=1))
def test_non_empty_title_with_valid_chars_gives_non_empty_slug(self, title):
"""Titles with at least one alphanumeric char should give non-empty slug"""
assume(any(c.isalnum() for c in title)) # At least one letter/number
slug = create_slug(title)
self.assertNotEqual(
slug, "", f"Title '{title}' with valid chars should give non-empty slug"
)
The property-based tests express natural expectations about what makes a good URL slug:
test_slug_has_no_leading_trailing_hyphens
-- we know slugs shouldn't start/end with hyphenstest_slug_has_no_consecutive_hyphens
-- obviously,my--post
looks bad in URLstest_slug_is_lowercase
-- standard expectation for URL slugstest_slug_contains_only_valid_chars
-- basic requirement for URL safety
The hypothesis library generates random inputs for us -- 100 of them by default, and you can adjust this parameter at your own convenience. It can be pretty flexible and customizable if you want, and not just bland boring values, but I won't dive deeper in this post, sorry.
Now let's run it:
$ uv run test_property_based.py
F...F.
======================================================================
FAIL: test_empty_title_gives_empty_slug (__main__.TestSlugGenerationProperties.test_empty_title_gives_empty_slug)
Empty or whitespace-only titles should give empty slugs
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/test_property_based.py", line 82, in test_empty_title_gives_empty_slug
def test_empty_title_gives_empty_slug(self, title):
^^^^^^^
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/.venv/lib/python3.12/site-packages/hypothesis/core.py", line 2027, in wrapped_test
raise the_error_hypothesis_found
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/test_property_based.py", line 86, in test_empty_title_gives_empty_slug
self.assertEqual(
AssertionError: '-' != ''
- -
' should give empty slug, got '-'
Falsifying example: test_empty_title_gives_empty_slug(
self=<__main__.TestSlugGenerationProperties testMethod=test_empty_title_gives_empty_slug>,
title='\r',
)
======================================================================
FAIL: test_slug_has_no_leading_trailing_hyphens (__main__.TestSlugGenerationProperties.test_slug_has_no_leading_trailing_hyphens)
A good slug should never start or end with hyphens
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/test_property_based.py", line 39, in test_slug_has_no_leading_trailing_hyphens
def test_slug_has_no_leading_trailing_hyphens(self, title):
^^^^^^^
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/.venv/lib/python3.12/site-packages/hypothesis/core.py", line 2027, in wrapped_test
raise the_error_hypothesis_found
File "/Users/hercules/BitMaybeWise/gitlab/python-playground/property-based-testing/test_property_based.py", line 43, in test_slug_has_no_leading_trailing_hyphens
self.assertFalse(
AssertionError: True is not false : Slug '-' starts with hyphen (from title: ':')
Falsifying example: test_slug_has_no_leading_trailing_hyphens(
self=<__main__.TestSlugGenerationProperties testMethod=test_slug_has_no_leading_trailing_hyphens>,
title=':',
)
----------------------------------------------------------------------
Ran 6 tests in 0.184s
FAILED (failures=2)
There we have it! Errors smashing in our faces.
None of these require knowing about the specific bug! They're just expressing what "good slug" means, and the bugs emerge naturally. Properties are great to catch these kind of things without us anticipating them.
Conclusion
With properties, you write what you want (good slugs), not what you fear (specific bugs). Hypothesis (or whatever property-based library) finds the problematic inputs by exploring the problem space systematically.
After you learn about property-based testing, you can't live without it.