PropertyDamage (PropertyDamage v0.2.0)
View SourcePropertyDamage: A stateful property-based testing framework for Elixir.
PropertyDamage combines the power of property-based testing with stateful system testing, allowing you to verify that your system behaves correctly under any sequence of operations.
Overview
Traditional property-based testing generates random inputs and verifies properties hold for all inputs. Stateful property-based testing extends this by generating random sequences of operations (commands) and verifying that the system under test (SUT) behaves correctly throughout the entire sequence.
Key Concepts
- Commands: Operations that can be executed against the SUT (create, update, delete, etc.)
- Model: Defines what commands are available and how state is tracked
- Projections: Pure state reducers that process commands and events to maintain state
- Adapters: Bridge between the test framework and the actual SUT
- Refs: Symbolic placeholders for entity IDs, resolved during execution
Two-Phase Execution
PropertyDamage uses a two-phase execution model:
- Symbolic Phase: Generate a sequence of commands with symbolic refs
- Concrete Phase: Execute commands against the SUT, resolving refs to real values
This separation enables powerful shrinking of failing test cases while maintaining the dependency relationships between commands.
Basic Usage
defmodule MyModelTest do
use ExUnit.Case
use PropertyDamage
@model MyApp.TestModel
@adapter MyApp.TestAdapter
property_damage "system maintains invariants" do
max_commands: 50,
max_runs: 100
end
endRunning Directly
PropertyDamage.run(
model: MyApp.TestModel,
adapter: MyApp.TestAdapter,
max_commands: 50,
max_runs: 100
)Debugging Failures
When a test fails, PropertyDamage provides rich tools for understanding what went wrong:
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
# Understand why each command in the shrunk sequence is needed
explanation = PropertyDamage.explain(failure)
# Find the specific field/value that caused the failure
{:ok, trigger} = PropertyDamage.isolate_trigger(failure)
# Generate a reproducible test case
test_code = PropertyDamage.generate_test(failure, format: :exunit)
# Try harder to shrink if needed
{:ok, smaller} = PropertyDamage.shrink_further(failure, strategy: :exhaustive)
# Replay step-by-step
{:ok, steps} = PropertyDamage.replay(failure)Failure Persistence
Save failures for later analysis or regression testing:
{:ok, path} = PropertyDamage.save_failure(failure, "failures/")
{:ok, loaded} = PropertyDamage.load_failure(path)
failures = PropertyDamage.list_failures("failures/")See PropertyDamage.Persistence for details.
Seed Library
Replay recently-failing seeds before random exploration (DR-023). This is an ephemeral, self-pruning working set for the fix cycle, not a durable corpus (for durable regressions, export to an ExUnit test):
# Replay failing seeds first; append any new failure's seed automatically
PropertyDamage.run(model: M, adapter: A, seed_library: true)See PropertyDamage.SeedLibrary for details.
Coverage Metrics
Track how thoroughly your model is being exercised:
coverage = PropertyDamage.coverage(result, MyModel)
IO.puts(PropertyDamage.Coverage.format(coverage))See PropertyDamage.Coverage for details.
Flakiness Detection
Detect non-deterministic behavior in your SUT:
PropertyDamage.check_determinism(Model, Adapter, seed, runs: 10)
flaky = PropertyDamage.discover_flaky_seeds(Model, Adapter, num_seeds: 20)See PropertyDamage.Flakiness for details.
Architecture
The framework consists of several layers:
- Tier 0 (Core Types): Ref, Command, Projection, Model behaviours
- Tier 1 (Execution): Adapter, EventQueue, InjectorAdapter, Executor
- Tier 2 (Shrinking): Validator, Shrinker, dependency graph
- Tier 3 (Analysis): Analysis, Replay, Coverage, Flakiness
- Utilities: Persistence, SeedLibrary, mix tasks
See the individual module documentation for detailed information on each component.
Summary
Functions
Add a failure to the seed library.
The model's invariant catalog (DR-026).
Per-invariant anti-vacuity coverage for a run result (DR-026).
Check if a seed produces deterministic results.
Get coverage statistics from a test result.
Delete a saved failure file.
Discover flaky seeds by testing random seeds.
Execute a fixed command sequence without a model.
Explain why each command in a failure's shrunk sequence is needed.
Mark a field as server-generated (external) in event struct definitions.
Convenience function to fail an assertion with a message and optional data.
Generate a reproducible test case from a failure.
Find the minimal change that eliminates the failure.
List all saved failures in a directory.
Load a previously saved failure report.
Load a seed library from disk.
Replay a failure sequence step-by-step for debugging.
Run a property-based test.
Save a failure report to disk for later analysis or regression testing.
Save a seed library to disk.
Attempt further shrinking on an existing failure report.
Types
@type failure_report() :: %{ seed: integer(), run_number: non_neg_integer(), original_sequence: PropertyDamage.Sequence.t(), shrunk_sequence: PropertyDamage.Sequence.t(), failed_at_index: non_neg_integer(), failure_reason: term(), shrink_iterations: non_neg_integer(), shrink_time_ms: non_neg_integer() }
Failure report from a failed run.
@type result() :: {:ok, stats()} | {:error, failure_report()}
Result from run/1 - either success stats or a failure report.
@type stats() :: %{ runs: non_neg_integer(), total_commands: non_neg_integer(), seed: integer() }
Result statistics from a successful run.
Functions
@spec add_to_seed_library( PropertyDamage.SeedLibrary.t(), PropertyDamage.FailureReport.t(), keyword() ) :: {:ok, PropertyDamage.SeedLibrary.t()} | {:error, term()}
Add a failure to the seed library.
Options
:tags- Categorization tags (e.g.,[:currency, :race_condition]):description- Human-readable description
Example
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, library} = PropertyDamage.add_to_seed_library(library, failure,
tags: [:currency_mismatch],
description: "Capture with different currency than authorization"
)
@spec assertion_catalog(module()) :: [ %{ projection: module(), id: atom(), invariant: PropertyDamage.Invariants.Invariant.t(), checks: [%{name: atom(), kind: :synchronous | :lifecycle | :polling}] } ]
The model's invariant catalog (DR-026).
The union of every projection's declared invariants, keyed {projection, id},
each entry carrying the %PropertyDamage.Invariants.Invariant{} and the checks
(with their kinds) that validate it. See PropertyDamage.Model.assertion_catalog/1.
@spec assertion_coverage( {:ok, map()} | {:error, PropertyDamage.FailureReport.t()} | map(), module() ) :: [ %{ projection: module(), id: atom(), name: atom(), description: String.t() | nil, kinds: [atom()], fire_count: non_neg_integer(), covered?: boolean() } ]
Per-invariant anti-vacuity coverage for a run result (DR-026).
Joins the run's per-assertion firings (result.assertion_fires, accumulated
across every generated sequence) against the model's assertion_catalog/1,
with no re-execution. Each entry reports whether the invariant was exercised:
result = PropertyDamage.run(model: M, adapter: A)
for inv <- PropertyDamage.assertion_coverage(result, M), not inv.covered? do
IO.puts("never exercised: #{inv.id}")
endEach entry is a map with :projection, :id, :name, :description,
:kinds (the distinct check kinds), :fire_count (summed over the invariant's
checks), and :covered? (fire_count > 0). Ordered like the catalog. Works on
a passing {:ok, stats} or a failing {:error, report} result, though on a
failed run the fire map is partial by nature (anti-vacuity is a passing-run
concern).
@spec check_determinism(module(), module(), integer(), keyword()) :: PropertyDamage.Flakiness.result()
Check if a seed produces deterministic results.
Runs the same seed multiple times to detect non-deterministic behavior in the system under test.
Options
:runs- Number of times to run (default: 5):adapter_config- Adapter configuration:max_commands- Maximum commands per run (default: 50):verbose- Print progress (default: false)
Returns
{:ok, :deterministic}- Same result every time{:ok, :flaky, stats}- Different results, with statistics{:error, reason}- Check failed
Example
case PropertyDamage.check_determinism(M, A, 512902757, runs: 10) do
{:ok, :deterministic} ->
IO.puts("Seed is deterministic")
{:ok, :flaky, stats} ->
IO.puts("FLAKY: passed #{stats.passes}/#{stats.runs} times")
end
@spec coverage({:ok, map()} | {:error, PropertyDamage.FailureReport.t()}, module()) :: PropertyDamage.Coverage.t()
Get coverage statistics from a test result.
Example
result = PropertyDamage.run(model: M, adapter: A)
coverage = PropertyDamage.coverage(result, M)
IO.puts(PropertyDamage.Coverage.format(coverage))
Delete a saved failure file.
@spec discover_flaky_seeds(module(), module(), keyword()) :: [ {integer(), PropertyDamage.Flakiness.flaky_stats()} ]
Discover flaky seeds by testing random seeds.
Options
:num_seeds- Number of random seeds to test (default: 10):runs_per_seed- Runs per seed (default: 3):verbose- Print progress (default: false)
Returns
List of {seed, flaky_stats} for seeds that are flaky.
Example
flaky_seeds = PropertyDamage.discover_flaky_seeds(M, A, num_seeds: 20)
IO.puts("Found #{length(flaky_seeds)} flaky seeds")
@spec execute( [struct()], keyword() ) :: {:ok, [PropertyDamage.EventLog.Entry.t()]} | {:error, term()}
Execute a fixed command sequence without a model.
This function provides a model-free execution path for static regression tests where you want to run a specific command sequence and assert on the raw event log directly, without using model-defined projections or checks.
Use Cases
- Regression tests: Run a specific sequence that reproduced a bug
- Integration tests: Execute commands with real injector adapters
- Debugging: Capture full SUT behavior including webhooks/callbacks
Options
:adapter- Adapter module (required):injector_adapters- List of injector adapter modules (default:[]):adapter_config- Config passed toadapter.setup/1(default:%{})
Returns
{:ok, event_log}- List ofEventLog.Entrystructs containing all events{:error, {:adapter_error, reason, partial_events}}- Adapter failed
Example
# Simple execution
commands = [
%CreateUser{name: "alice"},
%CreateOrder{user_id: 1, amount: 100}
]
{:ok, events} = PropertyDamage.execute(commands, adapter: MyAdapter)
# Assert on returned events
assert length(events) == 2
assert hd(events).event.__struct__ == UserCreatedWith Injector Adapters
When testing end-to-end flows with webhooks or async callbacks:
{:ok, events} = PropertyDamage.execute(commands,
adapter: MyAdapter,
injector_adapters: [WebhookAdapter],
adapter_config: %{base_url: "http://localhost:4000"}
)
# Assert on injected webhook events
assert Enum.any?(events, fn entry ->
entry.source == :injector and
match?(%WebhookReceived{status: "completed"}, entry.event)
end)Comparison with Direct Adapter Calls
For simple tests that only need command return values (no injector events), calling the adapter directly is simpler:
{:ok, adapter_ctx} = MyAdapter.setup(%{})
{:ok, events} = MyAdapter.execute(%CreateUser{name: "alice"}, adapter_ctx)
assert [%UserCreated{name: "alice"}] = events
MyAdapter.teardown(adapter_ctx)Use execute/2 when you need the full infrastructure: injector adapters,
event queue, external() value resolution across commands, etc.
@spec explain(PropertyDamage.FailureReport.t()) :: map()
Explain why each command in a failure's shrunk sequence is needed.
Delegates to PropertyDamage.Analysis.explain/1.
See that module for detailed documentation.
@spec external() :: PropertyDamage.External.t()
Mark a field as server-generated (external) in event struct definitions.
Use external() as the default value for fields that will be populated by
the System Under Test (SUT) during execution, such as auto-generated IDs,
timestamps, or transaction references.
Basic Usage
defmodule MyApp.Events.OrderCreated do
import PropertyDamage, only: [external: 0]
# id is server-generated, amount comes from the command
defstruct [:amount, :customer_id, id: external()]
endMultiple Externals
Events can have multiple external fields:
defmodule MyApp.Events.PaymentProcessed do
import PropertyDamage, only: [external: 0]
defstruct [
payment_id: external(),
transaction_ref: external(),
:order_id,
:amount
]
endNested Externals
Externals are supported in nested maps:
defstruct [
ids: %{transaction: external(), confirmation: external()},
:amount
]Fixed-Length Lists
Externals are supported in fixed-length lists:
defstruct [
item_ids: [external(), external(), external()],
:batch_name
]How It Works
In your simulator, return events with external fields unset.
simulate/2returns a bare list of event structs;id: external()is implicit:def simulate(%CreateOrder{amount: amt}, _state) do [%OrderCreated{amount: amt}] end
The framework automatically:
- Detects external markers during simulation
- Creates internal placeholders to track dependencies (these flow into projection state in place of the marker)
- Resolves placeholders with real values from the SUT
In projections, you receive concrete values at execution time:
def apply(state, %OrderCreated{id: id, amount: amt}) do put_in(state.orders[id], %{amount: amt}) # id is a real value end
To make a later command consume a server-generated value, route a placeholder out of state in the model's
with:function. During generation the projection holds placeholders, whichPropertyDamage.Generator.external_from/2surfaces as a seeded choice:# in the model's command list {ViewOrder, when: fn state -> map_size(state.orders) > 0 end, with: fn state ->
%{order_id: PropertyDamage.Generator.external_from(state, path: [:id])}end}
The chosen placeholder resolves to the real id before
ViewOrderruns.
Limitations
Variable-length lists where the count isn't known at struct definition
time are not supported. If you need a variable number of external IDs,
mark the entire list as external() and have the SUT return the complete list.
See Also
PropertyDamage.External- Implementation details and path detection
Convenience function to fail an assertion with a message and optional data.
Use this in projection assertions when you don't need a custom exception type.
Examples
# Simple failure
PropertyDamage.fail!("balance is negative")
# With context data
PropertyDamage.fail!("balance is negative", balance: -50, account_id: "acc_123")
# In a projection assertion
@trigger every: 1
def assert_balance_positive(state, _cmd) do
if state.balance < 0 do
PropertyDamage.fail!("negative balance", balance: state.balance)
end
endCustom Exceptions
For richer error context, define your own exception types:
defmodule MyApp.BalanceViolation do
defexception [:balance, :requirement]
def message(%{balance: b}) do
"Balance is negative: #{b}"
end
end
# Then raise directly:
raise %MyApp.BalanceViolation{balance: -50, requirement: "REQ-001"}
@spec generate_test( PropertyDamage.FailureReport.t(), keyword() ) :: String.t()
Generate a reproducible test case from a failure.
Delegates to PropertyDamage.Analysis.generate_test/2.
See that module for detailed documentation.
@spec isolate_trigger( PropertyDamage.FailureReport.t(), keyword() ) :: {:ok, map()} | {:error, term()}
Find the minimal change that eliminates the failure.
Delegates to PropertyDamage.Analysis.isolate_trigger/1.
See that module for detailed documentation.
List all saved failures in a directory.
Options
:sort- Sort order::newest,:oldest,:seed(default::newest):filter- Filter function(metadata -> boolean)
Examples
failures = PropertyDamage.list_failures("failures/")
# Only check failures
failures = PropertyDamage.list_failures("failures/",
filter: &(&1.failure_type == :check_failed))
@spec load_failure(Path.t()) :: {:ok, PropertyDamage.FailureReport.t()} | {:error, term()}
Load a previously saved failure report.
Examples
{:ok, failure} = PropertyDamage.load_failure("failures/currency-bug.pd")
PropertyDamage.replay(failure)
@spec load_seed_library(Path.t()) :: {:ok, PropertyDamage.SeedLibrary.t()} | {:error, term()}
Load a seed library from disk.
Returns an empty library if the file doesn't exist.
Example
{:ok, library} = PropertyDamage.load_seed_library("seeds.json")
@spec replay( PropertyDamage.FailureReport.t(), keyword() ) :: {:ok, [PropertyDamage.Replay.step()]} | {:error, term()}
Replay a failure sequence step-by-step for debugging.
Executes each command in the shrunk sequence and returns detailed information about each step including events and projection states.
Options
:adapter_config- Override adapter configuration:stop_on_failure- Stop at first failure (default: true)
Example
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, steps} = PropertyDamage.replay(failure)
Enum.each(steps, fn step ->
IO.puts("[#{step.index}] #{step.command_name}")
IO.inspect(step.projections)
end)For interactive stepping, use PropertyDamage.Replay directly:
{:ok, session} = PropertyDamage.Replay.start(failure)
{:ok, session, step} = PropertyDamage.Replay.step(session)
@spec run(keyword()) :: {:ok, stats()} | {:error, failure_report()}
Run a property-based test.
This is the main entry point for PropertyDamage. It generates command sequences, executes them against the SUT, and shrinks failures to minimal reproductions.
Required Options
:model- Model module implementing PropertyDamage.Model:adapter- Adapter module implementing PropertyDamage.Adapter
Optional Options
:max_commands- Maximum commands per sequence (default: 50):max_runs- Number of test sequences to run (default: 100):seed- Random seed for reproducibility (default: random):injector_adapters- List of InjectorAdapter modules (default: []):adapter_config- Config passed to adapter.setup/1 (default: %{}):shrink- Whether to shrink failing sequences (default: true):seed_library- Ephemeral replay working set (DR-023):false(default, disabled),true(default file), or a path. Previously-failing seeds are replayed before exploration; a still-failing replay halts the run.:seed_library_prune_after- Consecutive passing replays after which a seed is dropped from the library (default: 3):shrinker_config- ShrinkerConfig struct for tuning shrinking:on_failure- Callback function receiving failure_report (default: nil):regression- Keyword list for automatic regression test management (see below):verbose- Print progress and configuration (default: false):validate- Run configuration validation first (default: true):branching- Keyword list for parallel branching (see below):stutter- Map for idempotency testing (see below)
Branching Options
Pass branching: [...] to generate branching (parallel) sequences:
:branch_probability- Probability of creating a branch point (default: 0.2):max_branches- Maximum number of parallel branches (default: 3):max_branch_length- Maximum commands per branch (default: 5):min_prefix_length- Minimum commands before branching (default: 3)
Branching sequences enable detection of race conditions by executing commands in parallel branches and checking linearizability.
Stutter Options (Idempotency Testing)
Pass stutter: %{...} to enable idempotency testing:
:probability- Probability of stuttering each command (default: 0.1):max_repeats- Maximum retry attempts per stuttered command (default: 2):delay_ms- Delay between retries,{min, max}tuple or integer (default: {0, 100}):commands-:allor list of command modules to stutter (default: :all):comparison- Event comparison mode (default: :strict):strict- Events must be exactly equal{:structural, fields}- Ignore specified fields when comparing{:custom, fun}- Custom comparison functionfn(events1, events2) -> :match | {:mismatch, map()}
Stutter testing verifies that retrying commands produces consistent results (idempotency). Retry events are captured but not applied to projections.
Regression Options
Pass regression: [...] to automatically save failures for regression testing:
:save_failures- Directory to save failure files:seed_library- Path to seed library JSON file:generate_tests- Directory to generate ExUnit test files:tags- Tags to add to seed library entries (default:[:auto_detected]):dedup- Skip if similar failure exists (default: false):dedup_threshold- Similarity threshold for dedup (default: 0.90):verbose- Print regression actions (default: false)
This option integrates with :on_failure - both can be used together.
Returns
{:ok, stats}- All runs passed{:error, failure_report}- A run failed
Examples
# Basic usage
PropertyDamage.run(model: MyModel, adapter: MyAdapter)
# With options
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
max_commands: 100,
max_runs: 1000,
seed: 12345
)
# With failure callback
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
on_failure: fn failure_report ->
IO.puts("Failed at command #{failure_report.failed_at_index}")
end
)
# With automatic regression management
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
regression: [
save_failures: "failures/",
seed_library: "seeds.json",
generate_tests: "test/regressions/",
dedup: true
]
)
@spec save_failure(PropertyDamage.FailureReport.t(), Path.t(), keyword()) :: {:ok, Path.t()} | {:error, term()}
Save a failure report to disk for later analysis or regression testing.
Options
:filename- Custom filename (default: auto-generated from metadata):overwrite- Whether to overwrite existing files (default: false)
Examples
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
# Save with auto-generated name
{:ok, path} = PropertyDamage.save_failure(failure, "failures/")
# Save with custom name
{:ok, path} = PropertyDamage.save_failure(failure, "failures/", filename: "currency-bug.pd")
@spec save_seed_library(PropertyDamage.SeedLibrary.t(), Path.t()) :: :ok | {:error, term()}
Save a seed library to disk.
Example
:ok = PropertyDamage.save_seed_library(library, "seeds.json")
@spec shrink_further( PropertyDamage.FailureReport.t(), keyword() ) :: {:ok, PropertyDamage.FailureReport.t()} | {:error, term()}
Attempt further shrinking on an existing failure report.
Use this when the initial shrinking didn't produce a minimal enough sequence. You can specify more aggressive time/iteration limits or different strategies.
Options
:strategy- Shrinking strategy (default::thorough). Each strategy sets a default budget that the explicit options below override:strategy max_iterations max_time_ms :quick500 10_000 :thorough2000 60_000 :exhaustive10_000 300_000 :max_iterations- Maximum shrink attempts (default: from:strategy):max_time_ms- Maximum time for shrinking in ms (default: from:strategy):shrink_arguments- Whether to shrink argument values (default: true):adapter_config- Adapter configuration (uses report's adapter if not specified)
Returns
{:ok, new_failure_report}- Shrinking succeeded, possibly smaller sequence{:error, reason}- Shrinking failed (e.g., missing model/adapter)
Example
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
# Try harder to shrink
{:ok, smaller} = PropertyDamage.shrink_further(failure,
max_time_ms: 120_000,
strategy: :exhaustive
)
IO.puts("Reduced from #{length(original)} to #{length(smaller)} commands")