Hedgehog for .NET
repository·master·Indexed 43 days ago
https://github.com/hedgehogqa/fsharp-hedgehogA property-based testing library for .NET that provides powerful generators and automated shrinking to find and minimize edge cases. Compatible with F# and C#, it features support for async properties, auto-generation for records and discriminated unions, and a composable API built around Gen<T>, Range<T>, and Property<T>.
What's inside Hedgehog
- Hedgehog is a property-based testing library for .NET that explores a wide range of inputs to find edge cases. When a test fails, Hedgehog automatically performs 'shrinking' to reduce the failing input to the smallest, most minimal valid example that still triggers the failure. It is designed to preserve invariants by construction during the shrinking process. Hedgehog is compatible with any .NET testing framework and can be used in F#, C#, or other .NET languages.
What is Property-Based Testing with Hedgehog?
masterProperty-based testing allows you to describe invariants (properties) that should hold true for all possible inputs, rather than writing individual test cases with specific values. Hedgehog automatically generates hundreds of random test cases to attempt to falsify these properties.
For example, instead of testing
reversewith[1, 2, 3], you define the property:reverse (reverse xs) = xsfor all listsxs.property { let! xs = Gen.list (Range.linear 0 100) Gen.alpha return List.rev (List.rev xs) = xs } |> Property.renderHow AutoGenConfig layering and priority works
masterHedgehog merges
AutoGenConfigsettings from multiple levels. When merging, generators at more specific levels override those from outer levels. The priority order (from lowest to highest) is:- Default generators: Hedgehog's built-in generators for common types.
PropertiesAttributelevel: Configurations applied to all properties in a class/module.PropertyAttributelevel: Configurations for individual property tests.GenAttributelevel: Generators for specific parameters (highest priority).
Layering Example (F#)
type SmallInts = static member __ = AutoGenConfig.empty |> AutoGenConfig.addGenerator (Gen.int32 (Range.constant 1 10)) type WithStrings = static member __ = AutoGenConfig.empty |> AutoGenConfig.addGenerator (Gen.string (Range.constant 5 10) Gen.alpha) [<Properties(typeof<SmallInts>)>] module ``Layering example`` = [<Property>] let ``uses class-level config`` (x: int) = x >= 1 && x <= 10 [<Property(typeof<WithStrings>)>] let ``merges class and method config`` (x: int) (s: string) = x >= 1 && x <= 10 && s.Length >= 5 && s.Length <= 10How async support works in Hedgehog
masterHedgehog property testing operates in two distinct phases:
- Generation Phase: The tree of test values (including shrinks) is built synchronously. This ensures reproducible random generation via proper seed threading.
- Execution Phase: The property is evaluated. When using
checkAsyncorreportAsync, this phase is asynchronous and non-blocking.
Crucial Warning: Blocking during Generation If you use the result of an asynchronous operation to determine the parameters of a subsequent generator, Hedgehog must block the synchronous generation phase to resolve that value. This negates the benefits of async execution.
Example of what to avoid (Blocking):
property { let! x = Gen.int32 (Range.linear 1 100) let! asyncValue = async { return 42 } let! y = Gen.int32 (Range.linear asyncValue 100) // BLOCKS here to resolve asyncValue return y > 0 }Use Inverse/Roundtrip Properties for reversible operations
masterAn Inverse (or Roundtrip) property verifies that an operation can be undone. If you perform an operation and then its inverse, you should return to the original state.
Common Examples:
Serialize$\rightarrow$Deserialize= identityEncode$\rightarrow$Decode= identityCompress$\rightarrow$Decompress= identityAdd$\rightarrow$Subtract= identity
open Hedgehog open Hedgehog.FSharp open System.Text.Json open Swensen.Unquote [<Fact>] let ``Serialize should roundtrip when deserializing`` () = property { let! person = Gen.auto<Person> let json = JsonSerializer.Serialize(person) let restored = JsonSerializer.Deserialize<Person>(json) test <@ restored = person @> } |> Property.checkThe Mental Shift: From Examples to Properties
masterProperty-based testing (PBT) shifts the focus from testing specific values to testing universal truths. Instead of verifying that
add(1, 2) == 3, you verify that for any two integersaandb,add(a, b) == add(b, a)(commutativity).Core Principles:
- Properties over Examples: Seek universal truths that hold for all valid inputs.
- Generative Thinking: Consider the entire space of possible inputs (empty collections, max/min values, Unicode, etc.).
- Shrinking: When a test fails, Hedgehog automatically finds the minimal failing case to simplify debugging.
- Specification by Properties: Use properties as executable documentation of how your code behaves.
Best practice: Use AutoGenConfig.empty instead of AutoGenConfig.defaults
masterWhen creating custom
AutoGenConfiginstances, always start withAutoGenConfig.empty.Why?
AutoGenConfig.defaultsincludes generators for all built-in types. If you usedefaultsand then add a custom generator at a higher layer (like aPropertylevel), the default generator from thedefaultsobject might override your custom one during the merge process. Starting withemptyensures you only include the generators you explicitly add, as the default layer is already applied by Hedgehog automatically.Core Property Testing Patterns
masterWhen designing property-based tests, use these common patterns to define what your code should satisfy:
Pattern What It Tests Invariants Properties that always hold about the result (e.g., "Sorting twice = sorting once") Business Rules Domain constraints that must never be violated (e.g., "Discount never exceeds price") Inverse/Roundtrip Operations that undo each other (e.g., "Serialize then deserialize = identity") Idempotence Applying N times = applying once (e.g., "Normalize(Normalize(x)) = Normalize(x)") Oracle Compare against a reference/known truth (e.g., "Custom sort = standard library sort") Metamorphic How output changes with input transformations (e.g., "Double inputs → double output") Model-Based Generate valid output first, derive input that should produce it Avoid overlapping and redundant properties
masterTo minimize maintenance burden, ensure your properties are distinct and non-overlapping. Use the following elimination workflow:
- Discovery: List all candidate properties.
- Analysis: Examine relationships between them.
- Elimination: Remove redundancy using these criteria:
- If property A failing implies property B must also fail, keep only the stronger one.
- If property A is a special case of B, keep B.
- If they express the same invariant with different wording, keep the clearest one.
- Proposal: Present the minimal set of properties.
Use Oracle Properties to compare against a reference implementation
masterAn Oracle property compares your implementation against a known 'truth'—such as a standard library function, a mathematical formula, or a simpler (but slower) legacy implementation.
Common Examples:
- Custom sort should match
List.sort. sqrt(x)^2should approximately equalx.- Custom parser should match a standard parser.
open Hedgehog open Hedgehog.FSharp open Swensen.Unquote [<Fact>] let ``Custom sort should match standard sort`` () = property { let! list = Gen.list (Range.linear 0 100) Gen.int32 let custom = myCustomSort list let standard = List.sort list test <@ custom = standard @> } |> Property.check- Custom sort should match
Register Custom Generator Classes
masterYou can provide fine-grained control by creating a custom generator class containing
public staticmethods. When you register this class usingAutoGenConfig.addGenerators<T>(F#) or.AddGenerators<T>()(C#), Hedgehog will automatically discover and use these methods when it encounters the corresponding return type.Method Requirements
- Must be
public static. - Must return
Gen<T>whereTis the target type. - For generic types (e.g.,
Gen<List<T>>), the method can accept parameters likeGen<T>orIAutoGenContext.
F# Implementation
type MyGenerators = static member Email() : Gen<Email> = ... static member DateTime() : Gen<DateTime> = ... let config = AutoGenConfig.defaults |> AutoGenConfig.addGenerators<MyGenerators> let! user = Gen.autoWith<User> configC# Implementation
public class MyGenerators { public static Gen<Email> Email() => ... public static Gen<DateTime> DateTime() => ... } var config = AutoGenConfig.Defaults.AddGenerators<MyGenerators>(); var userGen = Gen.AutoWith<User>(config);type MyGenerators = static member Email() : Gen<Email> = gen { let! name = Gen.string (Range.linear 3 10) Gen.alphaNum let! domain = Gen.item ["com"; "net"; "org"] return Email $"{name}@example.{domain}" } static member DateTime() : Gen<DateTime> = gen { let! days = Gen.int32 (Range.linear 0 365) return DateTime.Now.AddDays(-float days) } let config = AutoGenConfig.defaults |> AutoGenConfig.addGenerators<MyGenerators>- Must be
Use Invariant Properties to verify what never changes
masterAn Invariant is a property that always holds about the result, regardless of the input. Use this when certain aspects of your data must be preserved by an operation.
Common Examples:
- Sorting preserves the count and contents of a list.
- Mapping over a list preserves its length.
- Filtering never increases the size of a collection.
- String trimming never increases the string length.
open Hedgehog open Hedgehog.FSharp open Swensen.Unquote [<Fact>] let ``Sort should preserve all elements`` () = property { let! list = Gen.list (Range.linear 0 100) Gen.int32 let sorted = List.sort list test <@ List.length sorted = List.length list @> test <@ List.forall (fun x -> List.contains x sorted) list @> } |> Property.check