package hegel

  1. Overview
  2. Docs

Module HegelSource

Introduction

Property-based testing for OCaml, powered by the native Hegel engine based on Hypothesis.

Hegel runs the test function on many generated inputs. You generate data inline, drawing values with draw as the test runs, rather than generating the data then running the property body. Each draw returns an ordinary OCaml value that you bind with let, compute with, and branch on, so a later draw can depend on an earlier generated value or a value from the system under test.

Because Hegel uses integrated shrinking, shrinking comes for free.

Getting started

Install Hegel

To install Hegel for OCaml:

  opam install hegel

The version of Hegel in OPAM sometimes lags behind the version in Github. To pin the version in Github:

  opam pin add hegel "git+https://github.com/hegeldev/hegel-ocaml.git"

Hegel for OCaml supports Linux (amd64/arm64) and macOS (Apple Silicon). macOS amd64 (Intel) has no published libhegel artifact, so on that platform point HEGEL_LIBHEGEL_PATH at a locally built libhegel.dylib.

Add hegel to your dune library dependencies:

  (library
   (name my_tests)
   (libraries hegel)
   (inline_tests (backend ppx_hegel_test))
   (preprocess (pps ppx_hegel_test)))

ppx_hegel_test is not required to use Hegel, but strongly recommended as it adds many convenience features and integration with dune runtest. The examples below assume ppx_hegel_test is used.

Write your first test

Write a property test using let%hegel_test:

  open Hegel
  open Hegel.Generators

  let%hegel_test commutative_addition tc =
    let a = draw tc (integers ~min_value:(-1000) ~max_value:1000 ()) in
    let b = draw tc (integers ~min_value:(-1000) ~max_value:1000 ()) in
    require_equal tc Core.Int.sexp_of_t (a + b) (b + a)

We check the property with require_equal rather than assert (a + b = b + a). It takes a printer for the values and, when they differ, shows a structural diff of the two sides in the failure report instead of a bare "assertion failed". Use require for a boolean check with a custom message. A plain assert can be used as well, but it does not provide as much information as require_equal and require.

Run dune runtest. You should see that the test passes. Hegel generates up to 100 random input pairs and reports the minimal counterexample if it finds one. When a test fails, Hegel prints each value you drew from the failing case, named after the let binding it was bound to (a = …, b = …).

let%hegel_test name tc = body also defines name as a plain unit -> unit function, so you can still call it directly from an executable or hand it to another test harness like Alcotest.

The rest of the examples in the documentation assume you have open Hegel and open Hegel.Generators at the top of the test file like in the example above.

Next, let's try a test that fails.

  let%hegel_test every_int_is_small tc =
    let n = draw tc (integers ()) in
    assert (n < 50)

This test asserts that any integer is less than 50, which is obviously incorrect. Hegel finds a test case that makes the assertion fail, then shrinks it to the smallest counterexample (n = 50). The final replay prints the drawn values, the exception, and a rerun with: line that replays the exact case:

  --- Failure: every_int_is_small (my_tests.ml:3) ------------------
  Falsified after 1 test case (0 discarded):

    n = 50

  Exception: File "my_tests.ml", line 5, characters 2-8: Assertion failed
  rerun with: [@@failure_blobs [ "AAEAAAAACgEAAAAy" ]]

To fix this test, you can constrain the integers you generate with min_value and max_value:

  let%hegel_test every_int_is_small tc =
    let n = draw tc (integers ~min_value:0 ~max_value:49 ()) in
    assert (n < 50)

Use generators

Hegel provides a rich library of generators that you can use out of the box. There are primitive generators, such as Generators.integers, Generators.floats, and Generators.text, and combinators that build generators out of other generators, such as Generators.lists and Generators.tuples2.

For instance, you can use Generators.lists to construct a list of integers:

  let%hegel_test append_increases_length tc =
    let xs = draw tc (lists (integers ()) ()) in
    let initial_length = List.length xs in
    let xs = draw tc (integers ()) :: xs in
    require tc ~msg:"prepending an element must grow the list"
      (List.length xs > initial_length)

Custom generators are also supported. Suppose you have a person record that requires generation. Build a generator for it with Generators.composite, drawing each field in sequence:

  type person =
    { age : int
    ; name : string
    }

  let person =
    composite (fun tc ->
      let age = draw_silent tc (integers ()) in
      let name = draw_silent tc (text ()) in
      { age; name })

You can chain drawing operations together, so a later draw depends on an earlier one. For instance, extending person with a driving_license field that can only be true once age is at least 18:

  type person =
    { age : int
    ; name : string
    ; driving_license : bool
    }

  let person =
    composite (fun tc ->
      let age = draw_silent tc (integers ()) in
      let name = draw_silent tc (text ()) in
      let driving_license =
        if age >= 18 then draw_silent tc (booleans ()) else false
      in
      { age; name; driving_license })

Changing test settings

To override the default settings, attach a [@@settings ...] attribute:

  let%hegel_test commutative_addition tc =
    let a = draw tc (integers ()) in
    let b = draw tc (integers ()) in
    require_equal tc Core.Int.sexp_of_t (a + b) (b + a)
  [@@settings Hegel.settings ~test_cases:500 ()]

This increases the number of test cases run from 100 to 500.

You can also update settings using the with_* functions:

  let%hegel_test commutative_addition tc =
    let a = draw tc (integers ()) in
    let b = draw tc (integers ()) in
    require_equal tc Core.Int.sexp_of_t (a + b) (b + a)
  [@@settings Hegel.settings ~test_cases:500 () |> with_seed 5 |> with_verbosity Verbose]

Debugging failing test cases

Use note to attach debug information:

  let%hegel_test every_int_is_small tc =
    let n = draw tc (integers ()) in
    note tc (Printf.sprintf "n is %d" n); 
    assert (n < 50)

A failing run prints a framed report: the shrunk counterexample's draws and notes, the exception, and a copy-pasteable rerun with: line whose base64 blob encodes the choice sequence that caused the failure (disable it with with_print_blob false). On a terminal the report headers (and require_equal diffs) print in color; set HEGEL_COLOR to 1 or 0 to force colors on or off.

For an equality property, prefer require_equal over assert (x = y): it adds a structural diff of the two values to this report, so you see exactly how they differ. require is the message-carrying boolean variant.

  let%hegel_test every_int_is_small tc =
    let n = draw tc (integers ()) in
    assert (n < 50)
  --- Failure: every_int_is_small (my_tests.ml:3) ------------------
  Falsified after 2 test cases (0 discarded):

    n = 50

  Exception: File "my_tests.ml", line 5, characters 2-8: Assertion failed
  rerun with: [@@failure_blobs [ "AAEAAAAACgEAAAAy" ]]

The blob can then be used to replay the failing test case:

  let%hegel_test every_int_is_small tc =
    let n = draw tc (integers ()) in
    assert (n < 50)
  [@@failure_blobs [ "AAEAAAAACgEAAAAy" ]]

The blob is only meant to reproduce the failure within a specific version of Hegel, since the choice sequence leading to a failure can change from version to version.

Learning more

See Generators for the generators and Stateful for state-machine testing.

Hegel module documentation

Sourceval version : string

The current version of Hegel for OCaml.

Sourcetype test_case

An opaque handle for the current test case, passed to your test function and threaded to draw and the other drawing primitives.

Submodules

Sourcemodule Generators : sig ... end

Generators for composable test data generation.

Sourcemodule Stateful : sig ... end

Stateful property-based testing.

Settings

Build a settings value with default_settings or settings, refine it with the with_* functions, and attach it to a let%hegel_test with the [@@settings ...] attribute:

  let%hegel_test many_cases tc =
    let n = draw tc (integers ~min_value:0 ~max_value:99 ()) in
    assert (n < 100)
  [@@settings settings ~test_cases:500 () |> with_verbosity Verbose]
Sourcetype verbosity = verbosity =
  1. | Quiet
  2. | Normal
  3. | Verbose
  4. | Debug

How much output Hegel produces during a run.

Sourcetype database = database =
  1. | Unset
  2. | Disabled
  3. | Path of string

Where Hegel stores and replays failing examples.

Sourcetype mode = mode =
  1. | Test_run
    (*

    the default: many cases, shrinking, database replay

    *)
  2. | Single_test_case
    (*

    run the body once, with no shrinking or replay

    *)

How a test run is executed.

Sourcetype phase = phase =
  1. | Explicit
    (*

    Reserved for future use: hegel-ocaml has no explicit-examples facility yet, so selecting this phase currently has no effect.

    *)
  2. | Reuse
    (*

    replay previously failing examples from the database

    *)
  3. | Generate
    (*

    generate new test cases

    *)
  4. | Target
    (*

    targeted search guided by target observations

    *)
  5. | Shrink
    (*

    shrink discovered counterexamples

    *)

Phases of a test run that can be enabled or disabled with with_phases.

Sourcetype health_check = health_check =
  1. | Filter_too_much
  2. | Too_slow
  3. | Test_cases_too_large
  4. | Large_initial_test_case

Health checks that can be suppressed with with_suppress_health_check.

Sourcetype settings = settings = {
  1. mode : mode;
  2. test_cases : int;
  3. stateful_step_count : int;
  4. verbosity : verbosity;
  5. seed : int option;
  6. derandomize : bool;
  7. database : database;
    (*

    Where failing examples are stored. When set, Hegel replays test cases from previous failed runs and records new failures as they occur.

    *)
  8. suppress_health_check : health_check list;
  9. phases : phase list option;
    (*

    None uses the engine's default phase list (all phases); Some xs restricts execution to xs.

    *)
  10. print_blob : bool;
    (*

    Print a rerun with: line whose base64 blob encodes the engine choices that led to a failure. true by default.

    *)
  11. report_multiple_failures : bool;
    (*

    false by default.

    *)
}

Configuration for a test run. Build one with default_settings or settings and refine it with the with_* functions below.

Sourceval default_settings : unit -> settings

default_settings () creates default test settings, auto-detecting CI. In CI, derandomize is true and the database is Disabled.

Sourceval settings : ?test_cases:int -> ?seed:int -> unit -> settings

settings ?test_cases ?seed () applies the given overrides to default_settings. Convenience constructor for the common cases.

  let s = settings ~test_cases:500 ~seed:42 ()
Sourceval with_test_cases : int -> settings -> settings

with_test_cases n s sets the number of test cases to run.

Sourceval with_stateful_step_count : int -> settings -> settings

with_stateful_step_count n s sets the maximum number of steps per stateful test (see Stateful).

Sourceval with_verbosity : verbosity -> settings -> settings

with_verbosity v s sets how much printed output the run produces.

  let s = default_settings () |> with_verbosity Verbose
Sourceval with_seed : int option -> settings -> settings

with_seed seed s sets the run's seed.

Sourceval with_derandomize : bool -> settings -> settings

with_derandomize b s makes the run reproducible by deriving its seed from the test's identity instead of fresh randomness.

Sourceval with_database : database -> settings -> settings

with_database db s sets where failing examples are persisted and replayed.

  let s = default_settings () |> with_database (Path "_hegel_db")
Sourceval with_suppress_health_check : health_check list -> settings -> settings

with_suppress_health_check checks s disables exactly the given health checks, replacing any previously suppressed list (like the other with_* builders).

  let s = default_settings () |> with_suppress_health_check [ Filter_too_much; Too_slow ]
Sourceval with_phases : phase list -> settings -> settings

with_phases phases s restricts the run to the given phases.

  let s = default_settings () |> with_phases [ Generate; Shrink ]
Sourceval with_mode : mode -> settings -> settings

with_mode mode s sets the execution mode.

Sourceval with_print_blob : bool -> settings -> settings

with_print_blob b s controls whether a failing run's report ends with a copy-pasteable rerun with: line encoding the engine choices that led to the failure. On by default.

Sourceval with_report_multiple_failures : bool -> settings -> settings

with_report_multiple_failures b s makes a failing run report every distinct failure it found rather than just the first.

Running tests

Sourcetype test_location = test_location = {
  1. function_name : string;
  2. file : string;
    (*

    Full source path as captured by __FILE__.

    *)
  3. begin_line : int;
    (*

    1-based line number of the test's let binding.

    *)
}

A source location identifying a single test, used to create the test's key in the database and by the Antithesis integration to build its assertion. The let%hegel_test PPX builds one automatically. Construct one manually to pass ~test_location to a direct run_hegel_test call.

Sourceval run_hegel_test : ?settings:settings -> ?test_location:test_location -> ?database_key:string -> ?failure_blobs:string list -> (test_case -> unit) -> unit

run_hegel_test ?settings ?test_location ?database_key ?failure_blobs test_fn runs a property test against the native engine, defaulting to default_settings. Call it directly to drive a property from a plain executable or another test harness:

  let my_settings = settings ~test_cases:50 ~seed:5 () in
  let () =
    run_hegel_test ~settings:my_settings (fun tc ->
      let n = draw tc (integers ~min_value:0 ~max_value:9 ()) in
      assert (n >= 0 && n <= 9))
  • parameter test_location

    source location of the test, used by the Antithesis integration. Provided automatically by the let%hegel_test PPX. When omitted, no Antithesis assertion is emitted.

  • parameter database_key

    optional key scoping persisted/replayed failing examples and, under derandomize, the per-test seed. Defaults to the test's test_location (as file:function_name) so each let%hegel_test gets a stable, distinct key; pass an explicit key to override. When both are absent, the engine uses its own default key.

  • parameter failure_blobs

    a list of base64 encoded strings (blobs), where each string encodes the choices made in a failing test run. When the list is nonempty, only the first blob is decoded and run. A blob is only guaranteed to reproduce a failure within the same version of Hegel.

Drawing values

Sourceval draw : ?label:string -> test_case -> ('a, Generators.printable) Generators.generator -> 'a

draw ?label tc gen produces a typed value from the printable generator gen using test case tc.

On the final replay of a failing test (or on every case under verbose output), an outermost draw prints its value as name = value, where name is label when given, else "draw". An unlabeled draw is numbered ("draw_1", "draw_2", …) while a label is printed bare. To draw a generator with no printer, use draw_silent or attach a printer with with_printer.

Inside a let%hegel_test, the PPX supplies the binding name as the label, so let x = draw tc gen prints its value as x = value. When the same name is shadowed or drawn in a loop, its draws are numbered x_1, x_2, … in draw order. Pass ?label to override the name (e.g. draw ~label:"y" tc gen).

  let%hegel_test draw_example tc =
    let n = draw tc (integers ~min_value:0 ~max_value:100 ()) in
    assert (n >= 0)
Sourceval draw_silent : test_case -> ('a, 'p) Generators.generator -> 'a

draw_silent tc gen produces a typed value from any generator without recording it for the final-replay output. Use it for draws whose value is not a useful part of the printed counterexample, or for generators that carry no printer.

  let%hegel_test draw_silent_example tc =
    let n = draw_silent tc (map (fun x -> x * 2) (integers ~min_value:0 ~max_value:9 ())) in
    assert (n >= 0)

Guiding generation

Sourceexception Assume_rejected

Raised by assume when its condition is false (rejecting the current test case).

Sourceval assume : test_case -> bool -> unit

assume tc condition states a precondition. If condition is false the current test case is discarded (not failed) and Hegel generates another. Use it to skip inputs that do not apply to a property.

  let%hegel_test head_cons_tail_reconstructs tc =
    let xs = draw tc (lists (integers ()) ()) in
    (* The property is only meaningful for non-empty lists. *)
    assume tc (xs <> []);
    assert (List.hd xs :: List.tl xs = xs)

Discarding too many cases trips the Filter_too_much health check. For a narrow precondition, write a generator that generates valid inputs by construction (e.g. making the minimum size of the list 1 in the example above).

The tc handle is accepted for API symmetry with the other per-test-case primitives; the rejection itself is client-side and does not consult tc.

Sourceval target : test_case -> float -> string -> unit

target tc value label sends a target command to guide the search engine toward higher values.

  let%hegel_test grow_size tc =
    let v = draw tc (integers ~min_value:0 ~max_value:1000 ()) in
    target tc (float_of_int v) "size";
    assert (v <= 1000)

Debugging tests

Sourceval note : test_case -> string -> unit

note tc message prints message to stderr subject to the run's verbosity: never under Quiet, only on the final (failing) replay under Normal, and on every test case under Verbose or Debug.

  let%hegel_test note_value tc =
    let n = draw tc (integers ~min_value:0 ~max_value:99 ()) in
    note tc (Printf.sprintf "n is %d" n);
    assert (n < 100)
Sourceval require : test_case -> ?msg:string -> bool -> unit

require tc ?msg condition fails the current test case when condition is false by raising Failure msg (msg defaults to a generic message).

  let%hegel_test balanced tc =
    let l = draw tc (lists (integers ()) ()) in
    require tc ~msg:"sum must stay non-negative" (running_sum l >= 0)
Sourceval require_equal : test_case -> ?msg:string -> ('a -> Core.Sexp.t) -> 'a -> 'a -> unit

require_equal tc ?msg sexp_of lhs rhs fails the current test case when the two values render to different sexps under sexp_of. The failure report's body shows a structural sexp diff of the two values. Lines marked - appear only in lhs, lines marked + only in rhs. Prefer it over assert (lhs = rhs), which reports only that the assertion failed, not the two values or how they differ.

  let%hegel_test sort_is_stable tc =
    let l = draw tc (lists (integers ()) ()) in
    require_equal
      tc
      (Core.List.sexp_of_t Core.Int.sexp_of_t)
      (List.sort compare l)
      (stable_sort l)

with_printer sexp_of gen attaches (or replaces) gen's printer, yielding a printable generator that draw accepts. This is how a map/flat_map/sampled_from/just result is made drawable with draw.

  let%hegel_test with_printer_example tc =
    let doubled = map (fun x -> x * 2) (integers ~min_value:0 ~max_value:9 ()) in
    let n = draw tc (with_printer Core.Int.sexp_of_t doubled) in
    assert (n >= 0)

Concurrency and parallelism

Hegel can drive generation from more than one thread or domain within a single test. Two rules govern it.

First, test-case handles may not be shared. A single handle must be drawn from by one thread at a time, so give each thread its own clone using clone. A clone has its own choice sequence. Drawing from one shared handle on multiple threads throws a concurrent-use error. Concurrently driving one shared collection, pool, or state machine will likely produce flaky results, so always make a new one per unit of concurrency/parallelism.

Second, a draw is a synchronous engine call that holds its domain's runtime lock and never yields, so it cannot cooperate with an event loop or overlap another draw on the same domain.

As long as you follow these two rules and your code is deterministic, you will be able to replay failures.

Some advice for common concurrency/parallelism libraries:

Use Threads for interleaving of concurrent operations and overlapping blocking work, not parallel generation, since draws serialize under the runtime lock. You should use spawn / join rather than Thread.create. Thread.join drops a worker's exception, whereas join re-raises it into the runner.

  let%hegel_test concurrent_workers tc =
    let w = spawn tc (fun worker -> draw_silent worker gen) in
    let mine = draw_silent tc gen in
    ignore (mine, join w)

Use Domainslib or any domain pool for when you need true parallelism, such as higher generation throughput. We strongly recommend that you do not use domains directly, as they are expensive to create and destruct. Set up the pool once and reuse it. Clone up front then Task.async each clone and Task.await it.

  (* the pool is created once and reused across cases *)
  let pool = Domainslib.Task.setup_pool ~num_domains:2 ()

  let%hegel_test parallel_generation tc =
    Domainslib.Task.run pool (fun () ->
      let worker = clone tc in
      let p = Domainslib.Task.async pool (fun () -> draw_silent worker gen) in
      let mine = draw_silent tc gen in
      ignore (mine, Domainslib.Task.await pool p))

Use Eio for concurrent generation with structured concurrency or if your code already uses Eio. Each fiber should draw its own data from its own clone. Since a draw does not yield, only separate domains make draws truly parallel. Here two workers race increments onto a shared atomic. The property is that no update is lost.

  Eio_main.run @@ fun env ->
  let dmgr = Eio.Stdenv.domain_mgr env in
  run_hegel_test (fun tc ->
    let counter = Atomic.make 0 in
    let amounts = integers ~min_value:0 ~max_value:100 () in
    let worker g () =
      Eio.Domain_manager.run dmgr (fun () ->
        let n = draw_silent g amounts in
        ignore (Atomic.fetch_and_add counter n : int);
        n)
    in
    let worker_b = clone tc in
    let sum_a, sum_b = Eio.Fiber.pair (worker tc) (worker worker_b) in
    require_equal tc Core.Int.sexp_of_t (sum_a + sum_b) (Atomic.get counter))
Sourceval clone : test_case -> test_case

clone tc creates a clone of tc, an independent stream of the same test case. A single test_case handle must not be drawn from concurrently, so give each thread its own clone.

Because Thread.join drops a worker's exception, you must capture the worker's result or its exception and re-raise it on the calling thread. spawn / join wrap that pattern for you.

  let%hegel_test two_hands_two_dice_manual tc =
    let die = integers ~min_value:1 ~max_value:6 () in
    let other_hand = clone tc in
    let out = ref (Error (Failure "unset")) in
    let rolling =
      Thread.create
        (fun () -> out := (try Ok (draw_silent other_hand die) with e -> Error e))
        ()
    in
    let right_hand = draw_silent tc die in
    Thread.join rolling;
    match !out with
    | Ok left_hand -> assert (right_hand + left_hand >= 2)
    | Error e -> raise e
Sourcetype 'a worker

A running worker started by spawn and awaited with join.

Sourceval spawn : test_case -> (test_case -> 'a) -> 'a worker

spawn tc f clones tc and runs f clone on a new thread. join awaits it. The example below is functionally identical to the example for clone, but more ergonomic.

  let%hegel_test two_hands_two_dice tc =
    let die = integers ~min_value:1 ~max_value:6 () in
    let other_hand = spawn tc (fun worker -> draw_silent worker die) in
    let this_hand = draw_silent tc die in
    assert (this_hand + join other_hand >= 2)
Sourceval join : 'a worker -> 'a

join w waits for worker w to finish and returns its result. It re-raises any exception w raised on the caller's thread. Join before the test body returns.