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:
- Automatic Test Generation ๐ฒ: No more thinking up test cases
- Edge Case Discovery ๐: Finds the weird inputs that break your code
- Shrinking ๐ฌ: Automatically simplifies failing cases
- 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
- ๐ฏ Start Simple: Begin with basic properties, add complexity gradually
- ๐ Name Properties Clearly:
test_addition_is_commutative
nottest_add
- ๐ก๏ธ Constrain Inputs: Use
min_value
,max_value
,filter()
- ๐จ Use Composite Strategies: Build complex data from simple parts
- โจ Let Hypothesis Shrink: Donโt override shrinking without good reason
- ๐ Check the Examples: Use
@example
for specific important cases - ๐ 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:
- ๐ป Install Hypothesis and try the exercises above
- ๐๏ธ Add property-based tests to your existing projects
- ๐ Explore more advanced strategies in the Hypothesis docs
- ๐ 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! ๐๐งชโจ