Back to Blog
Tutorial March 04, 2026 | 11 min read

Property-Based Testing in Elixir with StreamData

A practical guide to property-based testing using ExUnitProperties and StreamData, with real examples from validators, parsers, and encoders.

Tomas Korcak (korczis)

Prismatic Platform

Traditional example-based tests verify behavior against a handful of specific inputs. Property-based tests describe invariants that must hold for any valid input, then let the framework generate hundreds of random test cases to find violations. This post covers practical usage of ExUnitProperties and StreamData in the Prismatic Platform.


Why Property-Based Testing Matters


Consider a URL validator. An example-based test might check:



assert Validator.valid_url?("https://example.com")

refute Validator.valid_url?("not a url")


This covers two cases. A property-based test covers hundreds:



property "all generated valid URLs pass validation" do

check all scheme <- member_of(["http", "https"]),

host <- string(:alphanumeric, min_length: 1, max_length: 63),

tld <- member_of(["com", "org", "net", "io"]),

path <- string(:alphanumeric, min_length: 0, max_length: 50) do

url = "#{scheme}://#{host}.#{tld}/#{path}"

assert Validator.valid_url?(url)

end

end


Each test run generates 100 random combinations by default. Over time, this explores far more of the input space than hand-written examples ever could.


Setting Up StreamData


Add the dependency to your mix.exs:



defp deps do

[

{:stream_data, "~> 1.0", only: [:dev, :test]}

]

end


In your test files:



defmodule MyApp.ValidatorPropertyTest do

use ExUnit.Case, async: true

use ExUnitProperties


# Properties go here

end


Core Generators


StreamData provides generators for common types. These are the building blocks:


GeneratorOutputExample

|---|---|---|

integer()Any integer-42, 0, 1337 positive_integer()Integers > 01, 999 float()Any float-3.14, 0.0, 1.5e10 string(:alphanumeric)Alphanumeric strings"abc123" binary()Raw binaries<<1, 2, 3>> boolean()true or falsetrue atom(:alphanumeric)Random atoms:abc list_of(generator)Lists of generated values[1, 3, 7] map_of(key_gen, val_gen)Maps%{"a" => 1} member_of(enumerable)One element from list"active" from ["active", "inactive"] one_of(generators)Value from one generatorMixed types

Building Custom Generators


Real-world testing needs domain-specific generators. Here is a generator for a DD case entity as used in the platform:



defmodule Prismatic.Generators do

@moduledoc """

StreamData generators for Prismatic domain types.

"""

use ExUnitProperties


@spec dd_case_params() :: StreamData.t(map())

def dd_case_params do

gen all name <- string(:alphanumeric, min_length: 3, max_length: 100),

status <- member_of([:draft, :active, :completed, :archived]),

priority <- member_of([:low, :medium, :high, :critical]),

entity_count <- integer(0..50),

confidence <- float(min: 0.0, max: 1.0) do

%{

name: name,

status: status,

priority: priority,

entity_count: entity_count,

confidence_score: Float.round(confidence, 4)

}

end

end


@spec email_address() :: StreamData.t(String.t())

def email_address do

gen all local <- string(:alphanumeric, min_length: 1, max_length: 64),

domain <- string(:alphanumeric, min_length: 1, max_length: 63),

tld <- member_of(["com", "org", "net", "io", "cz"]) do

"#{local}@#{domain}.#{tld}"

end

end


@spec ico_number() :: StreamData.t(String.t())

def ico_number do

gen all digits <- list_of(integer(0..9), length: 8) do

Enum.join(digits)

end

end

end


Real Property Examples


Roundtrip Properties


The most powerful property pattern: encode then decode should return the original value.



property "JSON roundtrip preserves DD case data" do

check all params <- Prismatic.Generators.dd_case_params() do

encoded = Jason.encode!(params)

decoded = Jason.decode!(encoded, keys: :atoms)


assert decoded.name == params.name

assert decoded.status == Atom.to_string(params.status)

assert decoded.entity_count == params.entity_count

end

end


property "Base64 roundtrip preserves binary data" do

check all data <- binary(min_length: 0, max_length: 10_000) do

assert data == data |> Base.encode64() |> Base.decode64!()

end

end


Invariant Properties


These assert conditions that must always be true regardless of input:



property "confidence scores are always between 0 and 1" do

check all raw_score <- float(min: -100.0, max: 100.0) do

normalized = Prismatic.DD.ScoringEngine.normalize_confidence(raw_score)

assert normalized >= 0.0

assert normalized <= 1.0

end

end


property "sanitized strings never contain script tags" do

check all input <- string(:printable, max_length: 500) do

sanitized = Prismatic.Sanitizer.strip_html(input)

refute String.contains?(sanitized, "<script")

refute String.contains?(sanitized, "javascript:")

end

end


Commutative / Associative Properties


Mathematical properties that should hold for domain operations:



property "merging entity lists is associative" do

check all a <- list_of(entity_gen(), max_length: 20),

b <- list_of(entity_gen(), max_length: 20),

c <- list_of(entity_gen(), max_length: 20) do

left = EntityMerger.merge(EntityMerger.merge(a, b), c)

right = EntityMerger.merge(a, EntityMerger.merge(b, c))


assert MapSet.new(left, & &1.id) == MapSet.new(right, & &1.id)

end

end


Idempotency Properties


Operations that should produce the same result when applied multiple times:



property "deduplication is idempotent" do

check all items <- list_of(string(:alphanumeric), max_length: 100) do

once = Prismatic.Utils.deduplicate(items)

twice = Prismatic.Utils.deduplicate(once)

assert once == twice

end

end


Shrinking


When StreamData finds a failing case, it shrinks the input to the smallest value that still triggers the failure. This is automatic and makes debugging far easier.


For example, if a list of 47 elements triggers a bug, StreamData will try progressively smaller lists until it finds the minimal failing case, often a list of 1 or 2 elements.


Custom generators built with gen all get shrinking for free. If you build generators using StreamData.bind/2 or StreamData.map/2, shrinking propagates through the composition.



# This generator automatically shrinks each component independently

gen all name <- string(:alphanumeric, min_length: 1),

age <- positive_integer(),

tags <- list_of(atom(:alphanumeric), max_length: 5) do

%{name: name, age: age, tags: tags}

end


If a test fails for %{name: "xQ7", age: 42, tags: [:a, :b]}, shrinking might reduce it to %{name: "a", age: 1, tags: [:a]}.


Integration with TACH Doctrine


The Prismatic Platform's TACH doctrine mandates property-based tests for pure/stateless modules: validators, parsers, encoders, scoring functions. The enforcement checks for ExUnitProperties usage in test files corresponding to these module types:



# TACH audit checks for property test coverage

mix tach.audit --property-gaps


Modules flagged as pure (no side effects, no GenServer state, no database access) that lack property tests appear as advisory warnings during CI.


Configuring Test Runs


Control the number of generated cases per property:



# In test_helper.exs or per-test

@tag property_iterations: 500


property "holds for many inputs", %{property_iterations: n} do

check all input <- integer(), max_runs: n do

assert is_integer(input)

end

end


For CI, we run 200 iterations. For local development, 100 is the default. For pre-release validation, 1000.


Common Pitfalls


Overly constrained generators defeat the purpose. If your generator only produces values that your code handles correctly, you are testing nothing. Include edge cases: empty strings, zero, negative numbers, Unicode.


Slow generators can make property tests impractical. Avoid database calls inside generators. Properties should test pure logic.


Flaky properties from non-deterministic behavior (timestamps, random values inside the function under test) need careful handling. Inject dependencies rather than relying on system state.


Summary


PatternUse CaseExample

|---|---|---|

RoundtripEncode/decode, serialize/deserializeJSON, Base64, protocol buffers InvariantOutput constraints, bounds, sanitizationConfidence scores, HTML stripping CommutativeOrder-independent operationsSet operations, merges IdempotentRepeated application safetyDeduplication, normalization OracleCompare against reference implementationNew parser vs regex, optimized vs naive

Property-based testing finds bugs that example-based tests miss. Combined with the TACH doctrine's enforcement, it forms a safety net that grows stronger with every test run.

Tags

testing elixir property-based-testing streamdata quality