Hegel for Ruby

[!IMPORTANT] This is an unofficial, third-party implementation. The Hegel project and the libhegel engine belong to Antithesis, LLC. This repository has no affiliation with Antithesis or with the hegeldev organization, and nobody there reviews or endorses it.

The official implementations are Rust, Go, C++, TypeScript, Java, and OCaml. Report problems with this gem here, not to them.

[!NOTE] Hegel itself is in beta, and its maintainers expect to make breaking changes. See https://hegel.dev/compatibility.

Hegel is a property-based testing framework based on Hypothesis. Instead of writing tests with hand-picked inputs, you state a property that must hold for every input. Hegel generates inputs, tries to falsify the property, and shrinks any failure to a minimal counterexample.

This gem drives libhegel, the native engine that the official implementations also drive. The engine runs in the same process. There is no server and no Python dependency.

Status

The first release is prepared, and the version is 0.1.0. Everything described below runs: Hegel finds a counterexample, shrinks it, names the values it drew, and re-raises your own exception.

Work ran in three stages, and all three are done:

  1. The walking skeleton: the libhegel binding, the run loop, and failure reports.
  2. The full generator set: twenty-five generators, composing through map and filter.
  3. The advanced features: the example database, targeted testing, stateful testing, phases, and health checks.

Nothing is published to RubyGems yet. The packaging is built: rake platform_gems:build produces one gem per platform, each carrying the matching libhegel build. The arm64 macOS gem is verified to find that engine inside itself and run a property with no HEGEL_LIBHEGEL_PATH set. A v* tag publishes all six gems from CI. The Hegel maintainers permit a third-party project to redistribute their release binaries (hegeldev/hegel-rust#411).

So running this today means cloning the repository:

bin/setup
bundle exec rake libhegel:fetch   # downloads the pinned build, checked against its SHA-256
bundle exec rake

Every push to main runs the suite on Ruby 3.3, 3.4, and 4.0, across Linux x64 and arm64, macOS arm64, and Windows x64 and arm64. Windows arm64 starts at Ruby 3.4, the oldest Ruby that RubyInstaller publishes an arm64 build for. A pull request runs Linux x64 alone.

Design

Decision Choice
Gem name hegeltest
Require path require "hegel" (require "hegeltest" also works)
Namespace Hegel
Binding the ffi gem, which publishes a prebuilt binary for every platform above
Ruby 3.3, 3.4, and 4.0
Platforms Linux amd64/arm64, macOS arm64, Windows amd64/arm64
Engine delivery One prebuilt libhegel per platform-specific gem, built by rake platform_gems:build

HEGEL_LIBHEGEL_PATH will override the bundled engine with a local build.

The gem name follows the Rust implementation, whose published crate is hegeltest and whose library is hegel. The name hegel on RubyGems.org stays free for whoever publishes an official Ruby implementation.

macOS on Intel has no published libhegel artifact, so that platform will need HEGEL_LIBHEGEL_PATH and a local build.

Quickstart

Everything below runs today.

# spec/spec_helper.rb
require "hegel"

RSpec.configure do |config|
  config.include Hegel::Syntax::Methods
end
# spec/my_sort_spec.rb
def my_sort(ls) = ls.sort.uniq # oops: uniq drops duplicates

RSpec.describe "my_sort" do
  it "matches the builtin sort" do
    Hegel.test do |tc|
      xs = tc.draw(arrays(integers))
      expect(my_sort(xs)).to eq(xs.sort)
    end
  end
end

That test fails, and Hegel shrinks the failure to the smallest input that still shows the bug:

Falsified after 11 test cases (0 discarded):

  xs = [0, 0]

To reproduce this failure, pass the blob below to Hegel.test:
    reproduce_failure: "AXicY2VgYGBkZOBiZEBhMAAAAd8AIQ=="

Two duplicates are all it takes. Hegel names the value xs by reading the line the draw was written on, then re-raises RSpec's own expectation failure, so the framework reports it as its own.

Including Hegel::Syntax::Methods makes the generators available without a prefix, the way FactoryBot makes create available. The same generators stay reachable as Hegel::Generators.arrays(...) without the include.

The generators are arrays, binary, booleans, characters, composite, dates, datetimes, deferred, domains, emails, floats, from_regex, hashes, integers, ip_addresses, just, one_of, optional, sampled_from, sets, text, times, tuples, urls, and uuids. Each one composes through map and filter.

Minitest

Hegel needs nothing from your test framework. Install the generator methods once, and write the property inside an ordinary test:

# test/test_helper.rb
require "hegel"

class Minitest::Test
  include Hegel::Syntax::Methods
end
# test/my_sort_test.rb
class MySortTest < Minitest::Test
  def test_matches_the_builtin_sort
    Hegel.test do |tc|
      xs = tc.draw(arrays(integers))
      assert_equal xs.sort, my_sort(xs)
    end
  end
end

Minitest reports the shrunk case as its own assertion failure, pointing at your line:

1) Failure:
MySortTest#test_matches_the_builtin_sort [test/my_sort_test.rb:5]:
Expected: [0, 0]
  Actual: [0]

Stateful testing

Some bugs need a sequence of operations rather than one input. Declare the operations as rules on a Hegel::StateMachine, and Hegel picks which runs next, checks your invariants after each one, and shrinks a failure to the shortest sequence that still shows it:

class StackMachine < Hegel::StateMachine
  def initialize
    @stack = BoundedStack.new(3)
    @model = []
  end

  rule :push do |tc|
    x = tc.draw(integers(min_value: 0, max_value: 9))
    @stack.push(x)
    @model.push(x) if @model.size < 3
  end

  rule :pop do |tc|
    tc.assume(!@model.empty?)
    raise "pop disagreed" unless @stack.pop == @model.pop
  end

  invariant :size_agrees do
    raise "size disagreed" unless @stack.size == @model.size
  end
end

Hegel.test { |tc| Hegel::Stateful.run(StackMachine.new, tc) }

tc.assume inside a rule rejects that rule and lets Hegel choose another, rather than throwing the whole test case away. For a rule that has to act on something an earlier rule produced, such as freeing a handle that some alloc actually returned, put the value in a Hegel::Stateful::Pool and draw it back out.

Shaping a run

Hegel.test takes keywords for the rest: test_cases, seed, derandomize, verbosity, phases, suppress_health_check, report_multiple_failures, and stateful_step_count. Each one left unset means the engine's own default.

Two more turn on libhegel's example database, which stores a failing case and replays it first next time. database_key is the switch and database chooses the directory:

Hegel.test(database_key: "my_sort matches the builtin sort") { |tc| ... }

Give each property its own key. See ADR 0009 for why the key, rather than the directory, is what turns it on.

Inside a test, tc.note records a message the failure report prints, and tc.target tells Hegel which inputs to search toward.

Development

bin/setup          # install dependencies
bundle exec rake   # run the tests and the linter
bin/console        # open an interactive prompt

just recipes call the same Rake tasks, so just test and just lint work for anyone who already uses just with the other Hegel implementations.

Contributing

Report bugs and open pull requests at https://github.com/meganemura/hegel-ruby. Contributors follow the code of conduct.

License

This gem is available under the MIT License.

Released gems also carry libhegel's own license, which is MIT and copyright Antithesis, LLC. Each platform gem ships NOTICE-libhegel.txt next to the engine, and that file holds the text.