PropertyDamage (PropertyDamage v0.2.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

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

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.

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.

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

assertion_catalog(model)

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

assertion_coverage(result, model)

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

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

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

execute(commands, opts)

@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 to adapter.setup/1 (default: %{})

Returns

  • {:ok, event_log} - List of EventLog.Entry structs 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__ == UserCreated

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

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.

external()

@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()]
end

Multiple 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
  ]
end

Nested 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

  1. In your simulator, return events with external fields unset. simulate/2 returns a bare list of event structs; id: external() is implicit:

    def simulate(%CreateOrder{amount: amt}, _state) do [%OrderCreated{amount: amt}] end

  2. 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
  3. 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

  4. 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, which PropertyDamage.Generator.external_from/2 surfaces 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 ViewOrder runs.

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

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

  • :strategy - Shrinking strategy (default: :thorough). Each strategy sets a default budget that the explicit options below override:

    strategymax_iterationsmax_time_ms
    :quick50010_000
    :thorough200060_000
    :exhaustive10_000300_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")