Module Hegel

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.

Hegel works with whatever test framework your project already uses. The examples below use Alcotest.

Add hegel and alcotest to your dune test stanza. The examples in this documentation also use ppx_sexp_conv's [%sexp_of: t] to write value printers.

  (test
   (name my_tests)
   (libraries hegel alcotest)
   (preprocess (pps ppx_hegel_test ppx_sexp_conv)))

Write your first test

Write a property test using let%hegel_test:

  open Hegel

  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 [%sexp_of: int] (a + b) (b + a)

  let () =
    Alcotest.run
      "my_tests"
      [ "properties", [ Alcotest.test_case "commutative addition" `Quick commutative_addition ] ]

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. An 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 Alcotest report the test as passing. 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 = …).

The rest of the examples in the documentation assume you have open Hegel 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) ------------------

    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. See Generators for the full reference.

For instance, you can use 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 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 })

Derive generators

Annotate the type with [@@deriving hegel_generator] and add ppx_hegel_generator to your preprocess stanza:

  (test
   (name my_tests)
   (libraries hegel alcotest)
   (preprocess (pps ppx_hegel_test ppx_hegel_generator)))

open Hegel is required before the first @@deriving hegel_generator, unless you want to derive generators for Core types (see Hegel_jane.Derive).

  open Hegel

  type point =
    { x : int
    ; y : int
    }
  [@@deriving hegel_generator]

  let%hegel_test point_roundtrip tc =
    let p = draw tc hegel_generator_point in
    assert ({ x = p.x; y = p.y } = p)

The type t derives a value named hegel_generator. Any other type foo derives hegel_generator_foo. Derived generators print drawn values as s-expressions.

Deriving generators also works on types in modules:

  module Temperature = struct
    type t = { celsius : float } [@@deriving hegel_generator]
  end

  type reading =
    { sensor_id : int
    ; temp : Temperature.t
    }
  [@@deriving hegel_generator]

The reading generator draws its temp field through Temperature.hegel_generator.

[@hegel.generator expr] sets the generator for a type instead of deriving it:

  type ranked =
    { name : string
    ; level :
        (int[@hegel.generator integers ~min_value:1 ~max_value:5 ()])
    }
  [@@deriving hegel_generator]

[@hegel.do_not_generate] excludes a variant constructor from being generated.

  type response =
    | Ok_response of int
    | Errored of exn [@hegel.do_not_generate]
  [@@deriving hegel_generator]

If a field's type has no sexp_of_* representation, mark the field [@sexp.opaque]:

  type connection = { send : bytes -> unit }

  type session =
    { id : int
    ; conn : (connection [@sexp.opaque])
    }
  [@@deriving hegel_generator]

The conn field prints as <opaque>.

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 [%sexp_of: int] (a + b) (b + a)
  [@@settings Settings.create ~test_cases:500 ()]

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

Settings can be changed with record update syntax.

  let%hegel_test commutative_addition tc =
    let a = draw tc (integers ()) in
    let b = draw tc (integers ()) in
    require_equal tc [%sexp_of: int] (a + b) (b + a)
  [@@settings { (Settings.create ~test_cases:500 ()) with verbosity = Settings.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 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) ------------------

    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.

Jane Street Core support

Projects that use Jane Street's Core can also use the optional Hegel_jane library to generate Core values. See Hegel_jane.

Learning more

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

Hegel module documentation

val version : string

The current version of Hegel for OCaml.

type 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

module Generators : sig ... end

Generators for composable test data generation.

module Stateful : sig ... end

Stateful property-based testing.

module Concurrency : sig ... end

Concurrency capabilities for concurrent stateful tests.

module Derive : sig ... end

Auxiliary submodule for @@deriving hegel_generator.

Settings

module Settings : sig ... end

Configuration for a Hegel test run.

Running tests

type 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. It creates the test's key in the Settings.database, and libhegel uses it inside Antithesis to report the run's result as an assertion at that location. The assertion name is <path>::<function_name> passes properties, where path is file without its extension. The let%hegel_test PPX builds one automatically. Construct one manually to pass ~test_location to a direct run_hegel_test call.

val run_hegel_test : ?settings:Settings.t -> ?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 Settings.default. Call it directly to drive a property from an executable or another test harness:

  let my_settings = Settings.create ~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. Passed to the engine, which inside Antithesis reports the run's verdict as an assertion at that location; it also defaults database_key. Provided automatically by the let%hegel_test PPX. When omitted, nothing is reported.

  • 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

val draw : ?label:string -> ?loc:Stdlib.Lexing.position -> test_case -> ('a, Generators.printable) Generators.generator -> 'a

draw ?label ?loc 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 through Internal.note as name = value when no location is supplied. The name is label when given, else "draw". An unlabeled draw is numbered (draw_1, draw_2, …). Draws nested inside a span (e.g. composite elements) are suppressed so only the outermost value shows. To draw a generator with no printer, use draw_silent or attach a printer with with_printer.

?loc adds the source filename and line to the printed draw. In OxCaml, the compiler automatically supplies the caller's position when omitted. Providing loc overrides that position. The draw is then printed as name @ filename:line = value.

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)
val 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

exception Assume_rejected

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

val 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.

val target : test_case -> label:string -> value:float -> unit

target tc ~label ~value 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 ~label:"size" ~value:(float_of_int v);
    assert (v <= 1000)

Collecting statistics

With show_statistics enabled in Settings.t, every run prints test statistics.

  let%hegel_test list_statistics tc =
    let xs = draw_silent tc (lists (integers ()) ()) in
    (match xs with
     | [] -> event tc ~label:"empty input"
     | _ -> ());
    event_value tc ~label:"length" ~value:(float_of_int (List.length xs))

prints:

  Statistics (over 100 test cases):
    * empty input: 5.0% of test cases
    * length: count 100, min 0, median 4, mean 5.12, p90 9, max 15
val event : test_case -> label:string -> unit

event tc ~label records label as observed on this test case. The end-of-run statistics report (see the show_statistics field of Settings.t) shows the fraction of test cases in which each label was recorded at least once.

  let%hegel_test observe_emptiness tc =
    let xs = draw tc (lists (integers ()) ()) in
    if List.is_empty xs then event tc ~label:"empty input";
    assert (List.length (List.sort compare xs) = List.length xs)
val event_value : test_case -> label:string -> value:float -> unit

event_value tc ~label ~value records the numeric observation value under label. The end-of-run statistics report (see the show_statistics field of Settings.t) shows a distribution summary (count, min, median, mean, p90, max) per label. value must be finite.

  let%hegel_test observe_length tc =
    let xs = draw tc (lists (integers ()) ()) in
    event_value tc ~label:"length" ~value:(float_of_int (List.length xs));
    assert (List.length (List.sort compare xs) = List.length xs)

Debugging tests

val 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)
val 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)
val require_equal : test_case -> ?msg:string -> ('a -> Sexplib0.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. With the optional hegel.jane library's structural diff set (Hegel_jane.set_sexp_diff), a sexp_diff two-column diff is printed.

  let%hegel_test sort_is_stable tc =
    let l = draw tc (lists (integers ()) ()) in
    require_equal
      tc
      [%sexp_of: int list]
      (List.sort compare l)
      (stable_sort l)
val with_printer : ('a -> Sexplib0.Sexp.t) -> ('a, 'p) Generators.generator -> ('a, Generators.printable) Generators.generator

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 [%sexp_of: int] doubled) in
    assert (n >= 0)

Cloning a test case

val clone : test_case -> test_case

clone tc creates an independent clone of tc. It has its own choice stream forked from the parent test case. When the parent test case is freed, its clones are freed with it. Each concurrent task must have its own clone.