Contract Testing with Shared Libraries
View SourceThis guide covers advanced patterns for building reusable, composable test libraries with PropertyDamage. These patterns enable:
- Domain libraries that define commands/events without depending on PropertyDamage
- Model libraries that compose domain concepts into testable specifications
- Version tracking for reproducible test failures
Architecture Overview
A mature PropertyDamage setup typically has three layers:
┌─────────────────────────────────────────────────────────────┐
│ Test Projects │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Payment Tests │ │ Inventory Tests │ │
│ │ - Adapters │ │ - Adapters │ │
│ │ - Run config │ │ - Run config │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
├───────────┼────────────────────┼─────────────────────────────┤
│ Model Libraries │ │
│ ┌────────┴────────┐ ┌───────┴────────┐ │
│ │ PaymentModel │ │ InventoryModel │ │
│ │ - Projections │ │ - Projections │ │
│ │ - Commands │ │ - Commands │ │
│ │ - Invariants │ │ - Invariants │ │
│ └────────┬────────┘ └───────┬────────┘ │
│ │ │ │
├───────────┼───────────────────┼──────────────────────────────┤
│ Domain Library (company_domain) │
│ ┌────────┴───────────────────┴────────┐ │
│ │ Pure Elixir structs │ │
│ │ - Events: PaymentAuthorized, etc. │ │
│ │ - Commands: AuthorizePayment, etc. │ │
│ │ - NO PropertyDamage dependency │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Key principle: Domain libraries define WHAT (the domain vocabulary), PropertyDamage-aware libraries define HOW (testing behavior). This separation enables:
- Same domain definitions across different SUT implementations
- Domain libraries usable in production code (no test dependencies)
- Multiple test approaches for the same domain
Defining External Markers
When domain libraries don't depend on PropertyDamage, they can't use external() to mark server-generated fields. Instead, use atom sentinels.
In the Domain Library
# In company_domain (no PropertyDamage dependency)
defmodule CompanyDomain.Events.OrderCreated do
@moduledoc "Event emitted when an order is created"
@typedoc "Server-generated unique identifier"
@type id :: String.t()
# Use :__external__ as a sentinel for server-generated fields
defstruct [
id: :__external__,
customer_id: nil,
amount: nil,
currency: nil,
created_at: :__external__
]
end
defmodule CompanyDomain.Events.PaymentProcessed do
defstruct [
payment_id: :__external__,
transaction_ref: :__external__,
order_id: nil,
amount: nil,
status: nil
]
endIn the Test Project
Configure PropertyDamage to recognize your sentinel:
# In config/test.exs (applies to all tests)
config :property_damage, external_markers: [:__external__]
# Or per-test (overrides + combines with config)
PropertyDamage.run(
model: OrderModel,
adapter: OrderAdapter,
external_markers: [:__external__]
)Using a Protocol (Advanced)
For more control, define a custom marker type:
# In company_domain
defmodule CompanyDomain.ServerGenerated do
@moduledoc "Marker for server-generated fields"
defstruct [:description]
def new(description \\ nil), do: %__MODULE__{description: description}
end
# In a test support library that depends on both
defimpl PropertyDamage.ExternalMarker, for: CompanyDomain.ServerGenerated do
def external?(_), do: true
end
# Usage in domain library
defmodule CompanyDomain.Events.OrderCreated do
alias CompanyDomain.ServerGenerated
defstruct [
id: ServerGenerated.new("unique order ID"),
customer_id: nil,
amount: nil
]
endConfiguration Options
App Config (Global)
# config/test.exs
config :property_damage,
external_markers: [:__external__, :server_generated]Runtime Options (Per-Test)
PropertyDamage.run(
model: MyModel,
adapter: MyAdapter,
external_markers: [:__external__] # Combined with app config
)Priority
External marker recognition follows this priority:
%PropertyDamage.External{}struct (always recognized)- Protocol implementations
- Explicit
:external_markersoption - App config
:external_markers
Saved Test Case Compatibility
PropertyDamage tracks dependency versions in saved .pd files to help catch compatibility issues.
Saving Failures
When you save a failure, version metadata is automatically captured:
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, path} = PropertyDamage.Persistence.save(failure, "failures/")The saved file includes:
- PropertyDamage version
- Elixir version
- Versions of applications containing command/event structs
Loading with Version Warnings
case PropertyDamage.Persistence.load("failures/bug-123.pd") do
{:ok, report} ->
# Versions match, safe to replay (the adapter is taken from the report;
# pass adapter_config: if you need to override its setup config)
PropertyDamage.Replay.run(report)
{:ok, report, warnings} ->
# Version mismatch detected
IO.warn("Version warnings: #{inspect(warnings)}")
# Warnings might include:
# {:property_damage_version_mismatch, "1.0.0", "1.1.0"}
# {:dependency_version_mismatch, :company_domain, "2.1.0", "2.2.0"}
# {:dependency_missing, :old_domain, "1.0.0"}
{:error, reason} ->
IO.puts("Failed to load: #{inspect(reason)}")
endStrict Loading
For CI environments where version mismatches should fail:
# Raises ArgumentError on any version mismatch
report = PropertyDamage.Persistence.load!("failures/bug-123.pd")Version Coordination Strategies
Strategy 1: Pin Versions in CI
Lock dependency versions in your CI environment:
# mix.exs
defp deps do
[
{:company_domain, "~> 2.1.0"}, # Pin to specific version
{:property_damage, "~> 1.0"}
]
endStrategy 2: Version Matrix Testing
Test against multiple versions:
# .github/workflows/test.yml
matrix:
domain_version: ["2.0.0", "2.1.0", "2.2.0"]Strategy 3: Regenerate on Upgrade
When upgrading dependencies that change struct definitions, regenerate ExUnit tests from the saved failures with the current struct versions:
for path <- Path.wildcard("failures/*.pd") do
{:ok, report} = PropertyDamage.load_failure(path)
PropertyDamage.generate_test(report, format: :exunit)
endStrategy 4: Shared Seed Library
Use a seed library to track known-interesting seeds:
{:ok, library} = PropertyDamage.SeedLibrary.load("seeds.json")
# Add a new failure (captures dependency versions automatically)
{:ok, library} = PropertyDamage.SeedLibrary.add(library, failure,
tags: [:currency_bug],
description: "Mismatch when capturing with different currency"
)
PropertyDamage.SeedLibrary.save(library, "seeds.json")The seed library tracks dependency_versions for each entry, helping identify which seeds may need re-validation after upgrades.
Complete Example
Domain Library (company_domain)
# lib/company_domain/commands/create_order.ex
defmodule CompanyDomain.Commands.CreateOrder do
defstruct [:customer_id, :amount, :currency]
end
# lib/company_domain/events/order_created.ex
defmodule CompanyDomain.Events.OrderCreated do
defstruct [
id: :__external__,
customer_id: nil,
amount: nil,
currency: nil,
created_at: :__external__
]
endModel Library (order_model)
The command and projection modules live in order_model and implement the
PropertyDamage behaviours. The shared contract surface is the event struct
(CompanyDomain.Events.OrderCreated): it flows through the projection and the
adapter without either side depending on PropertyDamage.
# lib/order_model/commands/create_order.ex
defmodule OrderModel.Commands.CreateOrder do
use PropertyDamage.Command
defstruct [:customer_id, :amount, :currency]
@impl true
def generator(overrides \\ %{}) do
%{
customer_id: StreamData.string(:alphanumeric, min_length: 8),
amount: StreamData.positive_integer(),
currency: StreamData.member_of(["USD", "EUR", "GBP"])
}
|> PropertyDamage.Generator.merge_overrides(overrides)
|> StreamData.fixed_map()
end
end
# lib/order_model/projections/order_state.ex
defmodule OrderModel.Projections.OrderState do
use PropertyDamage.Model.Projection
# OrderCreated is the shared struct from company_domain
alias CompanyDomain.Events.OrderCreated
@impl true
def init, do: %{orders: %{}}
@impl true
def apply(state, %OrderCreated{} = event) do
put_in(state, [:orders, event.id], %{
customer_id: event.customer_id,
amount: event.amount,
currency: event.currency
})
end
def apply(state, _), do: state
end
# lib/order_model/projections/order_invariants.ex
defmodule OrderModel.Projections.OrderInvariants do
use PropertyDamage.Model.Projection
alias CompanyDomain.Events.OrderCreated
@impl true
def init, do: %{orders: %{}}
@impl true
def apply(state, %OrderCreated{} = event) do
put_in(state, [:orders, event.id], %{amount: event.amount})
end
def apply(state, _), do: state
@trigger every: OrderCreated
def assert_orders_have_valid_amount(state, _event) do
unless Enum.all?(state.orders, fn {_id, o} -> o.amount > 0 end) do
PropertyDamage.fail!("All orders must have positive amounts")
end
end
end
# lib/order_model.ex
defmodule OrderModel do
@behaviour PropertyDamage.Model
alias OrderModel.Commands.CreateOrder
alias OrderModel.Projections.{OrderState, OrderInvariants}
@impl true
def commands, do: [{CreateOrder, weight: 1}]
@impl true
def command_sequence_projection, do: OrderState
@impl true
def assertion_projections, do: [OrderInvariants]
endTest Project
# config/test.exs
config :property_damage, external_markers: [:__external__]
# test/order_test.exs
defmodule OrderTest do
use ExUnit.Case
test "orders maintain valid state" do
result = PropertyDamage.run(
model: OrderModel,
adapter: OrderHttpAdapter,
max_runs: 100,
max_commands: 20,
regression: [
save_failures: "failures/",
seed_library: "seeds/order_seeds.json"
]
)
assert match?({:ok, _}, result)
end
end
# lib/order_http_adapter.ex
defmodule OrderHttpAdapter do
@behaviour PropertyDamage.Adapter
def setup(_config) do
{:ok, %{base_url: "http://localhost:4000"}}
end
def execute(%CreateOrder{} = cmd, ctx) do
response = HTTPoison.post!(
"#{ctx.base_url}/orders",
Jason.encode!(%{
customer_id: cmd.customer_id,
amount: cmd.amount,
currency: cmd.currency
}),
[{"content-type", "application/json"}]
)
case response.status_code do
201 ->
body = Jason.decode!(response.body)
{:ok, [%OrderCreated{
id: body["id"],
customer_id: cmd.customer_id,
amount: cmd.amount,
currency: cmd.currency,
created_at: body["created_at"]
}]}
status ->
{:error, {:http_error, status}}
end
end
def teardown(_ctx), do: :ok
endBest Practices
Use consistent markers: Pick one sentinel (
:__external__) and use it across all domain libraries.Document markers: Add
@moduledocexplaining that fields with the marker are server-generated.Version your domain library: Follow semantic versioning. Bump the version when struct definitions change.
Regenerate tests after struct changes: When you change event/command struct fields, regenerate affected saved tests.
Use load! in CI: Strict loading catches version mismatches early in the CI pipeline.
Export durable regressions, don't commit seed libraries: The seed library is an ephemeral, self-pruning replay working set (seeds only reproduce while generators are byte-stable). To keep a regression, export the failure to an ExUnit test, which freezes the concrete shrunk sequence and survives generator changes.