hegeltest — Property-based testing for Dart, powered by a native fuzzing engine.
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: falseor set the environment variableHEGEL_DATABASE=0to 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 likeHealthCheck.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.