hegeltest — Property-based testing for Dart, powered by a native fuzzing engine.

pub package CI license

What is property-based testing?

Instead of writing individual test cases, you describe properties that should hold for all inputs. hegeltest generates random inputs, finds failures, and automatically shrinks them to the minimal counterexample. This allows you to find edge cases you might never have thought to write tests for.

Quick Start

Add hegeltest to your pubspec.yaml under dev_dependencies:

dev_dependencies:
  hegeltest: ^0.5.0
  test: ^1.25.0

Then, write your property-based test:

import 'package:hegeltest/hegeltest.dart';
import 'package:test/test.dart';

void main() {
  hegelTest('reverse is involutory', (tc) {
    // Provide a label to make counterexample output clearer on failure
    final xs = tc.draw(lists(integers()), label: 'original list');
    expect(xs.reversed.toList().reversed.toList(), equals(xs));
  });
}

Available Generators

(If you only need generators, you can use the sub-path import: import 'package:hegeltest/generators.dart';)

Category Generators
Primitives integers(), doubles(), booleans(), bigIntegers()
Text text(), fromRegex(), emails(), urls(), domains(), uuids()
Collections lists(), sets(), maps()
Combinators oneOf(), nullable(), sampled(), frequency(), tuples2/3/4()
Temporal dates(), times(), dateTimes()
Network ipv4Addresses(), ipv6Addresses()
Bytes bytes()

Composing Generators

You can build complex generators using combinators like map(), flatMap(), where(), and Generator.composite():

final evenIntegers = integers().where((i) => i.isEven);
final stringLengths = text().map((s) => s.length);
// Or build entirely new types
final userGen = Generator.composite<User>((tc) {
  final name = tc.draw(text(minSize: 1, maxSize: 50));
  final age = tc.draw(integers(min: 0, max: 150));
  return User(name: name, age: age);
});

Weighted Sampling

Pick values or generators based on relative weights:

// Weighted raw values
final statusGen = sampledWeighted([
  (80, 'active'),
  (15, 'pending'),
  (5, 'suspended'),
]);

// Weighted generators (oneOfWeighted is an alias for frequency)
final numberGen = oneOfWeighted([
  (7, integers(min: 0, max: 100)),
  (3, integers(min: 1000, max: 10000)),
]);

Preconditions and Filtering

You can filter out invalid inputs using tc.assume(). If the condition is false, the current test case is discarded and a new one is generated:

final a = tc.draw(integers());
tc.assume(a != 0); // discard test cases where a is 0

Optimization Hints

You can guide the engine's fuzzing towards specific edge cases using tc.target(). It records a numeric observation that the engine attempts to maximize or minimize. The label is required:

final items = tc.draw(lists(integers()), label: 'items');
tc.target(items.length.toDouble(), label: 'list_length');

Collecting Statistics & Coverage

Use tc.collect() to track the distribution of generated values across all valid test cases. This helps verify that your generators are producing a balanced variety of inputs:

hegelTest('reversing twice returns original list', (tc) {
  final items = tc.draw(lists(integers()), label: 'items');

  tc.collect(
    switch (items.length) {
      0 => 'empty',
      < 5 => 'short',
      _ => 'long',
    },
    label: 'length',
  );

  expect(items.reversed.toList().reversed.toList(), equals(items));
}, verbosity: Verbosity.verbose);

When run with verbosity: Verbosity.verbose, a distribution summary is printed at the end:

Collected statistics:
  length:
    52.0% short
    38.0% long
    10.0% empty

If a test case is discarded via tc.assume(), its observations are automatically excluded from the statistics.

Classifying Observations

Use tc.classify() to record observations conditionally:

tc.classify(items.isEmpty, 'empty');
tc.classify(items.length > 50, 'large');

Coverage Assertions

Use tc.cover() to assert that a target condition holds across at least a minimum percentage of valid test cases:

hegelTest('handles empty and non-empty collections', (tc) {
  final items = tc.draw(lists(integers()));

  // Require that at least 10% of test cases exercise the empty list edge case:
  tc.cover(10.0, items.isEmpty, 'empty collections');

  expect(processItems(items).length, equals(items.length));
});

If the test completes and the condition held in less than the required percentage of iterations, hegelTest fails with an InsufficientCoverageException. Coverage is computed exclusively over valid test cases (discarded cases from tc.assume() are excluded).

Stateful Testing

Test stateful systems by generating random sequences of operations and checking invariants after each step. Uses Swarm Testing to explore rule subsets and automatic shrinking to find minimal counterexamples.

class StackMachine extends StateMachine {
  final stack = <int>[];
  final model = <int>[];

  @override
  List<StateRule> get rules => [
    StateRule('push', execute: (tc) {
      final val = tc.draw(integers(min: -100, max: 100));
      stack.add(val);
      model.add(val);
    }),
    StateRule('pop',
      precondition: () => stack.isNotEmpty,
      execute: (tc) {
        expect(stack.removeLast(), equals(model.removeLast()));
      },
    ),
  ];

  @override
  List<StateInvariant> get invariants => [
    StateInvariant('size matches', check: (tc) {
      expect(stack.length, equals(model.length));
    }),
  ];
}

void main() {
  hegelStatefulTest('stack behaves like list', () => StackMachine());
}

Pools — tracking values across rules

Use Pool<T> to share values between rules (like keys you've inserted into a database):

class KVStoreMachine extends StateMachine {
  final store = <String, int>{};
  late final Pool<String> keys;

  @override
  void setUp() { keys = createPool<String>(); }

  @override
  List<StateRule> get rules => [
    StateRule('put', execute: (tc) {
      final key = tc.draw(text(minSize: 1, maxSize: 5));
      final val = tc.draw(integers(min: 0, max: 999));
      store[key] = val;
      keys.add(key);                        // track the key
    }),
    StateRule('get',
      precondition: () => keys.isNotEmpty,
      execute: (tc) {
        final key = tc.draw(keys.reusable);  // draw without removing
        expect(store.containsKey(key), isTrue);
      },
    ),
    StateRule('delete',
      precondition: () => keys.isNotEmpty,
      execute: (tc) {
        final key = tc.draw(keys.consumed);  // draw and remove from pool
        store.remove(key);
      },
    ),
  ];
}

Standalone Runner

If you are building custom test runners, integrating with other tools, or just want programmatic access to the fuzzing engine without package:test integration, you can use runHegelTest():

import 'package:hegeltest/hegeltest.dart';

void main() async {
  final result = await runHegelTest((tc) {
    final a = tc.draw(integers());
    if (a < 0) throw Exception('No negatives!');
  });

  print(result.status); // RunStatus.failed
  print(result.testCasesRun);
  
  for (final failure in result.failures) {
    print(failure.message);
    print(failure.reproductionBlob);
  }
}

Configuration

For reusable test configurations, you can use HegelConfig:

final thorough = HegelConfig(testCases: 100000);
hegelTest('check', (tc) { ... }, config: thorough);

Persistent Counterexample Database

By default, hegeltest automatically caches discovered failing counterexamples to .hegel/examples/ (scoped by the test's description). On subsequent test runs, known failing examples are replayed first on iteration 1 during Phase.reuse, providing instant regression feedback before generating fresh random inputs.

To ensure your repository worktree stays clean, hegeltest automatically creates a .gitignore inside .hegel/.

You can configure or disable persistence:

  • Opt-out: pass database: false or set the environment variable HEGEL_DATABASE=0 to disable disk persistence and replay.
  • Custom storage path: pass databasePath: '.custom_db/' to store examples in an alternative directory.
  • Stable scoping: pass databaseKey: 'my_stable_key' to preserve cache continuity even if a test description changes.

In CI pipelines (e.g. GitHub Actions), cache .hegel/ to catch regressions from previous runs instantly:

- name: Cache Hegel counterexamples
  uses: actions/cache@v4
  with:
    path: .hegel/
    key: hegel-${{ runner.os }}-${{ github.ref_name }}
    restore-keys: hegel-${{ runner.os }}-

For CI reproducibility without the database, you can set the HEGEL_SEED environment variable. When set, all hegelTest calls use this deterministic seed unless explicitly overridden.

Advanced Configuration

You can fine-tune the engine's behavior using advanced settings:

  • phases: Control which phases to run — Phase.explicit, Phase.reuse, Phase.generate, Phase.target, Phase.shrink.
  • verbosity: Set output detail — Verbosity.quiet, Verbosity.normal, Verbosity.verbose, Verbosity.debug.
  • backend: Choose the randomness source — Backend.auto_, Backend.default_, Backend.urandom.
  • suppressHealthChecks: Disable specific engine health checks like HealthCheck.tooSlow, HealthCheck.filterTooMuch, HealthCheck.returnsSlowly, HealthCheck.largeBaseExample.
  • derandomize: Avoid randomizing generation if possible.
  • reportMultipleFailures: Report all failures instead of stopping at the first.

Per-Iteration Isolation

If your test mutates state, make sure to isolate iterations properly using setUpEach and tearDownEach instead of the standard package:test setup functions. package:test's setUp runs once per property, not per iteration.

hegelTest('stateful test', (tc) { ... },
  setUpEach: () => resetState(),
  tearDownEach: () => cleanupState(),
);

Reproducing Failures

When a test fails, hegeltest provides a reproducible blob. You can use it to deterministically replay the exact failing scenario:

hegelTest('flaky test', (tc) { ... }, 
  reproduce: 'ABcdef123...', 
);

Flutter

For Flutter apps, use hegeltest_flutter:

dev_dependencies:
  hegeltest_flutter: ^0.2.0
import 'package:hegeltest_flutter/hegeltest_flutter.dart';

void main() {
  hegelFlutterTest('addition is commutative', (tc) {
    final a = tc.draw(integers());
    final b = tc.draw(integers());
    expect(a + b, equals(b + a));
  });

  hegelFlutterStatefulTest('stack works', () => StackMachine());
}

Platform Support

Platform Architecture Status
macOS Apple Silicon (arm64) ✅ Bundled
macOS Intel (x64) 🔜 Coming
Linux x64 ✅ Bundled
Linux arm64 ✅ Bundled
Windows x64 ✅ Bundled
Windows arm64 ✅ Bundled

All bundled binaries are verified via ABI version check at load time.

Set HEGEL_LIBHEGEL_PATH to use a custom-built binary on unsupported platforms.

Version Policy

Branch Dart SDK Status
hegeltest ^0.5.0 >=3.10.0 Active — all new features
hegeltest ^0.4.0 >=3.4.0 Maintenance — security fixes only

CI/CD Notes

hegeltest uses Dart's Build Hooks to register native binaries. The build hook runs automatically during dart test and flutter test — no extra CI configuration needed. No network access is required (binaries are bundled in the package).

License

This package is licensed under the MIT license.

Libraries

generators
Sub-path import for generators only.
hegeltest
Property-based testing for Dart, powered by Hegel's native engine.