+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 209 of 365

๐Ÿ“˜ Property-Based Testing: Hypothesis Framework

Master property-based testing: hypothesis framework in Python with practical examples, best practices, and real-world applications ๐Ÿš€

๐Ÿš€Intermediate
30 min read

Prerequisites

  • Basic understanding of programming concepts ๐Ÿ“
  • Python installation (3.8+) ๐Ÿ
  • VS Code or preferred IDE ๐Ÿ’ป

What you'll learn

  • Understand the concept fundamentals ๐ŸŽฏ
  • Apply the concept in real projects ๐Ÿ—๏ธ
  • Debug common issues ๐Ÿ›
  • Write clean, Pythonic code โœจ

๐ŸŽฏ Introduction

Welcome to the exciting world of property-based testing with Hypothesis! ๐ŸŽ‰ Ever felt like youโ€™re only testing the cases you can think of? What about the ones you canโ€™t? Thatโ€™s where property-based testing comes to the rescue! ๐Ÿฆธโ€โ™‚๏ธ

Imagine youโ€™re a quality inspector at a toy factory ๐Ÿญ. Instead of checking just a few specific toys, property-based testing is like having a magical robot that creates thousands of random toys and checks if they all follow the safety rules!

By the end of this tutorial, youโ€™ll be writing tests that find bugs you never knew existed. Letโ€™s transform your testing game! ๐Ÿš€

๐Ÿ“š Understanding Property-Based Testing

๐Ÿค” What is Property-Based Testing?

Property-based testing is like having a super-smart testing assistant ๐Ÿค– that generates hundreds of test cases automatically. Instead of writing specific test examples, you describe the properties your code should have, and Hypothesis creates the test data for you!

Think of it as the difference between:

  • Traditional testing: โ€œCheck if 2 + 2 = 4โ€ ๐Ÿ”ข
  • Property-based testing: โ€œCheck if addition always gives the same result regardless of orderโ€ ๐Ÿ”„

In Python terms, Hypothesis generates random inputs and verifies that certain properties always hold true. This means you can:

  • โœจ Find edge cases you never thought of
  • ๐Ÿš€ Test with thousands of inputs automatically
  • ๐Ÿ›ก๏ธ Catch subtle bugs before they reach production

๐Ÿ’ก Why Use Hypothesis?

Hereโ€™s why developers love property-based testing:

  1. Automatic Test Generation ๐ŸŽฒ: No more thinking up test cases
  2. Edge Case Discovery ๐Ÿ”: Finds the weird inputs that break your code
  3. Shrinking ๐Ÿ”ฌ: Automatically simplifies failing cases
  4. Reproducible ๐Ÿ“Œ: Failing tests can be replayed exactly

Real-world example: Imagine testing a shopping cart ๐Ÿ›’. Instead of manually testing with 1, 2, or 10 items, Hypothesis could test with 0, 1, 1000, negative numbers, and find that your cart breaks with exactly 256 items!

๐Ÿ”ง Basic Syntax and Usage

๐Ÿ“ Installation and Setup

First, letโ€™s install Hypothesis:

# ๐Ÿš€ Install hypothesis
pip install hypothesis

# ๐Ÿ“ฆ Import what we need
from hypothesis import given, strategies as st
import hypothesis

๐ŸŽฏ Your First Property-Based Test

Letโ€™s start with a simple example:

# ๐Ÿ‘‹ Hello, Hypothesis!
from hypothesis import given
import hypothesis.strategies as st

# ๐ŸŽจ Our function to test
def reverse_string(s):
    return s[::-1]

# ๐Ÿงช Property-based test
@given(st.text())
def test_reverse_twice_returns_original(s):
    """Reversing a string twice should return the original! ๐Ÿ”„"""
    assert reverse_string(reverse_string(s)) == s
    
# ๐ŸŽฎ Run the test
if __name__ == "__main__":
    test_reverse_twice_returns_original()
    print("โœ… All tests passed!")

๐Ÿ’ก Explanation: The @given decorator tells Hypothesis to generate random text strings. It then checks our property: reversing twice returns the original!

๐ŸŽฏ Common Strategies

Here are the strategies youโ€™ll use daily:

# ๐Ÿ—๏ธ Basic strategies
@given(st.integers())  # ๐Ÿ”ข Generate integers
def test_with_integers(n):
    assert isinstance(n, int)

@given(st.floats(allow_nan=False))  # ๐ŸŽฒ Generate floats
def test_with_floats(f):
    assert isinstance(f, float)

@given(st.text(min_size=1))  # ๐Ÿ“ Generate non-empty text
def test_with_text(s):
    assert len(s) >= 1

@given(st.lists(st.integers()))  # ๐Ÿ“‹ Generate lists of integers
def test_with_lists(lst):
    assert isinstance(lst, list)

# ๐ŸŽจ Composite strategies
@given(st.dictionaries(
    keys=st.text(min_size=1),
    values=st.integers()
))  # ๐Ÿ—‚๏ธ Generate dictionaries
def test_with_dicts(d):
    assert isinstance(d, dict)

๐Ÿ’ก Practical Examples

๐Ÿ›’ Example 1: Shopping Cart Calculator

Letโ€™s test a shopping cart with property-based testing:

# ๐Ÿ›๏ธ Shopping cart implementation
class ShoppingCart:
    def __init__(self):
        self.items = []  # ๐Ÿ“ฆ Items in cart
        
    def add_item(self, name, price, quantity):
        """Add item to cart ๐Ÿ›’"""
        if price < 0 or quantity < 0:
            raise ValueError("Price and quantity must be positive! ๐Ÿ’ฐ")
        self.items.append({
            'name': name,
            'price': price,
            'quantity': quantity,
            'emoji': '๐Ÿ›๏ธ'
        })
    
    def get_total(self):
        """Calculate total price ๐Ÿ’ต"""
        return sum(item['price'] * item['quantity'] for item in self.items)
    
    def apply_discount(self, percentage):
        """Apply percentage discount ๐Ÿท๏ธ"""
        if not 0 <= percentage <= 100:
            raise ValueError("Discount must be between 0 and 100! ๐Ÿšซ")
        discount_factor = 1 - (percentage / 100)
        return self.get_total() * discount_factor

# ๐Ÿงช Property-based tests
from hypothesis import given, assume
import hypothesis.strategies as st

# ๐ŸŽฏ Strategy for valid items
item_strategy = st.fixed_dictionaries({
    'name': st.text(min_size=1, max_size=50),
    'price': st.floats(min_value=0.01, max_value=10000),
    'quantity': st.integers(min_value=1, max_value=100)
})

@given(st.lists(item_strategy, max_size=20))
def test_cart_total_is_sum_of_items(items):
    """Total should equal sum of all items! ๐Ÿงฎ"""
    cart = ShoppingCart()
    
    # โž• Add all items
    for item in items:
        cart.add_item(item['name'], item['price'], item['quantity'])
    
    # ๐Ÿ” Calculate expected total
    expected = sum(item['price'] * item['quantity'] for item in items)
    
    # โœ… Check property
    assert abs(cart.get_total() - expected) < 0.01  # Float comparison

@given(
    st.lists(item_strategy, min_size=1, max_size=10),
    st.floats(min_value=0, max_value=100)
)
def test_discount_reduces_price(items, discount):
    """Discount should always reduce or maintain price! ๐Ÿ’ธ"""
    cart = ShoppingCart()
    
    # ๐Ÿ›’ Fill cart
    for item in items:
        cart.add_item(item['name'], item['price'], item['quantity'])
    
    original_total = cart.get_total()
    discounted_total = cart.apply_discount(discount)
    
    # โœ… Properties to check
    assert discounted_total <= original_total  # Never increase price!
    assert discounted_total >= 0  # Never negative!
    
    # ๐ŸŽฏ Special cases
    if discount == 0:
        assert discounted_total == original_total
    elif discount == 100:
        assert discounted_total == 0

# ๐ŸŽฎ Run the tests!
print("๐Ÿงช Testing shopping cart...")
test_cart_total_is_sum_of_items()
test_discount_reduces_price()
print("โœ… All shopping cart tests passed! ๐ŸŽ‰")

๐ŸŽฏ Try it yourself: Add a test for โ€œbuy 2 get 1 freeโ€ promotions!

๐ŸŽฎ Example 2: Password Validator

Letโ€™s test a password validator with tricky edge cases:

# ๐Ÿ” Password validator
class PasswordValidator:
    def __init__(self, min_length=8):
        self.min_length = min_length
        self.emoji_strength = {
            'weak': '๐Ÿ”ด',
            'medium': '๐ŸŸก', 
            'strong': '๐ŸŸข'
        }
    
    def is_valid(self, password):
        """Check if password meets requirements ๐Ÿ”"""
        if len(password) < self.min_length:
            return False
        
        has_upper = any(c.isupper() for c in password)
        has_lower = any(c.islower() for c in password)
        has_digit = any(c.isdigit() for c in password)
        has_special = any(c in "!@#$%^&*" for c in password)
        
        return has_upper and has_lower and has_digit and has_special
    
    def get_strength(self, password):
        """Rate password strength ๐Ÿ’ช"""
        if not self.is_valid(password):
            return 'weak'
        
        score = 0
        if len(password) >= 12:
            score += 1
        if len(password) >= 16:
            score += 1
        if any(c in "!@#$%^&*()_+-=" for c in password):
            score += 1
            
        if score >= 2:
            return 'strong'
        elif score >= 1:
            return 'medium'
        return 'weak'

# ๐Ÿงช Property-based tests
from string import ascii_lowercase, ascii_uppercase, digits

# ๐ŸŽฏ Strategy for valid passwords
valid_password = st.text(
    alphabet=ascii_lowercase + ascii_uppercase + digits + "!@#$%^&*",
    min_size=8
).filter(
    lambda s: (
        any(c.islower() for c in s) and
        any(c.isupper() for c in s) and
        any(c.isdigit() for c in s) and
        any(c in "!@#$%^&*" for c in s)
    )
)

@given(valid_password)
def test_valid_passwords_are_accepted(password):
    """Valid passwords should always be accepted! โœ…"""
    validator = PasswordValidator()
    assert validator.is_valid(password)
    assert validator.get_strength(password) != 'weak'

@given(st.text(max_size=7))
def test_short_passwords_are_invalid(password):
    """Short passwords should always fail! ๐Ÿšซ"""
    validator = PasswordValidator(min_length=8)
    assert not validator.is_valid(password)
    assert validator.get_strength(password) == 'weak'

@given(st.text(alphabet=ascii_lowercase, min_size=8))
def test_lowercase_only_is_weak(password):
    """Lowercase-only passwords are weak! ๐Ÿ”ด"""
    validator = PasswordValidator()
    assert not validator.is_valid(password)
    
# ๐ŸŽฎ Property: strength ordering
@given(valid_password)
def test_longer_passwords_not_weaker(password):
    """Longer passwords shouldn't be weaker! ๐Ÿ“"""
    validator = PasswordValidator()
    
    # ๐Ÿ” Test with extended password
    extended = password + "A1!"
    
    strength_order = ['weak', 'medium', 'strong']
    original_strength = validator.get_strength(password)
    extended_strength = validator.get_strength(extended)
    
    # โœ… Extended should be at least as strong
    assert strength_order.index(extended_strength) >= strength_order.index(original_strength)

print("๐Ÿ” Testing password validator...")
test_valid_passwords_are_accepted()
test_short_passwords_are_invalid()
test_lowercase_only_is_weak()
test_longer_passwords_not_weaker()
print("โœ… All password tests passed! ๐ŸŽ‰")

๐Ÿš€ Advanced Concepts

๐Ÿง™โ€โ™‚๏ธ Stateful Testing

For testing stateful systems, Hypothesis provides state machines:

# ๐ŸŽฏ Testing a bank account state machine
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st

class BankAccount:
    def __init__(self):
        self.balance = 0
        self.transactions = []
        self.emoji = "๐Ÿฆ"
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive! ๐Ÿ’ฐ")
        self.balance += amount
        self.transactions.append(f"โž• Deposited ${amount}")
        
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive! ๐Ÿ’ธ")
        if amount > self.balance:
            raise ValueError("Insufficient funds! ๐Ÿšซ")
        self.balance -= amount
        self.transactions.append(f"โž– Withdrew ${amount}")

# ๐Ÿงช Stateful test
class BankAccountStateMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.account = BankAccount()
        self.model_balance = 0  # ๐Ÿ“Š Track expected balance
    
    @rule(amount=st.floats(min_value=0.01, max_value=10000))
    def deposit(self, amount):
        """Test deposits ๐Ÿ’ต"""
        self.account.deposit(amount)
        self.model_balance += amount
        
    @rule(amount=st.floats(min_value=0.01, max_value=10000))
    def withdraw(self, amount):
        """Test withdrawals ๐Ÿ’ธ"""
        if amount <= self.model_balance:
            self.account.withdraw(amount)
            self.model_balance -= amount
    
    @invariant()
    def balance_matches_model(self):
        """Balance should always match our model! ๐ŸŽฏ"""
        assert abs(self.account.balance - self.model_balance) < 0.01
        
    @invariant()
    def balance_never_negative(self):
        """Balance should never go negative! ๐Ÿ›ก๏ธ"""
        assert self.account.balance >= 0

# ๐ŸŽฎ Run stateful test
TestBankAccount = BankAccountStateMachine.TestCase
print("๐Ÿฆ Testing bank account state machine...")
# This would be run by pytest normally

๐Ÿ—๏ธ Custom Strategies

Create your own strategies for domain-specific data:

# ๐Ÿš€ Custom strategy for email addresses
@st.composite
def email_strategy(draw):
    """Generate valid email addresses ๐Ÿ“ง"""
    username = draw(st.text(
        alphabet=ascii_lowercase + digits + "._-",
        min_size=1,
        max_size=20
    ))
    domain = draw(st.text(
        alphabet=ascii_lowercase,
        min_size=2,
        max_size=10
    ))
    tld = draw(st.sampled_from(['com', 'org', 'net', 'edu']))
    
    return f"{username}@{domain}.{tld}"

# ๐ŸŽจ Custom strategy for RGB colors
@st.composite  
def rgb_color_strategy(draw):
    """Generate RGB colors ๐ŸŽจ"""
    r = draw(st.integers(0, 255))
    g = draw(st.integers(0, 255))
    b = draw(st.integers(0, 255))
    return {'r': r, 'g': g, 'b': b, 'hex': f"#{r:02x}{g:02x}{b:02x}"}

# ๐Ÿงช Using custom strategies
@given(email_strategy())
def test_email_format(email):
    """Emails should have @ and . ๐Ÿ“ฎ"""
    assert '@' in email
    assert '.' in email.split('@')[1]

@given(rgb_color_strategy())
def test_rgb_to_hex(color):
    """RGB values should convert to valid hex ๐ŸŒˆ"""
    assert color['hex'].startswith('#')
    assert len(color['hex']) == 7
    assert all(c in '0123456789abcdef' for c in color['hex'][1:])

โš ๏ธ Common Pitfalls and Solutions

๐Ÿ˜ฑ Pitfall 1: Flaky Tests

# โŒ Wrong - using current time
import time

@given(st.integers())
def test_with_time(n):
    start_time = time.time()  # ๐Ÿ˜ฐ Changes every run!
    result = process_number(n)
    assert time.time() - start_time < 1  # ๐Ÿ’ฅ Might fail randomly!

# โœ… Correct - use deterministic properties
@given(st.integers())
def test_deterministic(n):
    result = process_number(n)
    assert isinstance(result, int)  # โœ… Always consistent!

๐Ÿคฏ Pitfall 2: Too Broad Strategies

# โŒ Dangerous - allows problematic inputs
@given(st.floats())
def test_divide(x):
    result = 1 / x  # ๐Ÿ’ฅ Fails with 0, inf, nan!

# โœ… Safe - constrain your inputs
@given(st.floats(min_value=0.1, max_value=1000, allow_nan=False))
def test_divide_safe(x):
    result = 1 / x  # โœ… Safe now!
    assert 0.001 <= result <= 10

๐Ÿค” Pitfall 3: Assuming Too Much

# โŒ Wrong - assuming list order
@given(st.lists(st.integers()))
def test_sorting(lst):
    sorted_lst = sorted(lst)
    assert sorted_lst[0] == min(lst)  # ๐Ÿ’ฅ Fails on empty list!

# โœ… Correct - handle edge cases
@given(st.lists(st.integers()))
def test_sorting_safe(lst):
    sorted_lst = sorted(lst)
    if lst:  # โœ… Check if not empty
        assert sorted_lst[0] == min(lst)
        assert sorted_lst[-1] == max(lst)

๐Ÿ› ๏ธ Best Practices

  1. ๐ŸŽฏ Start Simple: Begin with basic properties, add complexity gradually
  2. ๐Ÿ“ Name Properties Clearly: test_addition_is_commutative not test_add
  3. ๐Ÿ›ก๏ธ Constrain Inputs: Use min_value, max_value, filter()
  4. ๐ŸŽจ Use Composite Strategies: Build complex data from simple parts
  5. โœจ Let Hypothesis Shrink: Donโ€™t override shrinking without good reason
  6. ๐Ÿ” Check the Examples: Use @example for specific important cases
  7. ๐Ÿ“Š Profile Your Tests: Use --hypothesis-show-statistics

๐Ÿงช Hands-On Exercise

๐ŸŽฏ Challenge: Build a String Processor Test Suite

Create property-based tests for a string processor:

๐Ÿ“‹ Requirements:

  • โœ… Test a function that removes duplicate characters
  • ๐Ÿท๏ธ Test a function that counts word frequencies
  • ๐Ÿ‘ค Test a function that masks sensitive data (emails, phones)
  • ๐Ÿ“… Test a function that parses dates in multiple formats
  • ๐ŸŽจ Each test should check multiple properties!

๐Ÿš€ Bonus Points:

  • Use custom strategies for phone numbers
  • Test with Unicode and emojis
  • Create stateful tests for a text buffer

๐Ÿ’ก Solution

๐Ÿ” Click to see solution
# ๐ŸŽฏ String processor implementation and tests
import re
from collections import Counter
from hypothesis import given, assume
import hypothesis.strategies as st

class StringProcessor:
    def __init__(self):
        self.emoji = "๐Ÿ“"
        
    def remove_duplicates(self, text):
        """Remove duplicate characters while preserving order ๐Ÿงน"""
        seen = set()
        result = []
        for char in text:
            if char not in seen:
                seen.add(char)
                result.append(char)
        return ''.join(result)
    
    def count_words(self, text):
        """Count word frequencies ๐Ÿ“Š"""
        words = re.findall(r'\b\w+\b', text.lower())
        return dict(Counter(words))
    
    def mask_sensitive(self, text):
        """Mask emails and phone numbers ๐Ÿ”’"""
        # Mask emails
        text = re.sub(r'\b[\w.-]+@[\w.-]+\.\w+\b', '***@***.***', text)
        # Mask phone numbers (simple pattern)
        text = re.sub(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', '***-***-****', text)
        return text

# ๐Ÿงช Property-based tests
processor = StringProcessor()

@given(st.text())
def test_remove_duplicates_reduces_length(text):
    """Removing duplicates never increases length! ๐Ÿ“"""
    result = processor.remove_duplicates(text)
    assert len(result) <= len(text)

@given(st.text())
def test_remove_duplicates_preserves_unique(text):
    """Already unique strings stay the same! ๐ŸŽฏ"""
    unique_text = processor.remove_duplicates(text)
    # Applying again should give same result
    assert processor.remove_duplicates(unique_text) == unique_text

@given(st.text())
def test_remove_duplicates_has_all_chars(text):
    """Result contains all unique characters! โœ…"""
    result = processor.remove_duplicates(text)
    assert set(result) == set(text)

# ๐ŸŽฏ Custom strategy for text with words
words_text = st.text(alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd"), whitelist_characters=" "), min_size=1)

@given(words_text)
def test_word_count_values_positive(text):
    """All word counts are positive! ๐Ÿ”ข"""
    counts = processor.count_words(text)
    assert all(count > 0 for count in counts.values())

@given(words_text)
def test_word_count_sum_matches(text):
    """Sum of counts equals total words! ๐Ÿงฎ"""
    counts = processor.count_words(text)
    words = re.findall(r'\b\w+\b', text.lower())
    assert sum(counts.values()) == len(words)

# ๐ŸŽจ Custom strategy for emails
@st.composite
def email_in_text_strategy(draw):
    """Generate text with emails ๐Ÿ“ง"""
    prefix = draw(st.text(max_size=20))
    email = draw(st.from_regex(r'[a-z]+@[a-z]+\.com'))
    suffix = draw(st.text(max_size=20))
    return f"{prefix} {email} {suffix}"

@given(email_in_text_strategy())
def test_mask_hides_emails(text):
    """Emails are properly masked! ๐Ÿ”"""
    result = processor.mask_sensitive(text)
    # Original email pattern shouldn't exist
    assert not re.search(r'\b[\w.-]+@[\w.-]+\.\w+\b', result)
    # But masked version should
    assert '***@***.***' in result

# ๐Ÿ† Stateful test for text buffer
from hypothesis.stateful import RuleBasedStateMachine, rule

class TextBufferStateMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.buffer = []
        self.processor = StringProcessor()
    
    @rule(text=st.text())
    def append_text(self, text):
        """Append text to buffer ๐Ÿ“"""
        self.buffer.append(text)
    
    @rule()
    def process_buffer(self):
        """Process and clear buffer ๐Ÿ”„"""
        if self.buffer:
            combined = ''.join(self.buffer)
            result = self.processor.remove_duplicates(combined)
            # Property: result has all unique chars from buffer
            assert set(result) == set(combined)
            self.buffer.clear()
    
    @invariant()
    def buffer_size_reasonable(self):
        """Buffer doesn't grow too large ๐Ÿ“ฆ"""
        assert len(self.buffer) <= 1000

# ๐ŸŽฎ Run all tests
print("๐Ÿงช Testing string processor...")
test_remove_duplicates_reduces_length()
test_remove_duplicates_preserves_unique()
test_remove_duplicates_has_all_chars()
test_word_count_values_positive()
test_word_count_sum_matches()
test_mask_hides_emails()
print("โœ… All string processor tests passed! ๐ŸŽ‰")

๐ŸŽ“ Key Takeaways

Youโ€™ve mastered property-based testing with Hypothesis! Hereโ€™s what you can now do:

  • โœ… Write property-based tests that find edge cases automatically ๐Ÿ’ช
  • โœ… Use Hypothesis strategies to generate test data ๐ŸŽฒ
  • โœ… Create custom strategies for domain-specific needs ๐ŸŽจ
  • โœ… Test stateful systems with state machines ๐Ÿค–
  • โœ… Avoid common pitfalls in property-based testing ๐Ÿ›ก๏ธ

Remember: Property-based testing doesnโ€™t replace example-based tests, it complements them! Use both for maximum confidence. ๐Ÿค

๐Ÿค Next Steps

Congratulations! ๐ŸŽ‰ Youโ€™ve unlocked the power of property-based testing!

Hereโ€™s what to do next:

  1. ๐Ÿ’ป Install Hypothesis and try the exercises above
  2. ๐Ÿ—๏ธ Add property-based tests to your existing projects
  3. ๐Ÿ“š Explore more advanced strategies in the Hypothesis docs
  4. ๐ŸŒŸ Share your coolest bug finds with the community!

Remember: The best tests are the ones that find bugs you didnโ€™t know existed. Keep testing, keep discovering, and most importantly, have fun finding those edge cases! ๐Ÿš€


Happy testing! ๐ŸŽ‰๐Ÿงชโœจ