PropertyDamage (PropertyDamage v0.1.0)

View Source

PropertyDamage: 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:

  1. Symbolic Phase: Generate a sequence of commands with symbolic refs
  2. 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
end

Running 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

Track interesting seeds for regression testing:

{:ok, library} = PropertyDamage.load_seed_library("seeds.json")
{:ok, library} = PropertyDamage.add_to_seed_library(library, failure, tags: [:bug])
PropertyDamage.save_seed_library(library, "seeds.json")

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

Types

Failure report from a failed run.

Result from run/1 - either success stats or a failure report.

Result statistics from a successful run.

Functions

Add a failure to the seed library.

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.

Explain why each command in a failure's shrunk sequence is needed.

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.

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.

Attempt further shrinking on an existing failure report.

Types

failure_report()

@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.

result()

@type result() :: {:ok, stats()} | {:error, failure_report()}

Result from run/1 - either success stats or a failure report.

stats()

@type stats() :: %{
  runs: non_neg_integer(),
  total_commands: non_neg_integer(),
  seed: integer()
}

Result statistics from a successful run.

Functions

add_to_seed_library(library, failure, opts \\ [])

@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"
)

check_determinism(model, adapter, seed, opts \\ [])

@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

coverage(result, model)

@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_failure(path)

@spec delete_failure(Path.t()) :: :ok | {:error, term()}

Delete a saved failure file.

discover_flaky_seeds(model, adapter, opts \\ [])

@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")

explain(report)

@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.

fail!(message, data \\ [])

@spec fail!(
  String.t(),
  keyword()
) :: no_return()

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
end

Custom 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"}

generate_test(report, opts \\ [])

@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.

isolate_trigger(report, opts \\ [])

@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_failures(directory, opts \\ [])

@spec list_failures(
  Path.t(),
  keyword()
) :: [map()]

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))

load_failure(path)

@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)

load_seed_library(path \\ "property_damage_seeds.json")

@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")

replay(failure, opts \\ [])

@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)

run(opts)

@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)
  • :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 - :all or 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 function fn(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
  ]
)

save_failure(report, directory, opts \\ [])

@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")

save_seed_library(library, path \\ "property_damage_seeds.json")

@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")

shrink_further(report, opts \\ [])

@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

  • :max_iterations - Maximum shrink attempts (default: 5000)
  • :max_time_ms - Maximum time for shrinking in ms (default: 60000)
  • :strategy - Shrinking strategy (default: :thorough)
    • :quick - Fast shrinking, may miss some reductions
    • :thorough - Balanced approach (default)
    • :exhaustive - Try all possible reductions (slow)
  • :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")