Hegel for Elixir
An Elixir frontend for Hegel's Hypothesis-derived property-testing engine.
Hegel separates each language frontend from the engine that generates and
shrinks examples. hegel_elixir provides ExUnit macros and contextual
generators. Its API exposes Hegel's example database, reproduce blobs,
targeting, and model-based state machines. The engine runs in-process through
the canonical libhegel C ABI.
Beta: Hegel is pre-1.0 and may make breaking changes.
hegel_elixir0.1.0 pinshegeltest-c/libhegel 0.32.5. Use a reproduce blob with the Hegel version that created it. Pin the frontend version and review upgrades; see Hegel's compatibility policy.
Installation
Add an exact test dependency while the frontend and Hegel remain in beta:
def deps do
[
{:hegel_elixir, "== 0.1.0", only: :test}
]
end
The package downloads a checksum-verified precompiled NIF on these targets:
- Apple Silicon macOS:
aarch64-apple-darwin - Intel macOS:
x86_64-apple-darwin - x86-64 glibc Linux:
x86_64-unknown-linux-gnu
Run:
mix deps.get
mix test
RustlerPrecompiled selects NIF ABI 2.15, downloads the matching GitHub Release asset, and verifies its SHA-256 checksum from the Hex package.
Other targets need a source build. Add Rustler to the consuming project and
set HEGEL_ELIXIR_BUILD=1 while compiling the dependency:
def deps do
[
{:hegel_elixir, "== 0.1.0", only: :test},
{:rustler, "== 0.38.0", runtime: false}
]
end
MIX_ENV=test HEGEL_ELIXIR_BUILD=1 mix deps.compile hegel_elixir
A source build needs:
- Elixir 1.15 or newer in the 1.x series, with an Erlang/OTP release that the Elixir version supports. Rustler emits NIF ABI 2.15, which needs OTP 22 or newer.
- Rust 1.91 or newer, including Cargo and the Rust standard library for the target.
- A native build toolchain: Xcode Command Line Tools on macOS, GCC or Clang on Linux, or MSVC Build Tools for the Windows MSVC Rust target.
- Access to cached Cargo dependencies, or network access for the first build.
The source build links hegeltest-c 0.32.5 into the NIF. Hegel.version/0
reports the loaded engine version for either installation path.
Quickstart with ExUnit
use Hegel.ExUnit sets up an ExUnit case and imports Hegel.ExUnit and
Hegel.Generators:
defmodule MyApp.SortPropertyTest do
use Hegel.ExUnit, async: true
property "sorting is idempotent" do
check all values <- list_of(integer(-1_000..1_000)),
test_cases: 250 do
assert Enum.sort(Enum.sort(values)) == Enum.sort(values)
end
end
property "a selected element belongs to its source list" do
check all values <- list_of(integer(), min_length: 1),
index <- integer(0..(length(values) - 1)) do
assert Enum.at(values, index) in values
end
end
end
Elixir evaluates generator clauses from left to right, so a later generator
can depend on an earlier value. Start with a generator clause that uses <-.
Hegel rejects the current example as an assumption when a generator pattern
fails to match or a plain clause returns false or nil. The macro passes the
trailing keyword list to Hegel.check/2.
You may use max_runs: 250 as a StreamData migration alias. New properties
should use Hegel's test_cases: 250 setting.
Imperative properties
Call Hegel.check/2 when you need direct access to the test case:
defmodule MyApp.CodecPropertyTest do
use ExUnit.Case, async: true
import ExUnit.Assertions
alias Hegel.Generators, as: Gen
test "decoding reverses encoding" do
assert :ok =
Hegel.check(
fn test_case ->
value = Hegel.draw(test_case, Gen.binary(max_length: 256))
Hegel.note(test_case, %{byte_size: byte_size(value)})
encoded = Base.encode64(value)
assert {:ok, value} == Base.decode64(encoded)
end,
test_cases: 500,
database_key: "MyApp.CodecPropertyTest:round-trip"
)
end
end
Inside a running property, Hegel.draw/1, Hegel.assume/1, Hegel.note/1,
and Hegel.target/2 read the current test case from the calling process. Use
the explicit test-case forms in reusable helpers. Hegel.check/2 returns :ok
on success. After a failure, Hegel shrinks the example and prints its draws and
replay command. Hegel re-raises the original exception with its stacktrace so
ExUnit can show assertion diffs.
The check all macro appends :ok after its body to preserve source frames. In
a hand-written callback, add a final expression such as :ok after branching
assertions when report_multiple_failures: true must distinguish several
flunk/1 call sites. Tail-call elimination can remove those call-site frames.
For tooling that needs a result value, Hegel.run/2 returns
{:ok, %Hegel.Result{status: :passed}} or
{:error, %Hegel.Result{status: :failed | :error}}. Invalid frontend usage and
native-boundary failures raise. Hegel.sample/1 and Hegel.sample/2 draw one
value without shrinking for REPL inspection. Use a property in test code.
Generators
A %Hegel.Generator{} represents a contextual draw program tied to an active
Hegel.TestCase; it has no Enumerable implementation. Compound generators
record spans and collections that libhegel uses while shrinking the choice
sequence.
| Area | Core functions in Hegel.Generators |
|---|---|
| Constants and numbers | constant/1, just/1, boolean/0..1, integer/0..2, non_negative_integer/0, positive_integer/0, byte/0, float/0..1 |
| Binary and Unicode | binary/0..1, text/0..1, string/0..2, character/0..1, codepoint/0..1, from_regex/1..2 |
| Internet-shaped text | email/0, url/0, domain/0..1 |
| Dates and identifiers | date/0..1, time/0..1, datetime/0..1, naive_datetime/0..1, uuid/0..1, ip_address/0..1, ipv4/0, ipv6/0 |
| Collections and shapes | list_of/1..2, uniq_list_of/1..2, map_of/2..3, fixed_list/1, tuple/1, fixed_map/1 |
| Choice | member_of/1, sampled_from/1, one_of/1..2, frequency/1, nullable/1..2, optional/1..2 |
| Composition | map/2, bind/2, flat_map/2, filter/2..3, lazy/1, composite/1..2 |
Plural aliases such as integers/0, lists/1..2, and texts/0..1 match names
from other Hegel frontends. Open the Hegel.Generators module documentation for
the full option set. Binary, text, list, unique-list, and map generators accept
:length, :min_length, and :max_length, plus the corresponding *_size
spellings.
Use composite/1 for imperative dependent generation. Pass options with
composite/2; both forms preserve a single shrinkable structural span.
alias Hegel.Generators, as: Gen
ordered_pair =
Gen.composite(fn draw ->
lower = draw.(Gen.integer(0..100))
{lower, draw.(Gen.integer(lower..(lower + 20)))}
end)
Hegel.Generator.new/1 provides a low-level extension point. Use the built-in
combinators or composite/1 and composite/2 when their spans and collection
annotations fit the data shape; libhegel reads those annotations while
shrinking.
Settings
Hegel.check/2 and Hegel.run/2 accept a keyword list, map, or
%Hegel.Settings{}.
| Setting | Default | Meaning |
|---|---|---|
:test_cases | 100 | Maximum number of valid examples. Rejected assumptions do not count. :max_runs is an alias. |
:stateful_step_count | 50 | Maximum number of accepted rules in each state-machine example. |
:seed | nil | Unsigned 64-bit seed. With nil, libhegel selects a seed. |
:derandomize | nil | With true, libhegel derives a seed from :database_key. nil keeps libhegel's environment-based default. |
:database | :default | Outside detected CI, :default uses ./.hegel/examples/; on CI it turns persistence off. :disabled or false turns it off, and a string sets another root. |
:database_key | nil | Identity for example storage and seed derivation. check all creates one from the property module and function. |
:phases | all five | Any subset of :explicit, :reuse, :generate, :target, and :shrink. |
:suppress_health_check | [] | Health checks to suppress: :filter_too_much, :too_slow, :test_cases_too_large, or :large_initial_test_case. |
:report_multiple_failures | false | Find and shrink failures from distinct source origins in one run. |
:verbosity | :normal | Engine output level: :quiet, :normal, :verbose, or :debug. |
:backend | :auto | Randomness source: :auto, :default, or :urandom. |
:reproduce | nil | Base64 reproduce blob. A value makes Hegel run that case in place of a standard run. |
:inspect_opts | unlimited output | inspect/2 options for minimized Elixir values. |
The raw libhegel C settings default :report_multiple_failures to true.
This frontend sets it to false, which matches StreamData and Hegel's Rust and
TypeScript frontends. Set it to true to search for several source origins in
one run.
The environment variables HEGEL_TEST_CASES, HEGEL_SEED,
HEGEL_DERANDOMIZE, and HEGEL_REPRODUCE take precedence over per-call
settings. On a CI system that libhegel detects, its native defaults turn off
the database and turn on derandomization. A per-call database path,
:disabled, or Boolean :derandomize value overrides the relevant default.
Database and exact replay
With database: :default, libhegel stores interesting examples under
.hegel/examples/. The :reuse phase tries them before generation. check all
creates a stable key from the caller module and property function. Give an
imperative property a stable :database_key when you want reuse or
derandomize: true.
Hegel includes any reproduce blob from libhegel, along with a command, in the failure report:
HEGEL_REPRODUCE='<blob>' mix test test/my_property_test.exs:12
Replay feeds that choice sequence to the Elixir property. A change to draw order, generator bounds, or the Hegel version can invalidate the blob. If the property passes during replay, Hegel returns an error that reports a stale or nondeterministic reproduction.
Targeted generation
Hegel.target/3 submits a score to the target phase. Scores must be finite;
libhegel favors larger values and accepts one score per label in each example.
Hegel.check(fn test_case ->
values = Hegel.draw(test_case, Hegel.Generators.list_of(Hegel.Generators.integer()))
Hegel.target(test_case, length(values) * 1.0, "list length")
assert my_algorithm(values) == reference_algorithm(values)
end)
Targeting changes input selection. Assertions still define success. Add
:target to :phases to enable the search phase.
State machines
Hegel.StateMachine supports model-based testing. Rules transform immutable
Elixir state. Libhegel uses swarm testing to select a rule subset and sequence.
During shrinking, it changes or deletes rule spans. Hegel.StateMachine checks
invariants before the first rule and after each accepted rule.
alias Hegel.{Generators, StateMachine}
machine =
StateMachine.new([])
|> StateMachine.rule(:push, fn stack, test_case ->
[Hegel.draw(test_case, Generators.integer()) | stack]
end)
|> StateMachine.rule(:pop, fn
[], test_case -> Hegel.assume(test_case, false, "stack is empty")
[_head | tail], _test_case -> tail
end)
|> StateMachine.invariant(:integer_elements, fn stack, _test_case ->
Enum.all?(stack, &is_integer/1)
end)
Hegel.check(
fn test_case -> StateMachine.run(test_case, machine) end,
stateful_step_count: 100,
database_key: "stack-state-machine"
)
An assumption inside a rule discards that rule attempt while retaining the
example and its step budget. A false or nil invariant fails the property.
Keep the model and system-under-test handle in the rule state, then compare
them in invariants.
Compared with StreamData 1.4
The ExUnit macro names match
stream_data 1.4.0. Each engine uses
a different generator model. StreamData generators implement Enumerable as
pure-Elixir lazy shrink trees and receive a generation-size parameter. Hegel
generators run as contextual programs over a native choice sequence. Libhegel
handles generation, database reuse, targeting, health checks, and shrinking.
| Concern | StreamData 1.4 | Hegel for Elixir |
|---|---|---|
| ExUnit setup | use ExUnitProperties | use Hegel.ExUnit |
| Property syntax | property plus check all | property plus check all |
| Generator representation | %StreamData{} lazy tree and Enumerable | %Hegel.Generator{} draw program requiring an active Hegel.TestCase |
| Sampling | Enum.take(generator, n); enumeration loses shrinking | Hegel.sample(generator) for one exploratory value; generators are not enumerable |
| Run budget | max_runs: n | test_cases: n; max_runs: n is a migration alias |
| Size model | Generation size starts at initial_size, increments per run, and stops at max_generation_size | No global generation size; set bounds such as max_length: on each generator |
| Seed | initial_seed and ExUnit seed integration | seed, HEGEL_SEED, or derandomize; ExUnit's --seed does not set the Hegel seed |
| Shrinking | max_shrinking_steps bounds traversal of generator-produced lazy shrink trees | libhegel searches recorded choices using spans and collections; it exposes no Elixir-side shrink-step budget |
| Reproduction | Repeat the seed and property | Minimized choice blob via HEGEL_REPRODUCE; blobs depend on the Hegel version |
| Persistence | No core example database | Reuses per-property examples from .hegel/examples/ by default outside detected CI |
| Filtering | Generator filtering and check-clause filters | Span rejection plus assumption accounting and filter health checks |
| Search guidance | No equivalent in the core API | Hegel.target/3 and the :target phase |
| Stateful testing | Build custom generators and models | Native rule selection and sequence shrinking through Hegel.StateMachine |
| Runtime footprint | Pure Elixir, no package dependencies | In-process native NIF; supported targets download a precompiled binary |
Migration checklist
| StreamData code or option | Hegel equivalent or action |
|---|---|
use ExUnitProperties | Replace with use Hegel.ExUnit. |
check all x <- generator, max_runs: n | Hegel accepts this migration alias. Write test_cases: n in new code. |
initial_seed: integer | Use seed: non_negative_u64 or HEGEL_SEED. If migrating a direct StreamData.check_all/3 call, do not pass its three-integer RNG state. |
initial_size: / max_generation_size: | Remove them and bound individual generators (list_of(g, max_length: n), binary(max_length: n), integer(range)). |
max_run_time: | No direct setting; bound :test_cases and configure a wall-clock timeout in the test runner. |
max_shrinking_steps: | No direct setting; let libhegel shrink. Omitting :shrink during diagnosis leaves failures unshrunk and sets no budget. |
Enum.take(generator, n) | Use a property for meaningful testing. For a single debug sample, use Hegel.sample/1 or Hegel.sample/2. |
StreamData.sized/1, resize/2, or scale/2 | Redesign around explicit primitive/collection bounds; Hegel exposes no global size parameter. |
An unsupported generator such as atom/1, bitstring/1, chardata/0, or term/0 | Compose the available Hegel primitives, add a shrink-aware composite/1 or composite/2, or keep that property on StreamData. |
| A seed-only regression test | Prefer the emitted reproduce blob for an exact case and keep a stable database_key. |
Review a migrated property's assumptions. Hegel's integer/0 spans a fixed
signed 128-bit range. StreamData changes its integer range with the generation
size. Filters can trigger Hegel health checks. Database reuse, targeting,
shrinking, and final replay can invoke the property beyond its :test_cases
count. Keep the body deterministic for a given draw sequence and safe across
repeated calls.
Choose StreamData when you need pure-BEAM portability, generator enumeration, or one of its extra generator types. Choose Hegel when you need its shared Hypothesis-derived engine, persistent examples, targeted search, reproduce blobs, or native state-machine shrinking.
Concurrency and side effects
A Hegel.TestCase records the PID of the BEAM process running the property and
rejects use from another process. Draw immutable values before starting a
Task and pass the values to concurrent work. Passing the test case or a
generator draw closure across processes would make draw order depend on
scheduling and break replay.
Hegel runs the property body during generation, shrinking, and the final replay of a minimized failure. Reset mutable state for each execution and avoid one-time side effects.
Architecture
docs/architecture.md records the native-boundary
decision, lifecycle, scheduler behavior, ownership model, and release
implications.
The frontend binds libhegel's C ABI from the pinned hegeltest-c crate.
Rustler manages the NIF's BEAM resources and term conversion.