How to Use Hypothesis and Pytest for Robust Property-Based Testing in Python
When writing unit tests, it’s hard to consider all possible edge cases and validate that your code works correctly.
This is sometimes caught in production and a quick and speedy patch needs to be deployed. Only for a new bug to emerge later.
There will always be cases you didn’t consider, making this an ongoing maintenance job. Unit testing solves only some of these issues.
Property-based testing is a complementary approach to traditional unit testing, where test cases are generated based on properties or constriants that the code should satisfy.
Hypothesis addresses this limitation by automatically generating test data based on specified strategies.
This allows developers to test a much broader range of inputs and outputs than traditional unit tests, increasing the likelihood of catching edge cases and unexpected behaviour.
In this article, we’ll explore how to use Hypothesis with Pytest for property-based testing.
We’ll start with the difference between example-based testing and property-based testing and then dive into 2 examples of how to use Hypothesis with Pytest.
First we’ll go through a simple example (string/array transformations) and then move on to a more complex one — building a Shopping Cart app.
Finally, we’ll discuss best practices for using Hypothesis with Pytest, including tips for writing your own Hypothesis strategies and touch on Model-based testing.
By the end of this article, you’ll be equipped with the knowledge and tools to use Hypothesis and Pytest for efficient and comprehensive property-based testing of your code.
Let’s get started then?
Objectives
By the end of this tutorial you should be able to:
- Understand the key differences between example-based, property-based and model-based testing
- Use the Hypothesis library with Pytest to test your code and ensure coverage for a wide range of test data
- Apply property-based testing to your Python apps
- Build a Shopping App and test it using property-based testing
Example-Based Testing vs Property-Based Testing
Example-based testing and property-based testing are two common approaches to software testing along with model-based testing.
Example-based testing involves writing test cases that provide specific inputs and expected outputs for functions or methods.
These tests are easy to write and understand, and they can catch many common errors.
However, they are limited in scope and may not cover all possible edge cases or unexpected scenarios.
Property-based testing involves testing properties or invariants that code should satisfy, and then automatically generating test data to check if those properties hold true.
This approach can catch a much broader range of edge cases and unexpected behaviour that may not be covered by example-based testing.
However, it can be more challenging to write and understand these tests, and it may require more time and computation power to generate test data.
As a developer, you should strive to use a combination of both to capture as many edge cases as possible to produce robust well-tested code.
Project Set Up
The project has the following structure
Getting Started
To get started, clone the repo here, or you can create your own repo by creating a folder and running git init
to initialise it.
Prerequisites
In this project, we’ll be using Python 3.10.10.
Create a virtual environment and install the requirements (packages) using1
pip install -r requirements.txt
This will install the Hypothesis Library that we’ll be using throughout this tutorial.
Simple Example
To understand how to use this library, it’s always good to start with a simple example. Walk before trying to run.
Source Code
The source code for our simple application contains a bunch of Array (list) and String transformations in Python.
Array Operations
src/random_operations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42# Array Operations
def find_largest_smallest_item(input_array: list) -> tuple:
"""
Function to find the largest and smallest items in an array
:param input_array: Input array
:return: Tuple of largest and smallest items
"""
if len(input_array) == 0:
raise ValueError
# Set the initial values of largest and smallest to the first item in the array
largest = input_array[0]
smallest = input_array[0]
# Iterate through the array
for i in range(1, len(input_array)):
# If the current item is larger than the current value of largest, update largest
if input_array[i] > largest:
largest = input_array[i]
# If the current item is smaller than the current value of smallest, update smallest
if input_array[i] < smallest:
smallest = input_array[i]
return largest, smallest
def sort_array(input_array: list, sort_key: str) -> list:
"""
Function to sort an array
:param sort_key: Sort key
:param input_array: Input array
:return: Sorted array
"""
if len(input_array) == 0:
raise ValueError
if sort_key not in input_array[0]:
raise KeyError
if not isinstance(input_array[0][sort_key], int):
raise TypeError
sorted_data = sorted(input_array, key=lambda x: x[sort_key], reverse=True)
return sorted_data
String Operations
src/random_operations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30# String Operations
def reverse_string(input_string) -> str:
"""
Function to reverse a string
:param input_string: Input string
:return: Reversed string
"""
return input_string[::-1]
def complex_string_operation(input_string: str) -> str:
"""
Function to perform a complex string operation
:param input_string: Input string
:return: Transformed string
"""
# Remove Whitespace
input_string = input_string.strip().replace(" ", "")
# Convert to Upper Case
input_string = input_string.upper()
# Remove vowels
vowels = ("A", "E", "I", "O", "U")
for x in input_string.upper():
if x in vowels:
input_string = input_string.replace(x, "")
return input_string
Simple Example — Unit Tests
As you’ve seen in the above section, two popular types of testing are
- Example-based testing (tests input-output examples)
- Property-based testing (tests properties with various auto-generated input data)
Example-Based Testing
We write some simple example-based tests
tests/unit/test_random_operations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39import pytest
import logging
from src.random_operations import (
reverse_string,
find_largest_smallest_item,
complex_string_operation,
sort_array,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Example Based Unit Testing
def test_find_largest_smallest_item():
assert find_largest_smallest_item([1, 2, 3]) == (3, 1)
def test_reverse_string():
assert reverse_string("hello") == "olleh"
def test_sort_array():
data = [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 20},
{"name": "David", "age": 35},
]
assert sort_array(data, "age") == [
{"name": "David", "age": 35},
{"name": "Bob", "age": 30},
{"name": "Alice", "age": 25},
{"name": "Charlie", "age": 20},
]
def test_complex_string_operation():
assert complex_string_operation(" Hello World ") == "HLLWRLD"
Here we test our 4 methods using the traditional way. You give an input and you assert that input against an expected output.
Running The Unit Test
To run the unit tests, simply run1
pytest ./tests/unit/test_random_operations.py -v -s
Great. But what if there are edge cases that you didn’t consider?
Let’s look at property-based testing.
Property-Based Testing
tests/unit/test_random_operations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35from hypothesis import given, strategies as st
from hypothesis import assume as hypothesis_assume
# Property Based Unit Testing
def test_find_largest_smallest_item_hypothesis(input_list):
assert find_largest_smallest_item(input_list) == (max(input_list), min(input_list))
def test_sort_array_hypothesis(input_list):
if len(input_list) == 0:
with pytest.raises(ValueError):
sort_array(input_list, "age")
hypothesis_assume(len(input_list) > 0)
assert sort_array(input_list, "age") == sorted(
input_list, key=lambda x: x["age"], reverse=True
)
def test_reverse_string_hypothesis(input_string):
assert reverse_string(input_string) == input_string[::-1]
def test_complex_string_operation_hypothesis(input_string):
assert complex_string_operation(input_string) == input_string.strip().replace(
" ", ""
).upper().replace("A", "").replace("E", "").replace("I", "").replace(
"O", ""
).replace(
"U", ""
)
To use Hypothesis in this example, we import the given
, strategies
and assume
in-built methods.
The @given
decorator is placed just before each test followed by a strategy.
A strategy is specified using the strategy.X
method which can be st.list()
, st.integers()
, st.text()
and so on.
Here’s a comprehensive list of strategies.
Strategies are used to generate test data and can be heavily customised to your liking for example — generate only positive/negative numbers, X/Y key, value pairs, integer with a maximum value of 100 and so on.
In our test examples above we generate simple test data using relevant strategies and assert the actual property logic holds true rather than the data itself.
Common issues discovered (that aren’t accounted for) include
- Empty lists, strings, and dicts as input
- Repeated values in lists
- Negative numbers, 0 as input
- Keys not present in dict
- ASCII characters
These can be discovered easily using the Hypothesis library.
Running The Unit Test
To run the unit tests, simply run1
pytest ./tests/unit/test_random_operations.py -v -s
Complex Example
Now that you’ve seen a basic example, it’s time to step up your testing game.
Let’s build a simple Shopping App that lets us
- Add items to the cart
- Remove items from the cart
- Calculate the total based on the quantity
- View cart items
- Clear cart
Source code
src/shopping_cart.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76import random
from enum import Enum, auto
class Item(Enum):
"""Item type"""
APPLE = auto()
ORANGE = auto()
BANANA = auto()
CHOCOLATE = auto()
CANDY = auto()
GUM = auto()
COFFEE = auto()
TEA = auto()
SODA = auto()
WATER = auto()
def __str__(self):
return self.name.upper()
class ShoppingCart:
def __init__(self):
"""
Creates a shopping cart object with an empty dictionary of items
"""
self.items = {}
def add_item(self, item: Item, price: int | float, quantity: int = 1) -> None:
"""
Adds an item to the shopping cart
:param quantity: Quantity of the item
:param item: Item to add
:param price: Price of the item
:return: None
"""
if item.name in self.items:
self.items[item.name]["quantity"] += quantity
else:
self.items[item.name] = {"price": price, "quantity": quantity}
def remove_item(self, item: Item, quantity: int = 1) -> None:
"""
Removes an item from the shopping cart
:param quantity: Quantity of the item
:param item: Item to remove
:return: None
"""
if item.name in self.items:
if self.items[item.name]["quantity"] <= quantity:
del self.items[item.name]
else:
self.items[item.name]["quantity"] -= quantity
def get_total_price(self):
total = 0
for item in self.items.values():
total += item["price"] * item["quantity"]
return total
def view_cart(self) -> None:
"""
Prints the contents of the shopping cart
:return: None
"""
print("Shopping Cart:")
for item, price in self.items.items():
print("- {}: ${}".format(item, price))
def clear_cart(self) -> None:
"""
Clears the shopping cart
:return: None
"""
self.items = {}
We define an Item
enum that only accepts a specific custom Item
object.
Complex Example — Unit Tests
Before diving head first into Property-based testing with Hypothesis, let’s get a feel for the code with some simple Example-based testing.
Example-Based Testing
tests/unit/test_shopping_cart.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61import pytest
from src.shopping_cart import ShoppingCart, Item
def cart():
return ShoppingCart()
# Define Items
apple = Item.APPLE
orange = Item.ORANGE
gum = Item.GUM
soda = Item.SODA
water = Item.WATER
coffee = Item.COFFEE
tea = Item.TEA
# Example Based Testing
def test_add_item(cart):
cart.add_item(apple, 1.00)
cart.add_item(orange, 1.00)
cart.add_item(gum, 2.00)
cart.add_item(soda, 2.50)
assert cart.items == {
"APPLE": {"price": 1.0, "quantity": 1},
"ORANGE": {"price": 1.0, "quantity": 1},
"GUM": {"price": 2.0, "quantity": 1},
"SODA": {"price": 2.5, "quantity": 1},
}
def test_remove_item(cart):
cart.add_item(orange, 1.00)
cart.add_item(tea, 3.00)
cart.add_item(coffee, 3.00)
cart.remove_item(orange)
assert cart.items == {
"TEA": {"price": 3.0, "quantity": 1},
"COFFEE": {"price": 3.0, "quantity": 1},
}
def test_total(cart):
cart.add_item(orange, 1.00)
cart.add_item(apple, 2.00)
cart.add_item(soda, 2.00)
cart.add_item(soda, 2.00)
cart.add_item(water, 1.00)
cart.remove_item(apple)
cart.add_item(gum, 2.50)
assert cart.get_total_price() == 8.50
def test_clear_cart(cart):
cart.add_item(apple, 1.00)
cart.add_item(soda, 2.00)
cart.add_item(water, 1.00)
cart.clear_cart()
assert cart.items == {}
The above example illustrates simple exam-based testing where we test
- Add items
- Add/Remove items
- Check the total
- Check that the clear cart functionality works
If you notice, we’ve used a Fixture to initialise the Cart
object with default function
scope.
If you’re unfamiliar with fixtures, this article on Pytest fixtures offers a solid base.
This ensures that the Cart object is reset after each test, ensuring statelessness and test isolation.
Running The Unit Test
To run the unit tests, simply run1
pytest ./tests/unit/test_shopping_cart.py -v -s
Property-Based Testing
Now let’s look at the big boy. How to test our complex Shopping App with property-based testing.
We want to test properties like
- Assert the number of items in the cart is equal to the number of items added
- Assert that if we remove an item, the quantity of items in the cart is equal to the number of items added — 1
- Ensure the total calculation is correct.
And many more.
To keep this simple we’ll just test the above basic use cases, you can of course go as granular as you like to cover all possible edge cases.
tests/unit/test_shopping_cart_hypothesis.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70from typing import Callable
from hypothesis import given, strategies as st
from hypothesis.strategies import SearchStrategy
from src.shopping_cart import ShoppingCart, Item
# Create a strategy for items
def items_strategy(draw: Callable[[SearchStrategy[Item]], Item]):
return draw(st.sampled_from(list(Item)))
# Create a strategy for price
def price_strategy(draw: Callable[[SearchStrategy[float]], float]):
return round(draw(st.floats(min_value=0.01, max_value=100, allow_nan=False)), 2)
# Create a strategy for quantity
def qty_strategy(draw: Callable[[SearchStrategy[int]], int]):
return draw(st.integers(min_value=1, max_value=10))
def test_add_item_hypothesis(item, price, quantity):
cart = ShoppingCart()
# Add items to cart
cart.add_item(item=item, price=price, quantity=quantity)
# Assert that the quantity of items in the cart is equal to the number of items added
assert item.name in cart.items
assert cart.items[item.name]["quantity"] == quantity
def test_remove_item_hypothesis(item, price, quantity):
cart = ShoppingCart()
print("Adding Items")
# Add items to cart
cart.add_item(item=item, price=price, quantity=quantity)
cart.add_item(item=item, price=price, quantity=quantity)
print(cart.items)
# Remove item from cart
print(f"Removing Item {item}")
quantity_before = cart.items[item.name]["quantity"]
cart.remove_item(item=item)
quantity_after = cart.items[item.name]["quantity"]
# Assert that if we remove an item, the quantity of items in the cart is equal to the number of items added - 1
assert quantity_before == quantity_after + 1
def test_calculate_total_hypothesis(item, price, quantity):
cart = ShoppingCart()
# Add items to cart
cart.add_item(item=item, price=price, quantity=quantity)
cart.add_item(item=item, price=price, quantity=quantity)
# Remove item from cart
cart.remove_item(item=item)
# Calculate total
total = cart.get_total_price()
assert total == cart.items[item.name]["price"] * cart.items[item.name]["quantity"]
Let’s break down the above tests.
Define strategies
To keep things simple to understand, we first define 3 strategies —
- Item strategy (randomly choose a value from our
Item
enum) - Price strategy (randomly choose a float number from
0.01
to100
) - Quantity strategy (randomly choose an integer number from
1
to10
)
We can then use these strategies in our tests with the @given
decorator.
Running The Unit Test
To run the unit tests, simply run1
pytest ./tests/unit/test_shopping_cart_hypothesis.py -v -s
Discover Bugs With Hypothesis
Often you will find your tests failing for input values you didn’t think about.
For example — when writing up these tests I caught bugs like
- The Same Item Added With Different Pricing which we need to account for.
In real life, this would not happen as each product will have its own unique SKU (stock-keeping unit). Nevertheless, it’s something to think about and fix.
Define Your Own Hypothesis Strategies
As we mentioned above you can create your own custom Hypothesis strategies and go as granular as you like.
In our examples, we defined custom strategies below1
2
3
4
5
6
7
8
9
10
11
12
13
14# Create a strategy for items
def items_strategy(draw: Callable[[SearchStrategy[Item]], Item]):
return draw(st.sampled_from(list(Item)))
# Create a strategy for price
def price_strategy(draw: Callable[[SearchStrategy[float]], float]):
return round(draw(st.floats(min_value=0.01, max_value=100, allow_nan=False)), 2)
# Create a strategy for quantity
def qty_strategy(draw: Callable[[SearchStrategy[int]], int]):
return draw(st.integers(min_value=1, max_value=10))
We use the st.composite
wrapper and draw a value from a pre-determined strategy or our custom Enums (as in the case of Items).
Model-Based Testing in Hypothesis
Model-based testing is an approach where a formal model (often smaller scale) of the system is created, and test cases are generated from that model.
The Hypothesis library can be customized to generate test data that satisfies the properties and constraints of the model, allowing you to efficiently and effectively test your systems.
We’ll cover Model-Based testing with examples in a future article.
Conclusion
I hope you’ve enjoyed this article in learning how to use the awesome Hypothesis testing library.
In this article, we looked at example-based testing and how some of its shortcomings can be overcome with property-based testing.
We looked at two examples — a simple array/string transformation example and a Shopping Cart app.
For each example, we drew up example-based tests and followed with property-based testing to increase the amount and variation of test data.
As a good developer, you must always strive to test your application using as many realistic use cases as possible.
But we’re human after all and there’s always something you haven’t considered. For all of that, there’s Hypothesis.
If you have ideas for improvement or like for me to cover anything specific, please send me a message via Twitter, GitHub or Email.
Till the next time… Cheers!