Stateful Testing¶
Work in Progress
Stateful testing feature is still in progress. It may be usable in some cases but may have bugs and behave unexpectedly.
Stateful testing involves defining a sequence of actions or commands that can be applied to a system under test and verifying properties or invariants about the system's state after executing these sequences.
python-proptest provides utilities for defining state machines and generating sequences of commands to effectively test stateful systems. It allows you to model the state of your system, define actions that change the state, and automatically run sequences of these actions to find bugs.
Core Concepts¶
Stateful testing in python-proptest revolves around the StatefulProperty class, which orchestrates the test execution. Here are the key components:
- Initial State (
ObjectType): You need a generator (Generator[ObjectType]) that produces the initial state of the system under test for each test run. - Actions (
ActionorSimpleAction): Actions represent operations that modify the system's state.SimpleAction: Used when you don't need an explicit model. It takes a function(obj: ObjectType) -> None.Action: Used when you have a model. It takes a function(obj: ObjectType, model: ModelType) -> Noneand updates both the real object and the model.
- Model (
ModelType, Optional): A simplified representation of the system's state. It's used to verify the correctness of the actual system's state after each action. - Model Factory (
model_factory, Optional): A function(obj: ObjectType) -> ModelTypethat creates the initial model state based on the initial object state. Required if using a model. - Action Generation (
action_gen_factoryorsimple_action_gen_factory): A factory function that returns a generator for the next action based on the current state of the object (and model, if applicable).SimpleActionGenFactory:(obj: ObjectType) -> Generator[SimpleAction[ObjectType]]ActionGenFactory:(obj: ObjectType, model: ModelType) -> Generator[Action[ObjectType, ModelType]]python-proptestprovides helpers likesimple_action_gen_ofandaction_gen_ofto combine multiple action generators.
Creating a Stateful Property¶
You typically use factory functions to create a StatefulProperty:
-
simple_stateful_property<ObjectType>(initial_gen, simple_action_gen_factory): Use this when you don't need an explicit model. Checks are usually performed within theSimpleActionimplementations (e.g., asserting invariants after an operation).from python_proptest import Gen, simple_stateful_property, SimpleAction from typing import List # Define the system type MySystem = List[int] # Generator for the initial state (e.g., an empty list) initial_gen = Gen.just([]) # Action: Add an element def add_action_gen(): return Gen.int().map(lambda val: SimpleAction(lambda arr: arr.append(val)) ) # Action: Clear the list def clear_action_gen(): return Gen.just( SimpleAction(lambda arr: arr.clear()) ) # Combine action generators def action_factory(obj: MySystem): return Gen.one_of(add_action_gen(), clear_action_gen()) # Create the property prop = simple_stateful_property(initial_gen, action_factory) # Run the test prop.go() -
stateful_property<ObjectType, ModelType>(initial_gen, model_factory, action_gen_factory): Use this when you want to maintain a separate model to verify the system's behavior against.from python_proptest import Gen, stateful_property, Action from typing import List, Dict # Define the system and model types MySystem = List[int] MyModel = Dict[str, int] # Simplified model: {"count": int} # Initial state generator initial_gen = Gen.list(Gen.int(), min_length=0, max_length=10) # Model factory def model_factory(arr: MySystem) -> MyModel: return {"count": len(arr)} # Action: Add element (updates object and model) def add_action_gen(): return Gen.int().map(lambda val: Action(lambda arr, model: ( arr.append(val), model.update({"count": model["count"] + 1}) )) ) # Action: Remove element (updates object and model) def remove_action_gen(): return Gen.just( Action(lambda arr, model: ( arr.pop() if arr else None, model.update({"count": max(0, model["count"] - 1)}) if arr else None )) ) # Action generator factory def action_factory(obj: MySystem, model: MyModel): return Gen.one_of(add_action_gen(), remove_action_gen()) # Create the property prop = stateful_property(initial_gen, model_factory, action_factory) # Run the test prop.go()
Configuration¶
The StatefulProperty instance provides several methods for configuration:
set_seed(seed): Sets the initial seed for the random number generator for reproducible tests.set_num_runs(num_runs): Sets the number of test sequences to execute (default: 100).set_min_actions(min_actions)/set_max_actions(max_actions): Sets the minimum and maximum number of actions per sequence (default: 1-100).set_verbosity(verbose): Enables/disables verbose logging during execution.set_on_startup(startup_func): Sets a function to run before each test sequence.set_on_cleanup(cleanup_func): Sets a function to run after each successful test sequence.set_post_check(post_check_func): Sets a function to run after all actions in a sequence have completed successfully. Useful for final state validation. You can also useset_post_check_without_model((obj: ObjectType) -> None).
from python_proptest import simple_stateful_property, Gen, SimpleAction
def test_configured_stateful_property():
# Define a simple counter system
Counter = int
# Initial state
initial_gen = Gen.just(0)
# Actions
def increment_action():
return Gen.just(SimpleAction(lambda counter: counter + 1))
def decrement_action():
return Gen.just(SimpleAction(lambda counter: max(0, counter - 1)))
def action_factory(counter: Counter):
return Gen.one_of(increment_action(), decrement_action())
# Create and configure the property
prop = simple_stateful_property(initial_gen, action_factory)
prop.set_seed(42) # Reproducible tests
prop.set_num_runs(50) # Fewer runs for faster testing
prop.set_min_actions(5) # At least 5 actions per sequence
prop.set_max_actions(20) # At most 20 actions per sequence
prop.set_verbosity(True) # Enable verbose output
# Add startup and cleanup functions
prop.set_on_startup(lambda: print("Starting test sequence"))
prop.set_on_cleanup(lambda: print("Test sequence completed"))
# Add post-check to verify final state
prop.set_post_check_without_model(lambda counter: counter >= 0)
# Run the test
prop.go()
Advanced Stateful Testing Examples¶
Testing a Stack Data Structure¶
from python_proptest import simple_stateful_property, Gen, SimpleAction
from typing import List
def test_stack_implementation():
# Define the stack system
Stack = List[int]
# Initial state: empty stack
initial_gen = Gen.just([])
# Action: Push an element
def push_action():
return Gen.int().map(lambda val:
SimpleAction(lambda stack: stack.append(val))
)
# Action: Pop an element
def pop_action():
return Gen.just(
SimpleAction(lambda stack: stack.pop() if stack else None)
)
# Action: Peek at the top element
def peek_action():
return Gen.just(
SimpleAction(lambda stack: stack[-1] if stack else None)
)
# Action generator factory
def action_factory(stack: Stack):
# Only allow pop/peek if stack is not empty
if not stack:
return push_action()
else:
return Gen.one_of(push_action(), pop_action(), peek_action())
# Create the property
prop = simple_stateful_property(initial_gen, action_factory)
prop.set_post_check_without_model(lambda stack: len(stack) >= 0)
# Run the test
prop.go()
Testing a Bank Account with Model¶
from python_proptest import stateful_property, Gen, Action
from typing import Dict, Any
def test_bank_account():
# Define the system and model types
BankAccount = Dict[str, Any] # {"balance": float, "transactions": List[float]}
AccountModel = Dict[str, Any] # {"balance": float, "transaction_count": int}
# Initial state generator
initial_gen = Gen.dict(
Gen.just("balance"),
Gen.float(min_value=0.0, max_value=1000.0),
min_size=1,
max_size=1
).map(lambda d: {**d, "transactions": []})
# Model factory
def model_factory(account: BankAccount) -> AccountModel:
return {
"balance": account["balance"],
"transaction_count": len(account["transactions"])
}
# Action: Deposit money
def deposit_action():
return Gen.float(min_value=0.01, max_value=100.0).map(lambda amount:
Action(lambda account, model: (
account.update({
"balance": account["balance"] + amount,
"transactions": account["transactions"] + [amount]
}),
model.update({
"balance": model["balance"] + amount,
"transaction_count": model["transaction_count"] + 1
})
))
)
# Action: Withdraw money
def withdraw_action():
return Gen.float(min_value=0.01, max_value=100.0).map(lambda amount:
Action(lambda account, model: (
account.update({
"balance": max(0, account["balance"] - amount),
"transactions": account["transactions"] + [-amount]
}),
model.update({
"balance": max(0, model["balance"] - amount),
"transaction_count": model["transaction_count"] + 1
})
))
)
# Action generator factory
def action_factory(account: BankAccount, model: AccountModel):
return Gen.one_of(deposit_action(), withdraw_action())
# Create the property
prop = stateful_property(initial_gen, model_factory, action_factory)
# Add post-check to verify model consistency
def post_check(account: BankAccount, model: AccountModel):
assert account["balance"] == model["balance"]
assert len(account["transactions"]) == model["transaction_count"]
assert account["balance"] >= 0
prop.set_post_check(post_check)
prop.go()
Shrinking in Stateful Testing¶
If a test sequence fails (an action throws an error or the post_check fails), python-proptest automatically tries to shrink the test case to find a minimal reproduction. It does this by:
- Shrinking the Action Sequence: Trying shorter sequences or simpler actions.
- Shrinking the Initial State: Trying simpler versions of the initial state generated by
initial_gen.
The goal is to present the simplest possible initial state and sequence of actions that trigger the failure, making debugging easier. The error message will report the shrunk arguments if successful.
from python_proptest import simple_stateful_property, Gen, SimpleAction
def test_failing_stateful_property():
# This will demonstrate shrinking in action
Counter = int
initial_gen = Gen.just(0)
def increment_action():
return Gen.just(SimpleAction(lambda counter: counter + 1))
def decrement_action():
return Gen.just(SimpleAction(lambda counter: counter - 1))
def action_factory(counter: Counter):
return Gen.one_of(increment_action(), decrement_action())
prop = simple_stateful_property(initial_gen, action_factory)
# This will fail and show shrinking in action
prop.set_post_check_without_model(lambda counter: counter >= 0)
prop.go()
Stateful testing is particularly powerful for testing systems with complex state transitions, concurrent operations, or systems where the order of operations matters. It helps uncover race conditions, state inconsistencies, and other subtle bugs that are difficult to find with traditional unit tests.