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_elixir 0.1.0 pins hegeltest-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:

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:

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.

AreaCore functions in Hegel.Generators
Constants and numbersconstant/1, just/1, boolean/0..1, integer/0..2, non_negative_integer/0, positive_integer/0, byte/0, float/0..1
Binary and Unicodebinary/0..1, text/0..1, string/0..2, character/0..1, codepoint/0..1, from_regex/1..2
Internet-shaped textemail/0, url/0, domain/0..1
Dates and identifiersdate/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 shapeslist_of/1..2, uniq_list_of/1..2, map_of/2..3, fixed_list/1, tuple/1, fixed_map/1
Choicemember_of/1, sampled_from/1, one_of/1..2, frequency/1, nullable/1..2, optional/1..2
Compositionmap/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{}.

SettingDefaultMeaning
:test_cases100Maximum number of valid examples. Rejected assumptions do not count. :max_runs is an alias.
:stateful_step_count50Maximum number of accepted rules in each state-machine example.
:seednilUnsigned 64-bit seed. With nil, libhegel selects a seed.
:derandomizenilWith true, libhegel derives a seed from :database_key. nil keeps libhegel's environment-based default.
:database:defaultOutside 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_keynilIdentity for example storage and seed derivation. check all creates one from the property module and function.
:phasesall fiveAny 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_failuresfalseFind and shrink failures from distinct source origins in one run.
:verbosity:normalEngine output level: :quiet, :normal, :verbose, or :debug.
:backend:autoRandomness source: :auto, :default, or :urandom.
:reproducenilBase64 reproduce blob. A value makes Hegel run that case in place of a standard run.
:inspect_optsunlimited outputinspect/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.

ConcernStreamData 1.4Hegel for Elixir
ExUnit setupuse ExUnitPropertiesuse Hegel.ExUnit
Property syntaxproperty plus check allproperty plus check all
Generator representation%StreamData{} lazy tree and Enumerable%Hegel.Generator{} draw program requiring an active Hegel.TestCase
SamplingEnum.take(generator, n); enumeration loses shrinkingHegel.sample(generator) for one exploratory value; generators are not enumerable
Run budgetmax_runs: ntest_cases: n; max_runs: n is a migration alias
Size modelGeneration size starts at initial_size, increments per run, and stops at max_generation_sizeNo global generation size; set bounds such as max_length: on each generator
Seedinitial_seed and ExUnit seed integrationseed, HEGEL_SEED, or derandomize; ExUnit's --seed does not set the Hegel seed
Shrinkingmax_shrinking_steps bounds traversal of generator-produced lazy shrink treeslibhegel searches recorded choices using spans and collections; it exposes no Elixir-side shrink-step budget
ReproductionRepeat the seed and propertyMinimized choice blob via HEGEL_REPRODUCE; blobs depend on the Hegel version
PersistenceNo core example databaseReuses per-property examples from .hegel/examples/ by default outside detected CI
FilteringGenerator filtering and check-clause filtersSpan rejection plus assumption accounting and filter health checks
Search guidanceNo equivalent in the core APIHegel.target/3 and the :target phase
Stateful testingBuild custom generators and modelsNative rule selection and sequence shrinking through Hegel.StateMachine
Runtime footprintPure Elixir, no package dependenciesIn-process native NIF; supported targets download a precompiled binary

Migration checklist

StreamData code or optionHegel equivalent or action
use ExUnitPropertiesReplace with use Hegel.ExUnit.
check all x <- generator, max_runs: nHegel accepts this migration alias. Write test_cases: n in new code.
initial_seed: integerUse 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/2Redesign around explicit primitive/collection bounds; Hegel exposes no global size parameter.
An unsupported generator such as atom/1, bitstring/1, chardata/0, or term/0Compose the available Hegel primitives, add a shrink-aware composite/1 or composite/2, or keep that property on StreamData.
A seed-only regression testPrefer 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.

Further reading