Hegel Property-Based Testing for Rust

repository·main·Indexed 43 days ago

https://github.com/hegeldev/hegel-rust

A property-based testing library for Rust, version 0.46.1, inspired by Hypothesis and utilizing the Hegel protocol to find minimal failing test cases. It features the DataSource trait for generating primitive types, collections, recursive structures, and state machines for concurrency testing. The library also provides libhegel, a C ABI for non-Rust languages and WebAssembly targets.

Tokens
38.2K
Snippets
121
Records
161
Agent score
56%

What's inside Hegel

  1. Understand Hegel Settings and their hierarchy

    main

    Every Hegel run is governed by a Settings value. Settings are applied in a layered hierarchy where each subsequent layer overrides the previous one. The order of precedence (from lowest to highest) is:

    1. Base Settings: The immutable core values (e.g., test_cases = 100, verbosity = "normal"). Accessible via the reserved base profile.
    2. Profiles: Named sets of overrides (e.g., ci, development, or custom profiles in hegel.toml). These resolve by walking a chain down to base.
    3. Compiled-in Settings: Settings defined directly on the test using attribute macros like #[hegel::test(...)] or #[hegel::main(...)].
    4. Command-line Flags: For #[hegel::main] binaries, flags like --seed or --verbosity apply on top of compiled-in settings.
    5. Environment Variables: The final layer. Variables like HEGEL_TEST_CASES override everything else, including settings in the source code.
    // Example of compiled-in settings via attribute
    #[hegel::test(test_cases = 500, derandomize = true)]
    fn compiled_in(tc: TestCase) {
        tc.draw(gs::booleans());
    }
  2. Understand Hegel Profiles and Inheritance

    main

    A profile is a named set of overrides that can extend other profiles. Resolving a profile involves walking the inheritance chain down to the base settings and applying deltas bottom-up.

    Reserved Names

    • base: The immutable core settings. Every chain terminates here.
    • default: An alias for the profile chosen by the environment (see Choosing the default profile).

    Shipped Profiles

    • development: The default profile used locally.
    • ci: Used on CI servers (detects CI, GITHUB_ACTIONS, etc.). Sets derandomize = true, database = "disabled", and suppresses too_slow health checks.
    • workload: Used inside Antithesis (detects ANTITHESIS_OUTPUT_DIR). Sets backend = "urandom", database = "disabled", and suppresses all health checks.

    Custom Profiles

    Custom profiles are defined in hegel.toml. If a profile does not specify an extends key, it automatically extends default.

    [profiles.nightly]
    test_cases = 10000
    
    [profiles.pinned]
    extends = "base"
    test_cases = 10000
    
    [profiles.deep-ci]
    extends = "ci"
    test_cases = 100000
  3. Install Hegel for Rust

    main

    Add hegeltest to your Cargo.toml as a development dependency to use Hegel's property-based testing capabilities in your Rust projects.

    cargo add --dev hegeltest
  4. Install hegeltest via cargo

    main

    To add Hegel to your Rust project for development/testing purposes, use the cargo add command with the --dev flag to ensure it is added to your [dev-dependencies].

    cargo add --dev hegeltest
  5. Build libhegel for WebAssembly (Wasm)

    main

    To build the raw C ABI for the wasm32-unknown-unknown target, ensure you have the target installed via rustup and use the just command-line tool to run the build script.

    Running just c-build-wasm will also build the static archive if the wasm-static feature is enabled.

    Wasm Platform Service Hooks: When running in a WebAssembly environment, the module expects the host to provide the following platform services:

    • Entropy: hegel_host.entropy_fill (or hegel_host_entropy_fill when using the static archive)
    • Time: hegel_host.monotonic_nanos (or hegel_host_monotonic_nanos when using the static archive)

    Limitations: In this single-threaded Wasm build, filesystem persistence and concurrent state machines are unavailable. The host environment (e.g., a browser) must supply the necessary imports and handle Wasm memory.

    rustup target add wasm32-unknown-unknown
    just c-build-wasm
  6. Write a property-based test with #[hegel::test]

    main

    Hegel allows you to write property-based tests using the #[hegel::test] attribute. Instead of passing arguments directly to the test function, you pass a TestCase object. You then use tc.draw() combined with generators from hegel::generators to produce random data for your test properties.

    When a test fails, Hegel automatically performs shrinking to provide the minimal failing test case.

    use hegel::generators as gs;
    use hegel::TestCase;
    
    fn my_sort(ls: &[i32]) -> Vec<i32> {
        let mut result: Vec<i32> = ls.to_vec();
        result.sort();
        result.dedup();
        result
    }
    
    #[hegel::test]
    fn test_matches_builtin(tc: TestCase) {
        // Use tc.draw to pull generated data from a generator
        let mut vec1 = tc.draw(gs::vecs(gs::integers::<i32>()));
        let vec2 = my_sort(&vec1);
        vec1.sort();
        assert_eq!(vec1, vec2);
    }
  7. Choose the default profile for a run

    main

    The default profile is determined by the strongest of the following (in order of precedence):

    1. The --profile <name> flag used on a #[hegel::main] binary.
    2. The HEGEL_DEFAULT_PROFILE environment variable.
    3. The top-level default = "<profile>" entry in hegel.toml.
    4. The environment's automatic profile: workload (in Antithesis), ci (on CI servers), or development (locally).

    Note: Setting a custom default profile changes what custom profiles fall back to extending if they don't specify a parent.

    HEGEL_DEFAULT_PROFILE=nightly cargo test
  8. Use HEGEL_CONFIG to specify a configuration file path

    main

    If your test process is running in an environment where the standard directory search might fail (such as a Bazel sandbox), you can bypass the search by setting the HEGEL_CONFIG environment variable to the absolute or relative path of your hegel.toml file.

    Warning: If HEGEL_CONFIG is set to a path that cannot be read, the process will error out rather than ignoring the missing configuration.

    # Example: setting the config path in a shell
    export HEGEL_CONFIG="/path/to/your/hegel.toml"
  9. Install libhegel for non-Rust languages

    main

    If you are using libhegel from a language other than Rust, do not use the hegeltest-c crate from crates.io. Instead, download the prebuilt shared and static libraries and the hegel.h header from the official GitHub releases.

    Release Details:

    • Release Tags: libhegel-v<version>
    • Release Titles: libhegel v<version>
    • Supported Platforms:
      • linux/amd64
      • linux/arm64
      • darwin/arm64 (Apple Silicon)
      • windows/amd64
      • windows/arm64
    • Note on Intel macOS: darwin/amd64 is not provided in prebuilt releases. You must build the crate from source if you require this target.
    • WebAssembly: Releases include wasm32-unknown-unknown modules and static archives.
    https://github.com/hegeldev/hegel-rust/releases
  10. How PrintableGenerator works and how to implement it

    main

    A PrintableGenerator<T> is a generator that can describe its drawn values to a PrettyPrinter. Only printable generators can be passed to TestCase::draw.

    Implementation Contract

    If you are implementing a custom PrintableGenerator, you must ensure that do_draw_and_print draws exactly the same choices as do_draw. The engine uses a silent path for exploration and a printing path for replaying failures; any divergence makes failures unreplayable.

    Best Practice: Implement the drawing logic once in do_draw_and_print, and then implement do_draw by calling self.do_draw_and_print(tc, &mut PrettyPrinter::noop()). This ensures both paths are identical by construction.

    Making non-printable generators printable

    If your generator (or the type it produces) does not implement PrettyPrintable, you can use these methods:

    • .print_as_debug(): Uses the Debug representation.
    • .print_as_value(): Uses the PrettyPrintable representation (requires T: PrettyPrintable).
    • .print_with(|value, printer| ...): Provides fine-grained control over how the value is printed.
  11. Configure Hegel via hegel.toml profiles

    main

    Hegel uses named profiles to manage suite-wide settings. You can define profiles in a hegel.toml file at your package or workspace root. Profiles can inherit from others using extends.

    Common built-in profiles include development, ci, and workload. You can select a profile for a specific test using #[hegel::test(profile = "name")] or set a global default via HEGEL_DEFAULT_PROFILE.

    [profiles.development]
    test_cases = 200
    
    [profiles.nightly]
    extends = "development"
    test_cases = 10000
  12. Understand TestCaseResult and Failure

    main

    A TestCaseResult represents the outcome of a single test case execution:

    • Valid: Test passed.
    • Invalid: Test rejected due to an assumption failure.
    • Overrun: Test rejected because the backend ran out of data.
    • Interesting(Failure): A bug was found.

    If a failure is found, the Failure struct contains:

    • origin: A string identifying the bug origin (e.g., panic location).
    • reproduce_blob: A Base64 encoded string representing the minimal counterexample. This can be used with hegel_test_case_from_blob or #[hegel::reproduce_failure("...")] to replay the failure.