# PropertyDamage
*Controlled chaos from the outside in: break your systems before your users do it in prod.*
A stateful property-based testing (SPBT) framework for Elixir.
PropertyDamage generates random sequences of operations against your system and verifies that invariants hold throughout. When a failure is found, it automatically shrinks the sequence to the minimal reproduction case.
## Features
- **Stateful Testing**: Generate sequences of commands, not just individual inputs
- **Automatic Shrinking**: Failed sequences are minimized to the smallest reproduction
- **Symbolic References**: Commands can reference results from earlier commands
- **Parallel Execution**: Branching sequences for race condition detection
- **Linearization Checking**: Verify parallel results are sequentially explainable
- **Idempotency Testing**: Built-in stutter testing for retry safety
- **Rich Failure Reports**: Comprehensive diagnostics when tests fail
- **Failure Persistence**: Save failures for later analysis and regression testing
- **Step-by-Step Replay**: Debug failures by executing commands one at a time
- **Seed Library**: Track and share interesting seeds across your team
- **Coverage Metrics**: Know how thoroughly your model is being exercised
- **Flakiness Detection**: Identify non-deterministic behavior in your SUT
- **Load Testing**: Generate realistic load using SPBT traffic patterns
- **Visual Diagrams**: Sequence diagrams in Mermaid, PlantUML, WebSequence formats
- **Diff Debugging**: Compare passing vs failing runs to find divergence
- **Failure Export Hub**: Convert failures to portable artifacts (scripts, tests, notebooks)
- **Mutation Testing**: Verify your tests catch bugs by injecting faults
- **Invariant Suggestions**: Get AI-powered suggestions for missing checks
- **Failure Intelligence**: Pattern detection, similarity analysis, and fix verification
- **OpenAPI Scaffolding**: Generate command modules from API specifications
- **Telemetry Dashboard**: Real-time monitoring of test runs with LiveView integration
- **Livebook Integration**: Interactive exploration with rich visualizations and charts
- **Chaos Engineering**: Built-in nemesis operations for network, resource, time, and process faults
- **Differential Testing**: Compare implementations against oracles, baselines, or each other
## Installation
Add `property_damage` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:property_damage, "~> 0.1.0"}
]
end
```
## Quick Start
### 1. Define Commands
Commands represent operations that can be executed against your system:
```elixir
defmodule MyApp.Commands.CreateUser do
use PropertyDamage.Command
defstruct [:name, :email]
@impl true
def new!(state, generators) do
%__MODULE__{
name: Faker.Person.name(),
email: Faker.Internet.email()
}
end
@impl true
def precondition(_state), do: true
@impl true
def events(command, response) do
[%MyApp.Events.UserCreated{
id: response["id"],
name: command.name,
email: command.email
}]
end
@impl true
def ref(_command, response), do: response["id"]
end
```
### 2. Define Projections
Projections maintain state by processing events:
```elixir
defmodule MyApp.Projections.Users do
use PropertyDamage.Model.Projection
def init, do: %{}
def handles?(%MyApp.Events.UserCreated{}), do: true
def handles?(_), do: false
def apply(state, %MyApp.Events.UserCreated{} = event) do
Map.put(state, event.id, %{name: event.name, email: event.email})
end
end
```
### 3. Define Assertions (Invariants)
Assertions verify that invariants hold after each command. Use `Model.Projection` to define assertions with optional state tracking:
```elixir
defmodule MyApp.Assertions.UniqueEmails do
use PropertyDamage.Model.Projection
# Track users state (optional - defaults to %{})
def init, do: %{users: %{}}
# Update state on events (optional - defaults to returning state unchanged)
def apply(state, %UserCreated{id: id, email: email}) do
put_in(state, [:users, id], %{email: email})
end
def apply(state, _), do: state
# Assert unique emails after every step
@trigger every: 1
def assert_unique_emails(state, _cmd_or_event) do
emails = Map.values(state.users) |> Enum.map(& &1.email)
unless length(emails) == length(Enum.uniq(emails)) do
PropertyDamage.fail!("Duplicate emails found", emails: emails)
end
end
end
```
For simpler assertions that don't need state tracking, you can skip `init/0` and `apply/2`:
```elixir
defmodule MyApp.Assertions.ValidEmails do
use PropertyDamage.Model.Projection
# Just define assertions - defaults are injected
@trigger every: CreateUser
def assert_valid_email(_state, %CreateUser{email: email}) do
unless String.contains?(email, "@") do
PropertyDamage.fail!("Invalid email", email: email)
end
end
end
```
### 4. Define a Model
The model ties everything together:
```elixir
defmodule MyApp.TestModel do
@behaviour PropertyDamage.Model
@impl true
def commands do
[
{MyApp.Commands.CreateUser, weight: 10},
{MyApp.Commands.UpdateUser, weight: 5},
{MyApp.Commands.DeleteUser, weight: 3}
]
end
@impl true
def state_projection, do: MyApp.Projections.Users
@impl true
def extra_projections do
[MyApp.Assertions.UniqueEmails, MyApp.Assertions.ValidEmails]
end
end
```
### 5. Define an Adapter
The adapter executes commands against your actual system:
```elixir
defmodule MyApp.TestAdapter do
@behaviour PropertyDamage.Adapter
@impl true
def execute(%MyApp.Commands.CreateUser{} = cmd, config) do
Req.post!("#{config.base_url}/users", json: %{
name: cmd.name,
email: cmd.email
}).body
end
# ... other commands
end
```
### 6. Run Tests
```elixir
PropertyDamage.run(
model: MyApp.TestModel,
adapter: MyApp.TestAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
max_commands: 50,
max_runs: 100
)
```
## Debugging Failures
When PropertyDamage finds a failure, it provides rich tools for understanding what went wrong.
### Understanding Failure Reports
```elixir
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
# Get a quick explanation
explanation = PropertyDamage.explain(failure)
IO.puts(PropertyDamage.Analysis.format_explanation(explanation))
# Find what triggered the failure
{:ok, trigger} = PropertyDamage.isolate_trigger(failure)
IO.puts("Cause: #{trigger.likely_cause}")
# Generate a reproducible test
test_code = PropertyDamage.generate_test(failure, format: :exunit)
File.write!("test/regression_test.exs", test_code)
```
### Interactive Shrinking
If the initial shrinking didn't produce a minimal sequence:
```elixir
# Try harder to shrink
{:ok, smaller} = PropertyDamage.shrink_further(failure,
strategy: :exhaustive,
max_time_ms: 120_000
)
```
Strategies:
- `:quick` - Fast, may miss some reductions
- `:thorough` - Balanced approach (default)
- `:exhaustive` - Try all possible reductions
### Step-by-Step Replay
Execute commands one at a time to see exactly what happens:
```elixir
{:ok, steps} = PropertyDamage.replay(failure)
for step <- steps do
IO.puts("[#{step.index}] #{step.command_name}")
IO.inspect(step.projections, label: "State after")
case step.result do
:ok -> IO.puts(" OK")
{:check_failed, check, msg} -> IO.puts(" FAILED: #{msg}")
end
end
```
For interactive debugging:
```elixir
alias PropertyDamage.Replay
{:ok, session} = Replay.start(failure)
{:ok, session, step1} = Replay.step(session)
IO.inspect(Replay.current_state(session))
{:ok, session, step2} = Replay.step(session)
# ... continue stepping
Replay.stop(session)
```
### Visual Debugging Tools
For complex failures, PropertyDamage provides visual tools to understand execution flow:
```elixir
# Generate a sequence diagram from a failure
diagram = PropertyDamage.Diagram.from_failure_report(failure, :mermaid)
IO.puts(diagram) # Paste into GitHub markdown, Notion, etc.
# Compare a passing run against a failing run to find the divergence
passing_trace = PropertyDamage.Diff.create_trace(passing_commands, passing_events, [], :pass)
failing_trace = PropertyDamage.Diff.create_trace(failing_commands, failing_events, [], {:fail, :test})
diff = PropertyDamage.Diff.compare_traces(passing_trace, failing_trace)
IO.puts(PropertyDamage.Diff.format(diff, format: :terminal))
```
See [Visual Sequence Diagrams](#visual-sequence-diagrams) and [Diff-Based Debugging](#diff-based-debugging) for detailed documentation.
## Failure Persistence
Save failures for later analysis or to build a regression suite:
```elixir
# Save a failure
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, path} = PropertyDamage.save_failure(failure, "failures/")
# => {:ok, "failures/20251226T143000-check_failed-UniqueEmails-seed512902757.pd"}
# Load and analyze later
{:ok, loaded} = PropertyDamage.load_failure(path)
{:ok, steps} = PropertyDamage.replay(loaded)
# List all saved failures
failures = PropertyDamage.list_failures("failures/", sort: :newest)
# Delete old failures
PropertyDamage.delete_failure(path)
```
## Seed Library
Track seeds that have found bugs for regression testing:
```elixir
# Create or load a seed library
{:ok, library} = PropertyDamage.load_seed_library("seeds.json")
# Add a failure to the library
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, library} = PropertyDamage.add_to_seed_library(library, failure,
tags: [:currency, :capture],
description: "Currency mismatch in capture"
)
# Save the library
PropertyDamage.save_seed_library(library, "seeds.json")
# Get seeds to run in CI
alias PropertyDamage.SeedLibrary
failing_seeds = SeedLibrary.seed_values(library, status: :failing)
# Update status after running
library = SeedLibrary.record_run(library, seed, failed: false)
# View statistics
IO.puts(SeedLibrary.format(library))
```
## Coverage Metrics
Track how thoroughly your model is being exercised:
```elixir
alias PropertyDamage.Coverage
# Single run coverage
result = PropertyDamage.run(model: M, adapter: A)
coverage = PropertyDamage.coverage(result, M)
IO.puts(Coverage.format(coverage))
# Track across multiple runs
tracker = Coverage.new(M)
tracker = Coverage.record(tracker, result1)
tracker = Coverage.record(tracker, result2)
# Check thresholds in CI
unless Coverage.meets_threshold?(tracker, command: 80, transition: 50) do
raise "Coverage threshold not met!"
end
# Find untested commands
untested = Coverage.untested_commands(tracker)
```
### Format Options
Coverage supports multiple output formats:
```elixir
# Summary - basic stats
IO.puts(Coverage.format(tracker, :summary))
# Matrix - shows command transition coverage
IO.puts(Coverage.format(tracker, :matrix))
# Full - includes everything
IO.puts(Coverage.format(tracker, :full))
# State classes (when classifier is set)
IO.puts(Coverage.format(tracker, :state_classes))
```
### Transition Coverage
Track which command pairs (transitions) have been tested:
```elixir
# Get a transition matrix showing which A→B pairs were tested
matrix = Coverage.transition_matrix(tracker)
# => %{CreateAccount => %{CreateAccount => 5, CreditAccount => 12, DebitAccount => 8}, ...}
# Find untested transitions
untested = Coverage.untested_transitions(tracker)
# => [{CreateAccount, DeleteAccount}, {DebitAccount, CloseAccount}, ...]
# Get most frequent transitions
top = Coverage.top_transitions(tracker, 5)
# => [{{CreateAccount, CreditAccount}, 42}, {{CreditAccount, DebitAccount}, 38}, ...]
```
### State Class Coverage
For more meaningful coverage, define a state classifier to group concrete states into abstract classes:
```elixir
# Define a classifier function
classifier = fn state ->
cond do
state.accounts == %{} -> :no_accounts
Enum.all?(state.accounts, fn {_, a} -> a.balance == 0 end) -> :all_zero_balance
Enum.any?(state.accounts, fn {_, a} -> a.balance < 0 end) -> :has_negative
true -> :has_positive
end
end
# Create tracker with classifier
tracker = Coverage.new(MyModel, state_classifier: classifier)
tracker = Coverage.record(tracker, result1)
tracker = Coverage.record(tracker, result2)
# View state class distribution
counts = Coverage.state_class_counts(tracker)
# => %{no_accounts: 5, all_zero_balance: 12, has_positive: 83}
# View state class transitions (what state classes lead to what)
transitions = Coverage.state_class_transitions(tracker)
# => %{{:no_accounts, :all_zero_balance} => 5, {:all_zero_balance, :has_positive} => 10, ...}
# Get state class matrix for visualization
state_matrix = Coverage.state_class_matrix(tracker)
# Format with state class matrix
IO.puts(Coverage.format(tracker, :state_classes))
```
State class coverage helps answer: "Have we tested all interesting state configurations?"
## Flakiness Detection
Detect non-deterministic behavior in your system:
```elixir
# Check if a specific seed is flaky
case PropertyDamage.check_determinism(M, A, 512902757, runs: 10) do
{:ok, :deterministic} ->
IO.puts("Seed produces consistent results")
{:ok, :flaky, stats} ->
IO.puts("FLAKY: passed #{stats.passes}/#{stats.runs} times")
IO.puts("Variance type: #{stats.variance_type}")
end
# Discover flaky seeds
flaky_seeds = PropertyDamage.discover_flaky_seeds(M, A,
num_seeds: 20,
runs_per_seed: 5,
verbose: true
)
```
## OpenAPI Scaffolding
Generate command modules from an OpenAPI specification:
```bash
# Generate from a local file
mix pd.scaffold --from openapi.json --output lib/my_app_test/commands/
# Generate from a URL
mix pd.scaffold --from https://api.example.com/openapi.json --output lib/
# Only specific operations
mix pd.scaffold --from openapi.json --operations createUser,updateUser
# Preview without writing
mix pd.scaffold --from openapi.json --dry-run
```
Generated commands include:
- Struct fields from request body schemas
- Type hints from OpenAPI types
- Placeholder generators based on field types
- Adapter execution hints
## Model Validation
Validate your model before running tests:
```bash
mix pd.validate --model MyApp.TestModel
```
This checks:
- All commands implement required callbacks
- Projections handle their declared events
- Checks reference valid projections
- No circular dependencies
## Configuration
### Run Options
```elixir
PropertyDamage.run(
model: MyApp.TestModel,
adapter: MyApp.TestAdapter,
# Generation
max_commands: 50, # Max commands per sequence
max_runs: 100, # Number of test runs
seed: 12345, # Deterministic seed (optional)
# Shrinking
shrink_timeout_ms: 30_000,
max_shrink_iterations: 1000,
# Idempotency
stutter_probability: 0.1, # Retry probability
# Adapter
adapter_config: %{base_url: "http://localhost:4000"}
)
```
### Model Callbacks
```elixir
defmodule MyModel do
@behaviour PropertyDamage.Model
# Required
def commands, do: [{CommandModule, weight: N}, ...]
def state_projection, do: MyStateProjection
def extra_projections, do: [MyExtraProjection, ...] # Optional
# Optional
def injectable_events, do: [] # For Adapter.Injector
def simulator, do: MySimulatorModule # Returns module implementing Simulator behaviour
def setup_once(config), do: :ok
def setup_each(config), do: :ok # Called before each run/shrink attempt
def teardown_each(config), do: :ok
def teardown_once(config), do: :ok
def terminate?(state, command, events), do: false # Custom termination
end
```
## Parallel Execution
PropertyDamage supports branching sequences for detecting race conditions and
concurrent bugs. Commands can execute in parallel branches, and the framework
verifies that results are linearizable.
### Enabling Branching Sequences
```elixir
PropertyDamage.run(
model: MyApp.TestModel,
adapter: MyApp.TestAdapter,
max_commands: 50,
max_runs: 100,
branching: [
branch_probability: 0.3, # Probability of creating branch points
max_branches: 3, # Max parallel branches
max_branch_length: 5, # Max commands per branch
min_prefix_length: 3 # Min commands before branching
]
)
```
### How It Works
A branching sequence has three parts:
1. **Prefix**: Commands executed sequentially before branching
2. **Branches**: Parallel command lists executed concurrently
3. **Suffix**: Commands executed after branches merge
```
Prefix: [cmd1, cmd2]
|
+--------+--------+
| |
Branch A: [cmd3a, cmd4a] | Branch B: [cmd3b]
| |
+--------+--------+
|
Suffix: [cmd5]
```
### Linearization Checking
After parallel execution, PropertyDamage verifies that the observed results
can be explained by some sequential ordering of the commands. If no valid
ordering exists, a `:linearization_failed` error is raised.
```elixir
alias PropertyDamage.Linearization
# Check complexity before verification
case Linearization.feasibility(branches) do
:ok -> IO.puts("Manageable linearization space")
{:warning, count} -> IO.puts("#{count} possible orderings")
end
# Count possible linearizations
count = Linearization.linearization_count([[cmd1, cmd2], [cmd3]])
# => 3 (possible orderings: [1,2,3], [1,3,2], [3,1,2])
```
### Shrinking Branching Sequences
The shrinker handles branching sequences with special strategies:
1. **Convert to linear**: If race not required for failure
2. **Remove branches**: Eliminate unnecessary parallel branches
3. **Shrink branches**: Remove commands within individual branches
4. **Shrink prefix/suffix**: Remove non-essential sequential commands
### Ref Constraints in Parallel Execution
Symbolic references follow strict rules in branching sequences:
- Refs from prefix can be used in any branch
- Refs from one branch **cannot** be used in another branch
- Refs from branches can be used in suffix
```elixir
# Valid: prefix ref used in branch
prefix = [CreateUser.new()] # Creates :user_ref
branches = [[GetUser.new(user_ref: :user_ref)], [UpdateUser.new(user_ref: :user_ref)]]
# Invalid: cross-branch ref usage
branches = [[CreateItem.new()], # Creates :item_ref
[ViewItem.new(item_ref: :item_ref)]] # ERROR: :item_ref not visible
```
## Eventual Consistency (Async Support)
For systems with eventual consistency, PropertyDamage provides probe and async
command semantics with automatic settle/retry logic.
### Command Semantics
Commands can declare their semantics via the `semantics/0` callback:
```elixir
defmodule MyTest.Commands.GetOrderStatus do
@behaviour PropertyDamage.Command
defstruct [:order_id]
# This is a probe - it queries state and may need to retry
def semantics, do: :probe
# Configure settle behavior
def settle_config do
%{
timeout_ms: 5_000, # Max time to wait
interval_ms: 200, # Time between retries
backoff: :exponential # :linear or :exponential
}
end
def read_only?, do: true
end
```
### Semantics Types
| Semantics | Purpose | Settle Behavior |
|-----------|---------|-----------------|
| `:sync` | Mutates state (default) | Execute once |
| `:probe` | Queries state | Retry until success or timeout |
| `:async` | Waits for async completion | Retry until complete |
| `:mock_config` | Configures mock services | Not sent to SUT |
### Adapter Integration
Adapters return settle-compatible results for probes:
```elixir
def execute(%GetOrderStatus{order_id: id}, ctx) do
case MyAPI.get_order(id) do
{:ok, %{status: "pending"}} ->
{:retry, :still_pending} # Keep trying
{:ok, order} ->
{:ok, order} # Success - stop retrying
{:error, :not_found} ->
{:retry, :not_found} # Keep trying
{:error, reason} ->
{:error, reason} # Hard failure - stop immediately
end
end
```
See [Async and Eventual Consistency Guide](guides/async_and_eventual_consistency.md)
for complete documentation including bridge commands, `Adapter.Injector`, and
handling async operations that require polling.
## Fault Injection (Nemesis)
Test system resilience by injecting faults like network partitions, latency,
and node crashes.
### Defining a Nemesis Command
```elixir
defmodule MyTest.Nemesis.PartitionNetwork do
@behaviour PropertyDamage.Nemesis
defstruct [:partition_type, :duration_ms]
@impl true
def precondition(_state), do: true
@impl true
def inject(%__MODULE__{partition_type: type}, ctx) do
:ok = Toxiproxy.partition(ctx.proxy, type)
{:ok, [%NetworkPartitioned{type: type}]}
end
@impl true
def restore(%__MODULE__{partition_type: type}, ctx) do
Toxiproxy.restore(ctx.proxy, type)
{:ok, [%NetworkRestored{type: type}]}
end
# Auto-restore after duration
def auto_restore?, do: true
def duration_ms(%__MODULE__{duration_ms: d}), do: d
end
```
### Using Nemesis in Models
Add nemesis commands with lower weights:
```elixir
def commands do
[
{CreateOrder, weight: 5},
{ProcessPayment, weight: 3},
{PartitionNetwork, weight: 1}, # Fault injection
{InjectLatency, weight: 1}
]
end
```
### Built-in Nemesis Operations
PropertyDamage includes ready-to-use nemesis operations for common fault injection scenarios:
#### Network Operations
| Operation | Description |
|-----------|-------------|
| `NetworkLatency` | Add latency (50-500ms) with optional jitter |
| `NetworkPartition` | Block traffic (full, upstream, downstream, asymmetric) |
| `PacketLoss` | Drop percentage of packets (5-50%) |
```elixir
# Add network latency
alias PropertyDamage.Nemesis.NetworkLatency
def commands do
[
{CreateOrder, weight: 5},
{NetworkLatency, weight: 1} # Uses defaults: 100ms latency, 5s duration
]
end
# Or customize
%NetworkLatency{latency_ms: 200, jitter_ms: 50, duration_ms: 10_000}
```
#### Resource Operations
| Operation | Description |
|-----------|-------------|
| `MemoryPressure` | Allocate memory to create pressure (bulk or fragmented) |
| `CPUStress` | Spawn busy-loop processes to stress schedulers |
| `ResourceExhaustion` | Exhaust file descriptors, ports, ETS tables, or processes |
```elixir
alias PropertyDamage.Nemesis.{MemoryPressure, CPUStress}
# Create memory pressure (100MB)
%MemoryPressure{megabytes: 100, allocation_pattern: :bulk}
# Create CPU stress (intensity 1-10)
%CPUStress{intensity: 5, schedulers: :all, duration_ms: 5000}
```
#### Time Operations
| Operation | Description |
|-----------|-------------|
| `ClockSkew` | Shift virtual time forward/backward with optional drift |
```elixir
alias PropertyDamage.Nemesis.ClockSkew
# Jump 1 minute into the future
%ClockSkew{skew_ms: 60_000, mode: :instant}
# Gradual drift (10% fast)
%ClockSkew{skew_ms: 0, drift_rate: 1.1, mode: :gradual}
# In your adapter, use the virtual clock:
def get_current_time do
ClockSkew.now() # Returns skewed time when active
end
```
#### Process Operations
| Operation | Description |
|-----------|-------------|
| `ProcessKill` | Kill processes by name, pattern, or randomly |
| `SlowIO` | Add artificial delay to I/O operations |
#### Security Operations
| Operation | Description |
|-----------|-------------|
| `CertificateExpiry` | Simulate TLS certificate failures (expired, wrong host, self-signed, revoked)
```elixir
alias PropertyDamage.Nemesis.CertificateExpiry
# Simulate expired certificate
%CertificateExpiry{failure_type: :expired}
# Simulate hostname mismatch
%CertificateExpiry{failure_type: :wrong_host, target: :api}
# In your adapter:
def connect(host, port, opts) do
if CertificateExpiry.should_fail?() do
CertificateExpiry.get_ssl_error() # Returns {:error, {:tls_alert, ...}}
else
:ssl.connect(host, port, opts)
end
end
```
#### Process Operations (continued)
```elixir
alias PropertyDamage.Nemesis.{ProcessKill, SlowIO}
# Kill a specific named process
%ProcessKill{target: {:name, :my_worker}, signal: :kill}
# Kill random processes from supervised children
%ProcessKill{target: {:supervised_by, MyApp.WorkerSupervisor}}
# Slow down I/O operations
%SlowIO{delay_ms: 100, target: :all} # :reads, :writes, or :all
# In your adapter:
def read_data(path) do
if SlowIO.should_delay?(:reads), do: SlowIO.apply_delay()
File.read(path)
end
```
#### Integration with Toxiproxy
Network operations integrate with [Toxiproxy](https://github.com/Shopify/toxiproxy) when available:
```elixir
# Configure in adapter context
context = %{
toxiproxy: %{
proxy_name: "my_service",
api_url: "http://localhost:8474"
}
}
# Nemesis operations will automatically use Toxiproxy
# Falls back to simulated mode if not configured
```
### Adjusting Invariants During Faults
```elixir
@trigger every: 1
def assert_latency_sla(state, _cmd_or_event) do
# Skip SLA check during partition
unless Map.get(state.active_faults, :network_partition) do
unless state.last_latency_ms < 100 do
PropertyDamage.fail!("SLA violated", latency_ms: state.last_latency_ms)
end
end
end
```
## Production Forensics
Replay production event logs through your model to analyze incidents.
### Basic Usage
```elixir
# Fetch events from your observability system
{:ok, events} = ProductionLogs.fetch(trace_id: "abc123")
# Replay through model projections
result = PropertyDamage.Forensics.analyze(
events: events,
model: OrderModel
)
case result do
{:ok, %{final_state: state, events_processed: n}} ->
IO.puts("Processed #{n} events - no violations")
{:error, failure} ->
IO.puts("Violation at event ##{failure.failure_step}")
IO.puts(PropertyDamage.Forensics.format_report(failure))
end
```
### Event Mapping
Translate production event formats to your model's event structs:
```elixir
defmodule MyEventMapping do
@behaviour PropertyDamage.Forensics.EventMapping
@impl true
def map(%{"type" => "order.created", "payload" => p}) do
{:ok, %OrderCreated{
order_id: p["order_id"],
amount: p["total"]
}}
end
def map(%{"type" => "internal.metric"}), do: :skip
def map(_), do: {:skip, :unknown_event}
end
# Use with analyze
Forensics.analyze(
events: production_events,
model: OrderModel,
event_mapping: MyEventMapping
)
```
### Generate Regression Tests
Create test cases from production failures:
```elixir
{:error, failure} = Forensics.analyze(events: events, model: MyModel)
test_code = Forensics.generate_regression_test(failure, MyModel)
File.write!("test/regressions/incident_2025_01_15_test.exs", test_code)
```
## Liveness Checking
Detect deadlocks, livelocks, and starvation with the Liveness projection.
### Configuration
```elixir
defmodule MyModel do
def extra_projections do
[
{PropertyDamage.Model.Projection.Liveness, [
max_pending_duration_ms: 10_000,
check_interval: 10,
required_completions: %{
CreateTransfer => [TransferCompleted, TransferFailed],
CreateOrder => [OrderConfirmed, OrderRejected]
}
]}
]
end
end
```
### How It Works
1. **Track starts**: When `CreateTransfer` executes, mark operation as pending
2. **Track completions**: When `TransferCompleted` or `TransferFailed` arrives, mark complete
3. **Check timeouts**: Periodically check for operations pending too long
4. **Report stuck**: If any operation exceeds `max_pending_duration_ms`, fail
### What It Detects
| Issue | Symptom |
|-------|---------|
| Deadlock | Operations never complete |
| Livelock | System busy but no progress |
| Starvation | Some operations always timeout |
## Load Testing
Generate realistic load against your system using SPBT-generated traffic. Unlike synthetic benchmarks, each simulated user session follows valid state transitions with command weights that model real usage patterns.
### Basic Usage
```elixir
{:ok, report} = PropertyDamage.LoadTest.run(
model: MyModel,
adapter: HTTPAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
concurrent_users: 50,
duration: {2, :minutes}
)
# Print formatted report
IO.puts(PropertyDamage.LoadTest.format(report, :terminal))
```
### Advanced Configuration
```elixir
{:ok, report} = PropertyDamage.LoadTest.run(
model: MyModel,
adapter: HTTPAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
# Load configuration
concurrent_users: 100,
duration: {5, :minutes},
# Ramp strategies: :immediate, {:linear, duration}, {:step, N, interval}, {:exponential, duration}
ramp_up: {:linear, {30, :seconds}},
ramp_down: {:linear, {10, :seconds}},
# Session behavior
commands_per_session: {10, 50}, # {min, max} commands per sequence
think_time: {100, 500}, # {min, max} ms between commands
# Live metrics callback (called every interval)
metrics_interval: {1, :seconds},
on_metrics: fn m ->
IO.puts("RPS: #{m.requests_per_second}, p95: #{m.latency_p95}ms, errors: #{m.error_rate}%")
end,
# Called when test completes
on_complete: fn report ->
PropertyDamage.LoadTest.save(report, "load_test.md", :markdown)
end,
# Assertion mode: :disabled (default), :record, or :log
assertion_mode: :record # Track assertion failures in metrics
)
```
### Ramp Strategies
| Strategy | Description |
|----------|-------------|
| `:immediate` | All users start at once |
| `{:linear, {30, :seconds}}` | Gradually add users over 30 seconds |
| `{:step, 4, {15, :seconds}}` | Add users in 4 steps, 15 seconds apart |
| `{:exponential, {1, :minutes}}` | Exponential growth over 1 minute |
### Metrics Collected
- **Throughput**: Total requests, requests/second
- **Latency**: p50, p95, p99, min, max, mean (in milliseconds)
- **Errors**: Total count, error rate, breakdown by type
- **Assertions**: Failures count, rate, by assertion name (when enabled via `assertion_mode`)
- **Per-Command**: Individual metrics for each command type
- **History**: Time series for trend analysis
### Report Formats
```elixir
# Terminal output with ASCII charts
IO.puts(PropertyDamage.LoadTest.format(report, :terminal))
# Markdown for documentation
PropertyDamage.LoadTest.save(report, "report.md", :markdown)
# JSON for programmatic analysis
json = PropertyDamage.LoadTest.format(report, :json)
```
### Async Control
```elixir
# Start without blocking
{:ok, runner} = PropertyDamage.LoadTest.start(opts)
# Monitor progress
status = PropertyDamage.LoadTest.status(runner)
# => %{phase: :steady, active_sessions: 50, progress_percent: 45.0, ...}
# Get live metrics
metrics = PropertyDamage.LoadTest.get_metrics(runner)
# Stop early if needed
{:ok, report} = PropertyDamage.LoadTest.stop(runner)
# Or wait for completion
{:ok, report} = PropertyDamage.LoadTest.await(runner)
```
## Visual Sequence Diagrams
Generate sequence diagrams from failure reports to visualize command flows and pinpoint failures.
### Supported Formats
| Format | Description | Use Case |
|--------|-------------|----------|
| `:mermaid` | Mermaid syntax | GitHub, GitLab, Notion |
| `:plantuml` | PlantUML syntax | Enterprise docs, IDE plugins |
| `:websequence` | sequencediagram.org | Quick sharing |
### Basic Usage
```elixir
# From a failure report
{:error, report} = PropertyDamage.run(model: MyModel, adapter: MyAdapter)
diagram = PropertyDamage.Diagram.from_failure_report(report, :mermaid)
IO.puts(diagram)
# From sequence and event log
diagram = PropertyDamage.Diagram.generate(sequence, event_log, :plantuml,
title: "Account Creation Flow",
highlight_failure: true
)
# Save to file
PropertyDamage.Diagram.save(diagram, "failure_diagram", :mermaid)
# Creates: failure_diagram.md
```
### Example Output (Mermaid)
```mermaid
sequenceDiagram
title Failure: NonNegativeBalance (seed: 12345)
participant Test
participant SUT
Test->>SUT: CreateAccount(name: "Alice")
SUT-->>Test: AccountCreated(id: "acc_123", balance: 0)
Test->>SUT: Deposit(amount: 100)
SUT-->>Test: DepositSucceeded(new_balance: 100)
Note over Test,SUT: ❌ FAILURE at command 2
Test-xSUT: Withdraw(amount: 200)
Note right of SUT: Balance went negative
```
### Options
- `:title` - Custom diagram title
- `:show_state` - Include state participant
- `:max_value_length` - Truncate long values (default: 50)
- `:highlight_failure` - Visual failure markers (default: true)
## Diff-Based Debugging
Compare passing and failing test runs to identify exactly what changed.
### Comparing Traces
```elixir
# Compare two failure reports
passing = PropertyDamage.run(model: M, adapter: A, seed: 123) |> elem(1)
failing = PropertyDamage.run(model: M, adapter: A, seed: 456) |> elem(1)
diff = PropertyDamage.Diff.compare_reports(passing, failing)
IO.puts(PropertyDamage.Diff.format(diff))
```
### Output Formats
```elixir
# Terminal (default) - ASCII boxes
PropertyDamage.Diff.format(diff, format: :terminal)
# Markdown - tables for documentation
PropertyDamage.Diff.format(diff, format: :markdown)
# JSON - for programmatic analysis
PropertyDamage.Diff.format(diff, format: :json)
```
### Example Terminal Output
```
╔══════════════════════════════════════════════════════════════════════╗
║ EXECUTION DIFF ║
╚══════════════════════════════════════════════════════════════════════╝
Summary: Divergence at command 2: Withdraw. Events differ.
┌─ Event Differences ─────────────────────────────────────────────────┐
│ Cmd 2 ≠: LEFT: [WithdrawSucceeded] │
│ RIGHT: [WithdrawFailed] │
└──────────────────────────────────────────────────────────────────────┘
┌─ State Differences ─────────────────────────────────────────────────┐
│ After command 2: │
│ balance: -50 → 100 │
└──────────────────────────────────────────────────────────────────────┘
```
### What It Detects
| Difference | Description |
|------------|-------------|
| Command divergence | Different commands in sequence |
| Event differences | Different events produced |
| State changes | Field values that differ |
| Missing commands | Commands present in one trace but not other |
## Failure Export Hub
Convert failure reports into portable artifacts for sharing, regression testing, and interactive exploration.
### Export Formats
| Format | Output | Use Case |
|--------|--------|----------|
| ExUnit | `.exs` test file | CI regression protection |
| Elixir Script | `.exs` standalone | Elixir developers |
| Bash/curl Script | `.sh` with curl | Any developer with a shell |
| Python Script | `.py` with requests | Python teams |
| LiveBook | `.livemd` notebook | Interactive debugging |
### Basic Usage
```elixir
{:error, failure} = PropertyDamage.run(model: MyModel, adapter: MyAdapter)
# Generate ExUnit regression test
test_code = PropertyDamage.Export.to_exunit(failure)
File.write!("test/regressions/seed_#{failure.seed}_test.exs", test_code)
# Generate standalone scripts
elixir_script = PropertyDamage.Export.to_script(failure, :elixir,
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter
)
curl_script = PropertyDamage.Export.to_script(failure, :curl,
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter
)
python_script = PropertyDamage.Export.to_script(failure, :python,
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter
)
# Generate LiveBook notebook
notebook = PropertyDamage.Export.to_livebook(failure,
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter
)
```
### File Operations
```elixir
# Save single format
{:ok, path} = PropertyDamage.Export.save(failure, "exports/", :exunit)
# => {:ok, "exports/reproduce_512902757.exs"}
{:ok, path} = PropertyDamage.Export.save(failure, "exports/", {:script, :curl},
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter
)
# => {:ok, "exports/reproduce_512902757.sh"}
# Save all formats at once
{:ok, paths} = PropertyDamage.Export.save_all(failure, "exports/",
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter,
script_languages: [:elixir, :curl, :python]
)
# => {:ok, %{
# exunit: "exports/reproduce_512902757.exs",
# livebook: "exports/reproduce_512902757.livemd",
# script_elixir: "exports/reproduce_512902757.exs",
# script_curl: "exports/reproduce_512902757.sh",
# script_python: "exports/reproduce_512902757.py"
# }}
```
### HTTPSpec for Script Generation
For scripts to make HTTP calls, your adapter needs to implement `http_spec/2`:
```elixir
defmodule MyHTTPAdapter do
@behaviour PropertyDamage.Adapter
alias PropertyDamage.Export.HTTPSpec
# Standard adapter callbacks...
def execute(cmd, ctx), do: # ...
# Optional: HTTP mapping for export
def http_spec(%CreateAccount{currency: curr}, _ctx) do
%HTTPSpec{
method: :post,
path: "/api/accounts",
body: %{currency: curr}
}
end
def http_spec(%CreditAccount{account_ref: ref, amount: amt}, _ctx) do
%HTTPSpec{
method: :post,
path: "/api/accounts/:account_id/credit",
path_params: %{account_id: ref},
body: %{amount: amt}
}
end
def http_spec(%DebitAccount{account_ref: ref, amount: amt}, _ctx) do
%HTTPSpec{
method: :post,
path: "/api/accounts/:account_id/debit",
path_params: %{account_id: ref},
body: %{amount: amt}
}
end
end
```
### LiveBook Features
Generated LiveBook notebooks include:
- **Setup section**: Installs dependencies (Req, Jason)
- **State tracking**: Tracks refs and model state alongside execution
- **Step-by-step commands**: Each command in its own cell with HTTP call
- **Failure marker**: Highlights the command that caused the failure
- **Exploration section**: Space to experiment with variations
```elixir
# Exclude exploration section if not needed
notebook = PropertyDamage.Export.to_livebook(failure,
base_url: "http://localhost:4000",
adapter: MyHTTPAdapter,
include_exploration: false
)
```
### Example Generated Script (curl)
```bash
#!/bin/bash
# Failure Reproduction Script
# Generated: 2025-12-26T14:30:00Z
# Failure: NonNegativeBalance check failed
# Seed: 512902757
set -e
BASE_URL="${BASE_URL:-http://localhost:4000}"
echo "=== Step 1: CreateAccount ==="
RESP1=$(curl -s -X POST "$BASE_URL/api/accounts" \
-H "Content-Type: application/json" \
-d '{"currency": "USD"}')
echo "$RESP1"
REF_account_0=$(echo "$RESP1" | jq -r '.data.id // .id // empty')
echo "=== Step 2: CreditAccount ==="
RESP2=$(curl -s -X POST "$BASE_URL/api/accounts/$REF_account_0/credit" \
-H "Content-Type: application/json" \
-d '{"amount": 100}')
echo "$RESP2"
echo "=== Step 3: DebitAccount (FAILURE POINT) ==="
RESP3=$(curl -s -X POST "$BASE_URL/api/accounts/$REF_account_0/debit" \
-H "Content-Type: application/json" \
-d '{"amount": 200}')
echo "$RESP3"
```
## Mutation Testing
Verify that your property tests are actually effective at catching bugs. Mutation testing injects faults into adapter responses and checks if your tests detect them.
### Basic Usage
```elixir
{:ok, report} = PropertyDamage.Mutation.run(
model: MyModel,
adapter: MyAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
target_score: 0.80
)
# Check results
IO.puts(PropertyDamage.Mutation.format(report))
# Get detailed analysis
if not PropertyDamage.Mutation.passes?(report) do
analysis = PropertyDamage.Mutation.analyze(report)
IO.puts(PropertyDamage.Mutation.Analysis.format(analysis))
end
```
### Understanding Results
- **Killed mutant**: Your tests detected the simulated bug (good)
- **Survived mutant**: Your tests missed the bug (bad - weak tests)
- **Mutation score**: `killed / total` - aim for 80%+
### Mutation Operators
| Operator | Description |
|----------|-------------|
| `:value` | Mutates numeric/string values (zero, negate, off-by-one) |
| `:omission` | Removes fields from events |
| `:status` | Changes success/error outcomes |
| `:event` | Modifies event contents and structure |
| `:boundary` | Pushes values to edge cases (0, -1, max, nil) |
### Options
```elixir
PropertyDamage.Mutation.run(
model: MyModel,
adapter: MyAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
# Which operators to use (default: all)
operators: [:value, :omission, :status],
# Mutations per command type (default: 5)
mutations_per_command: 10,
# PropertyDamage runs per mutation (default: 10)
max_runs: 20,
# Target score to pass (default: 0.80)
target_score: 0.80,
# Timeout per mutation test (default: 30000)
timeout_ms: 60_000,
# Print progress
verbose: true
)
```
### Example Report
```
╔══════════════════════════════════════════════════════════════════════╗
║ MUTATION TESTING REPORT ║
╚══════════════════════════════════════════════════════════════════════╝
Mutation Score: 85% (17/20 killed) ✓ PASS (target: 80%)
┌─ By Command ────────────────────────────────────────────────────────┐
│ CreateAccount ████████████████████ 100% (5/5) │
│ CreditAccount ██████████████░░░░░░ 86% (6/7) │
│ DebitAccount ████████████░░░░░░░░ 75% (6/8) │
└─────────────────────────────────────────────────────────────────────┘
┌─ Survived Mutations (Weaknesses) ───────────────────────────────────┐
│ 1. CreditAccount: amount 100→99 (off-by-one not detected) │
│ 2. DebitAccount: omitted 'timestamp' field not detected │
└─────────────────────────────────────────────────────────────────────┘
```
### Analysis & Suggestions
```elixir
analysis = PropertyDamage.Mutation.analyze(report)
# Weak commands (low kill rates)
for {cmd, score} <- analysis.weak_commands do
IO.puts("#{cmd}: #{Float.round(score * 100, 1)}%")
end
# Fields that aren't being validated
IO.inspect(analysis.unchecked_fields)
# Actionable suggestions
for suggestion <- analysis.suggestions do
IO.puts("• #{suggestion}")
end
```
## Property & Invariant Suggestions
Automatically analyze your model and get suggestions for missing checks and invariants.
### Basic Usage
```elixir
# Analyze a model
suggestions = PropertyDamage.Suggestions.analyze(MyModel)
# Print formatted suggestions
IO.puts(PropertyDamage.Suggestions.format(suggestions))
# Get high-priority suggestions only
high_priority = PropertyDamage.Suggestions.high_priority(suggestions)
# Filter by field or event
balance_suggestions = PropertyDamage.Suggestions.for_field(suggestions, :balance)
```
### What It Detects
The suggestion system examines your events and existing checks to identify gaps:
| Pattern Type | Fields Detected | Suggested Checks |
|--------------|-----------------|------------------|
| Numeric | `balance`, `amount`, `total`, `count`, `price` | Non-negative, reasonable bounds |
| Currency | `currency`, `currency_code` | Currency consistency across operations |
| Reference | `*_ref`, `*_id` | Reference exists, reference valid |
| Status | `status`, `state`, `phase` | Valid status values, valid transitions |
| Timestamp | `*_at`, `created_at`, `updated_at` | Timestamp ordering, not future |
### Example Output
```
╔════════════════════════════════════════════════════════════════════════╗
║ PROPERTY & INVARIANT SUGGESTIONS ║
╚════════════════════════════════════════════════════════════════════════╝
Model: MyApp.TestModel
Events analyzed: 12
Existing checks: 3
Field coverage: 40%
Suggestions: 8 total
▸ 2 high priority (should address)
▸ 4 medium priority (consider adding)
▸ 2 low priority (nice to have)
┌─ Suggestions ──────────────────────────────────────────────────────────┐
│ ▶ HIGH PRIORITY ───────────────────────────────────────────────────────│
│ 1. Add non-negative check for balance (balance) │
│ 2. Add currency consistency check (currency) │
│ │
│ ▶ MEDIUM PRIORITY ─────────────────────────────────────────────────────│
│ 3. Add reference existence check for account_ref (account_ref) │
│ 4. Add status transition validation for status (status) │
└────────────────────────────────────────────────────────────────────────┘
```
### Output Formats
```elixir
# Terminal - ASCII boxes (default)
PropertyDamage.Suggestions.format(suggestions, :terminal)
# Markdown - tables with example code
PropertyDamage.Suggestions.format(suggestions, :markdown)
# JSON - for programmatic analysis
PropertyDamage.Suggestions.format(suggestions, :json)
```
### Options
```elixir
PropertyDamage.Suggestions.analyze(MyModel,
# Include low-priority suggestions (default: true)
include_low_priority: true,
# Maximum suggestions to return (default: 20)
max_suggestions: 10,
# Focus on specific areas (default: :all)
# Options: :all, :numeric, :references, :consistency
focus: :numeric
)
```
### Integration with Mutation Testing
Use suggestions to improve your mutation testing score:
```elixir
# Run mutation testing
{:ok, mutation_report} = PropertyDamage.Mutation.run(model: MyModel, adapter: MyAdapter)
# If score is low, get suggestions for improvement
if mutation_report.mutation_score < 0.8 do
suggestions = PropertyDamage.Suggestions.analyze(MyModel)
IO.puts(PropertyDamage.Suggestions.format(suggestions, :markdown))
end
```
## Failure Intelligence
Analyze, cluster, and verify fixes for failures using fingerprinting and similarity detection.
### Pattern Detection
When you have multiple failures, identify patterns to find root causes:
```elixir
# Analyze a set of failures
failures = [failure1, failure2, failure3, ...]
analysis = PropertyDamage.FailureIntelligence.analyze(failures)
IO.puts(analysis.pattern_summary)
# => "Analyzed 15 failures:
# - 3 distinct patterns (12 failures)
# - 3 unique failures (no pattern match)
#
# Top patterns:
# - Check failure in :balance_valid during DebitAccount (5 occurrences)
# - Invariant violation during CreditAccount (4 occurrences)"
# Get individual clusters
for cluster <- analysis.clusters do
IO.puts("Pattern: #{cluster.pattern.description}")
IO.puts("Occurrences: #{cluster.size}")
end
```
### Similarity Detection
Compare failures to identify duplicates and related issues:
```elixir
# Check if two failures are similar
if PropertyDamage.FailureIntelligence.similar?(failure1, failure2) do
IO.puts("These failures likely have the same root cause")
end
# Get similarity score (0.0 to 1.0)
score = PropertyDamage.FailureIntelligence.similarity_score(failure1, failure2)
# => 0.85
# Detailed comparison
comparison = PropertyDamage.FailureIntelligence.compare(failure1, failure2)
# => %{
# score: 0.85,
# breakdown: %{failure_type: 1.0, check_name: 1.0, command_type: 0.8, ...},
# is_similar: true
# }
# Find similar failures from a list
similar = PropertyDamage.FailureIntelligence.find_similar(new_failure, known_failures,
threshold: 0.80,
limit: 5
)
```
### Fingerprinting
Fingerprints capture the essential characteristics of a failure:
```elixir
# Get a fingerprint for quick comparison
fingerprint = PropertyDamage.FailureIntelligence.fingerprint(failure)
# => %Fingerprint{
# failure_type: :check_failed,
# check_name: :balance_non_negative,
# command_type: DebitAccount,
# event_types: [AccountDebited],
# sequence_shape: [CreateAccount, CreditAccount, DebitAccount],
# error_category: :check_violation,
# ...
# }
# Get a short hash for display
hash = PropertyDamage.FailureIntelligence.fingerprint_hash(failure)
# => "a1b2c3d4"
# Group failures by fingerprint
groups = PropertyDamage.FailureIntelligence.group_by_fingerprint(failures)
for {hash, group} <- groups do
IO.puts("Hash #{hash}: #{length(group)} failures")
end
# Find potential duplicates (> 90% similar)
duplicates = PropertyDamage.FailureIntelligence.find_duplicates(failures)
for {f1, f2, score} <- duplicates do
IO.puts("Seeds #{f1.seed} and #{f2.seed} are #{score * 100}% similar")
end
```
### Fix Verification
When you believe a bug is fixed, verify the fix is robust:
```elixir
result = PropertyDamage.FailureIntelligence.verify_fix(failure, MyModel,
adapter: MyAdapter,
adapter_config: %{base_url: "http://localhost:4000"},
max_variations: 20 # Test 20 seed variations
)
case result.status do
:verified ->
IO.puts("Fix verified with #{result.confidence * 100}% confidence")
:still_failing ->
IO.puts("Original failure still reproduces!")
:partially_fixed ->
IO.puts("Fix incomplete. #{result.variations_failed} variations still fail")
:flaky ->
IO.puts("Intermittent failures detected. May be timing-related.")
end
# Format for display
IO.puts(PropertyDamage.FailureIntelligence.format_verification(result))
```
### Verification Result
```elixir
%{
status: :verified | :still_failing | :partially_fixed | :flaky,
original_seed: 12345,
original_passes: true,
variations_run: 20,
variations_passed: 18,
variations_failed: 2,
failed_variations: [12346, 12400],
confidence: 0.95,
summary: "Fix verified! Original seed and all 18 variations pass."
}
```
### Quick Checks
```elixir
# Quick check if a seed still fails
if PropertyDamage.FailureIntelligence.still_fails?(12345, MyModel, MyAdapter) do
IO.puts("Bug not fixed yet!")
end
# Verify multiple fixes at once
results = PropertyDamage.FailureIntelligence.verify_fixes(failures, MyModel,
adapter: MyAdapter
)
for {failure, result} <- results do
IO.puts("Seed #{failure.seed}: #{result.status}")
end
```
### Example Workflow
```elixir
# 1. Collect failures from test runs
failures = collect_failures_from_ci()
# 2. Analyze to find patterns
analysis = PropertyDamage.FailureIntelligence.analyze(failures)
IO.puts("Found #{length(analysis.clusters)} distinct failure patterns")
# 3. Work on the most common pattern first
if pattern = analysis.most_common_pattern do
IO.puts("Most common: #{pattern.description}")
end
# 4. After fixing, verify the fix
{:ok, fixed_failure} = PropertyDamage.load_failure("failures/issue_123.pd")
result = PropertyDamage.FailureIntelligence.verify_fix(fixed_failure, MyModel,
adapter: MyAdapter,
max_variations: 50
)
if result.status == :verified do
IO.puts("Fix confirmed! Safe to merge.")
PropertyDamage.delete_failure("failures/issue_123.pd")
end
```
## Automatic Regression Management
Automatically save failures to seed libraries and generate regression tests when bugs are found.
### Basic Usage
Use the `:regression` option in `PropertyDamage.run/1`:
```elixir
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
regression: [
save_failures: "failures/", # Save failure files
seed_library: "seeds.json", # Add to seed library
generate_tests: "test/regressions/", # Generate ExUnit tests
tags: [:auto_detected], # Tags for seed library
dedup: true # Skip similar failures
]
)
```
When a failure is found, PropertyDamage will automatically:
1. Save the failure file to the specified directory
2. Add the seed to your seed library
3. Generate an ExUnit regression test
### Deduplication
Avoid noise from multiple runs finding the same bug:
```elixir
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
regression: [
save_failures: "failures/",
dedup: true, # Enable deduplication
dedup_threshold: 0.90 # 90% similarity threshold
]
)
```
### Using Handlers Directly
For more control, use handlers with `:on_failure`:
```elixir
alias PropertyDamage.Regression
# Single handler
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
on_failure: Regression.save_failure("failures/")
)
# Compose multiple handlers
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
on_failure: Regression.compose([
Regression.save_failure("failures/"),
Regression.add_to_library("seeds.json", tags: [:critical]),
fn report -> Logger.warning("Failure found: #{report.seed}") end
])
)
```
### Batch Processing
Process multiple failures at once with deduplication:
```elixir
failures = [failure1, failure2, failure3]
results = PropertyDamage.Regression.process_batch(failures,
seed_library: "seeds.json",
dedup: true,
dedup_threshold: 0.90
)
summary = PropertyDamage.Regression.batch_summary(results)
IO.puts(PropertyDamage.Regression.format_batch_summary(summary))
```
### Options
| Option | Description |
|--------|-------------|
| `:save_failures` | Directory to save failure files |
| `:seed_library` | Path to seed library JSON file |
| `:generate_tests` | Directory for ExUnit test files |
| `:tags` | Tags for seed library entries (default: `[:auto_detected]`) |
| `:description` | Description for seed library entries |
| `:dedup` | Enable deduplication (default: false) |
| `:dedup_threshold` | Similarity threshold (default: 0.90) |
| `:dedup_source` | Where to check: `:failures`, `:library`, or `:both` |
| `:verbose` | Print actions taken (default: false) |
## Differential Testing
Compare multiple implementations by running the same command sequences against them.
Use cases include oracle testing, performance comparison, migration validation, and
regression testing.
### Basic Usage
```elixir
# Oracle testing - compare against reference implementation
PropertyDamage.Differential.run(
model: MyModel,
targets: [
{ReferenceAdapter, role: :reference},
{SUTAdapter, name: "new-impl"}
],
compare: :correctness,
max_runs: 100
)
# Performance comparison
PropertyDamage.Differential.run(
model: MyModel,
targets: [
{RedisAdapter, name: "redis-backend"},
{PostgresAdapter, name: "postgres-backend"}
],
compare: :performance
)
# Same adapter, different configurations (e.g., staging vs prod)
PropertyDamage.Differential.run(
model: MyModel,
targets: [
{HTTPAdapter, role: :reference, opts: [base_url: "https://prod.example.com"]},
{HTTPAdapter, name: "staging", opts: [base_url: "https://staging.example.com"]}
],
compare: :correctness
)
```
### Time-Separated Comparison
Save results now, compare later:
```elixir
# Export baseline before deployment
PropertyDamage.Differential.run(
model: MyModel,
targets: [{ProdAdapter, name: "v2.3"}],
compare: :performance,
export_to: "baselines/v2.3.json",
seed: 12345
)
# Compare against baseline after deployment
PropertyDamage.Differential.run(
model: MyModel,
targets: [{ProdAdapter, name: "v2.4"}],
compare: :performance,
baseline: "baselines/v2.3.json"
)
```
### Equivalence Strategies
```elixir
# Exact matching (default)
compare: :correctness, equivalence: :exact
# Structural - ignores IDs, timestamps, UUIDs
compare: :correctness, equivalence: :structural
# Custom comparison function
compare: :correctness, equivalence: fn ref, target ->
ref.status == target.status && ref.amount == target.amount
end
```
See [Differential Testing Guide](guides/differential_testing.md) for complete documentation.
## Telemetry Dashboard
PropertyDamage emits telemetry events during test execution that can be used for real-time monitoring via a LiveView dashboard.
### Setup
1. **Add the Collector to your application supervisor:**
```elixir
# In your application.ex
def start(_type, _args) do
children = [
# ... your other children
PropertyDamage.Telemetry.Collector
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
```
2. **Create a LiveView for the dashboard:**
```elixir
defmodule MyAppWeb.PropertyDamageDashboardLive do
use MyAppWeb, :live_view
alias PropertyDamage.Telemetry.{Collector, Dashboard}
def mount(_params, _session, socket) do
if connected?(socket) do
Collector.subscribe()
end
state = Collector.get_state()
{:ok,
assign(socket,
page_title: "PropertyDamage Dashboard",
state: state,
view_mode: :overview
)}
end
def handle_info({:telemetry_update, _event_type, _data, state}, socket) do
{:noreply, assign(socket, :state, state)}
end
def handle_event("reset", _params, socket) do
Collector.reset()
{:noreply, socket}
end
def handle_event("set_view_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :view_mode, String.to_existing_atom(mode))}
end
def render(assigns) do
Dashboard.render(assigns)
end
end
```
3. **Add a route:**
```elixir
# In your router.ex
live "/property-damage", PropertyDamageDashboardLive
```
### Dashboard Views
| View | Description |
|------|-------------|
| **Overview** | Cards showing runs/commands/checks/shrinking stats, current run progress, pass rate |
| **Commands** | Table with command counts, average timing, total timing |
| **Checks** | Table with check pass/fail counts and rates |
| **Events** | Timeline of recent telemetry events |
### Telemetry Events
PropertyDamage emits these telemetry events:
| Event | Description |
|-------|-------------|
| `[:property_damage, :run, :start]` | Test run started |
| `[:property_damage, :run, :stop]` | Test run completed |
| `[:property_damage, :run, :exception]` | Test run crashed |
| `[:property_damage, :sequence, :start]` | Sequence execution started |
| `[:property_damage, :sequence, :stop]` | Sequence execution completed |
| `[:property_damage, :command, :start]` | Command execution started |
| `[:property_damage, :command, :stop]` | Command execution completed |
| `[:property_damage, :check, :start]` | Check evaluation started |
| `[:property_damage, :check, :stop]` | Check evaluation completed |
| `[:property_damage, :shrink, :start]` | Shrinking started |
| `[:property_damage, :shrink, :iteration]` | Shrink iteration completed |
| `[:property_damage, :shrink, :stop]` | Shrinking completed |
### Custom Telemetry Handlers
You can attach custom handlers to these events:
```elixir
:telemetry.attach(
"my-metrics-handler",
[:property_damage, :command, :stop],
fn _event, measurements, metadata, _config ->
# Record command execution time to your metrics system
MyMetrics.histogram(
"property_damage.command.duration",
measurements.duration,
tags: [command: metadata.command]
)
end,
nil
)
```
### Collector API
```elixir
# Get current aggregated state
state = PropertyDamage.Telemetry.Collector.get_state()
# Subscribe to updates (for LiveView)
PropertyDamage.Telemetry.Collector.subscribe()
# Reset all counters
PropertyDamage.Telemetry.Collector.reset()
```
## Livebook Integration
PropertyDamage includes rich Livebook integration for interactive exploration of test results.
### Setup
In your Livebook notebook:
```elixir
Mix.install([
{:property_damage, "~> 0.1"},
{:kino, "~> 0.12"},
{:vega_lite, "~> 0.1"},
{:kino_vega_lite, "~> 0.1"}
])
```
### Quick Start
```elixir
alias PropertyDamage.Livebook
# Run tests and visualize results
result = PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
max_runs: 100
)
# Create main dashboard with tabs
Livebook.visualize(result)
```
### Available Widgets
| Widget | Description |
|--------|-------------|
| `visualize/1` | Main tabbed dashboard with overview, commands, state, failures |
| `results_table/1` | Sortable DataTable of command execution history |
| `command_stats/1` | Per-command execution counts and timing statistics |
| `state_timeline/1` | Visual progression of state changes |
| `failure_details/1` | Detailed failure analysis with shrunk sequence |
| `live_monitor/0` | Real-time telemetry streaming widget |
| `command_stepper/1` | Step through command execution interactively |
| `state_diff/1` | Compare model vs actual state |
| `explore_failure/1` | Interactive failure explorer with tabs |
### Charts and Visualizations
With VegaLite installed, you get rich interactive charts:
```elixir
alias PropertyDamage.Livebook.Charts
# Bar chart of command execution counts
Charts.command_bar_chart(result)
# Histogram of command timing distribution
Charts.timing_histogram(result)
# Pie chart of success/failure rate
Charts.success_pie_chart(result)
# Timeline showing execution progression
Charts.execution_timeline(result)
# Heatmap of command transitions
Charts.command_transition_heatmap(result)
# Check results by type
Charts.check_results_chart(result)
```
### Live Visualization
Run tests with live progress updates:
```elixir
# Displays real-time progress as tests run
result = Livebook.run_with_visualization(
model: MyModel,
adapter: MyAdapter,
max_runs: 100,
max_commands: 20
)
```
### Interactive Command Stepper
Debug failures by stepping through commands:
```elixir
# Navigate through execution step-by-step
Livebook.command_stepper(result)
```
The stepper shows:
- Command name and arguments
- Result status (success/failure)
- Events generated
- State before and after
### Sample Notebook
A demo notebook is included at `notebooks/property_damage_demo.livemd` showing all features.
## Example Projects
Complete working examples are available in the `example_tests/` directory:
### Counter (Hello World)
The simplest PropertyDamage example - a counter with an intentional bug.
Start here if you're new to stateful property-based testing.
```
example_tests/counter/
```
### ToyBank (Payment Authorization)
A banking API with 12 intentional bugs. Demonstrates:
- Multiple entity types (accounts, authorizations, captures)
- Complex state machines and cross-entity invariants
- Parallel testing for race conditions
- Bug detection and regression testing
```
example_tests/toy_bank/
```
### TravelBooking (Chaos Engineering)
A travel booking service demonstrating chaos engineering:
- Multi-provider coordination (flights, hotels)
- Fault injection with nemesis operations
- Certificate failure simulation
- Partial failure rollback testing
```
example_tests/travel_booking/
```
## Guides
- [Getting Started](guides/getting_started.md) - First steps with PropertyDamage
- [Writing Invariants](guides/writing_invariants.md) - Projections and assertions
- [Debugging Failures](guides/debugging_failures.md) - Analyzing and fixing test failures
- [Async and Eventual Consistency](guides/async_and_eventual_consistency.md) - Probes, bridges, and Adapter.Injector
- [Chaos Engineering](guides/chaos_engineering.md) - Nemesis fault injection
- [Integration Testing](guides/integration_testing.md) - Testing against live services
- [Differential Testing](guides/differential_testing.md) - Comparing implementations
## Architecture
```
PropertyDamage
├── Core Types (Tier 0)
│ ├── Ref - Symbolic references
│ ├── Command - Operation behaviour
│ ├── Model - Test model behaviour
│ │ ├── Projection - State reducer behaviour
│ │ └── Simulator - Symbolic execution behaviour
│ └── Sequence - Linear and branching command sequences
│
├── Execution (Tier 1)
│ ├── Adapter - SUT bridge behaviour
│ │ └── Injector - External event injection behaviour
│ ├── Executor - Command execution (linear and parallel)
│ ├── Linearization - Parallel execution verification
│ └── EventQueue - Event coordination
│
├── Shrinking (Tier 2)
│ ├── Shrinker - Sequence minimization (supports branching)
│ ├── Validator - Sequence validation
│ └── Graph - Dependency analysis
│
├── Analysis (Tier 3)
│ ├── Analysis - Causal explanation, trigger isolation
│ ├── Replay - Step-by-step execution
│ ├── Coverage - Metrics tracking
│ └── Flakiness - Determinism checking
│
├── Load Testing
│ ├── LoadTest - Main API
│ ├── Runner - Orchestrates concurrent sessions
│ ├── Session - Single user session
│ ├── Metrics - Lock-free metrics collection
│ ├── RampStrategy - Load ramping strategies
│ └── Report - Report generation
│
├── Debugging
│ ├── Diagram - Visual sequence diagrams
│ └── Diff - Trace comparison and diffing
│
├── Export
│ ├── Export - Main API (to_exunit, to_script, to_livebook)
│ ├── HTTPSpec - HTTP call description struct
│ ├── ExUnit - ExUnit test generation
│ ├── Script - Script dispatcher
│ ├── Script.Elixir - Elixir + Req scripts
│ ├── Script.Curl - Bash + curl scripts
│ ├── Script.Python - Python + requests scripts
│ ├── LiveBook - LiveBook notebook generation
│ └── Common - Shared utilities
│
├── Mutation
│ ├── Mutation - Main API (run, analyze, format)
│ ├── Runner - Orchestrates mutation runs
│ ├── MutatingAdapter - Wraps adapters to inject faults
│ ├── Report - Aggregates results
│ ├── Analysis - Weakness detection
│ ├── Formatter - Output formatting
│ └── Operators - Value, Omission, Status, Event, Boundary
│
├── Suggestions
│ ├── Suggestions - Main API (analyze, format, high_priority)
│ ├── Analyzer - Model analysis and suggestion generation
│ ├── Patterns - Pattern detection for fields and events
│ └── Formatter - Output formatting (terminal, markdown, json)
│
├── FailureIntelligence
│ ├── FailureIntelligence - Main API (analyze, similar?, verify_fix)
│ ├── Fingerprint - Extract comparable features from failures
│ ├── Similarity - Compare fingerprints and compute scores
│ ├── Patterns - Cluster failures and detect patterns
│ └── Verification - Verify fixes with seed variations
│
├── Regression
│ └── Regression - Automatic regression test management
│
├── Differential
│ ├── Differential - Main API (run, compare modes)
│ ├── Target - Target parsing and validation
│ ├── Result - Result struct and formatting
│ ├── Equivalence - Comparison strategies (exact, structural, custom)
│ └── Baseline - Export/import for time-separated testing
│
├── Telemetry
│ ├── Telemetry - Event emission API
│ ├── Collector - Aggregates events for dashboard
│ └── Dashboard - HTML rendering for LiveView
│
└── Utilities
├── Persistence - Save/load failures
├── SeedLibrary - Seed management
└── Scaffold - Code generation
```
## License
MIT License. See LICENSE for details.