Current section
Files
Jump to
Current section
Files
hegel_elixir
README.md
README.md
# 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](https://hegel.dev/reference/libhegel).
> **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](https://hegel.dev/compatibility).
## Installation
Add an exact test dependency while the frontend and Hegel remain in beta:
```elixir
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:
```console
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:
```elixir
def deps do
[
{:hegel_elixir, "== 0.1.0", only: :test},
{:rustler, "== 0.38.0", runtime: false}
]
end
```
```console
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`:
```elixir
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:
```elixir
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.
```elixir
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:
```console
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.
```elixir
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.
```elixir
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](https://hex.pm/packages/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`](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
- [How Hegel works](https://hegel.dev/explanation/how-hegel-works)
- [libhegel reference](https://hegel.dev/reference/libhegel)
- [Pinned 0.32.5 `hegel.h`](https://github.com/hegeldev/hegel-rust/blob/v0.32.5/hegel-c/include/hegel.h)
- [Hegel compatibility and beta policy](https://hegel.dev/compatibility)
- [StreamData 1.4 documentation](https://hexdocs.pm/stream_data/1.4.0/StreamData.html)