DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

What if your tests could think of edge cases for you?

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.

whack-a-mole

One implementation with example-based tests

Let's see an example first. 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
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

These tests pass:

$ uv run test_example_based.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.000s

OK
Enter fullscreen mode Exit fullscreen mode

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"
        )
Enter fullscreen mode Exit fullscreen mode

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 hyphens
  • test_slug_has_no_consecutive_hyphens -- obviously, my--post looks bad in URLs
  • test_slug_is_lowercase -- standard expectation for URL slugs
  • test_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)
Enter fullscreen mode Exit fullscreen mode

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.


Thanks for reading Bit Maybe Wise! If this post passed your quality properties test, subscribe for more.

Top comments (0)