Skip to content

Expose hedgehog function for testing#158

Merged
micahhahn merged 1 commit into
trunkfrom
expose-hedgehog-function
Jun 16, 2026
Merged

Expose hedgehog function for testing#158
micahhahn merged 1 commit into
trunkfrom
expose-hedgehog-function

Conversation

@micahhahn

@micahhahn micahhahn commented Jun 10, 2026

Copy link
Copy Markdown
Member

This PR exposes a new function from the Test module called hedgehog. This function is almost identical to the fuzz function except that we accept the Gen type that underlies the Fuzz opaque type:

hedgehog :: (Stack.HasCallStack, Show a) => Hedgehog.Gen a -> Text -> (a -> Expectation) -> Test

This allows consumers of the library to use the raw powerful Hedgehog combinators if they need to instead of our Elm-like wrappers.

Why do we need this?

I'm trying to write a Fuzzer for a recursive type that is an extension of a JSON object (for json-render):

newtype StatePath = StatePath Text

data Exp
  = Object (Map Text Exp)
  | Array [Exp]
  | String Text
  | Number Scientific
  | Bool Bool
  | BindState StatePath

A first attempt at a fuzzer might look like:

fuzzExp :: Fuzzer Exp
fuzzExp = 
  Fuzz.oneOf
    [ map (Map.fromList >> Object) (Fuzz.list (Fuzz.tuple (Fuzz.text, fuzzExp))),
      map Array (Fuzz.list fuzzExp)
      map String Fuzz.text,
      map (fromFloatDigits >> Number) Fuzz.float,
      map Bool Fuzz.bool, 
      map (StatePath >> BindState) Fuzz.Text
    ]

This of course will cause an infinite recursion. (It's odd that this library doesn't expose lazy like Elm's does).

The documentation from frequency suggests manually passing a depth parameter:

fuzzExp :: Int -> Fuzzer Exp
fuzzExp depth =
  if depth <= 0 then 
    Fuzz.oneOf
      [ map String Fuzz.text,
        map (fromFloatDigits >> Number) Fuzz.float,
        map Bool Fuzz.bool, 
        map (StatePath >> BindState) Fuzz.Text
      ]
  else
    Fuzz.oneOf
      [ map (Map.fromList >> Object) (Fuzz.list (Fuzz.tuple (Fuzz.text, fuzzExp))),
        map Array (Fuzz.list fuzzExp)
        map String Fuzz.text,
        map (fromFloatDigits >> Number) Fuzz.float,
        map Bool Fuzz.bool, 
        map (StatePath >> BindState) Fuzz.Text
      ]

This still has exponential behavior in terms of depth however. This leads to trees that are wide but not deep as leave can have an Object or Array with a potentially large list.

Hedgehog provides a recursive function that reduces the Size parameter by half. This ensures that the size of lists continues to shrink as we recuse and when size hits zero evenutally then we stop recursing entirely.

I'm currently hitting hangs and system out of memory errors when running fuzzExp 4.

Aside from recursion issues, I also find it frustrating how I cannot build up fuzzers constructively. In one case I only want a string of alpha numeric characters and AFAICT the only way to do that is build up a Text and map into it to remove unwanted things. Aside from being harder than it needs to be (see Hedgehogs alphaNum and element functions), I'm sure this has less than ideal shrinking behavior.

@micahhahn micahhahn changed the title Expose hedgehog function Expose hedgehog function for testing Jun 12, 2026
@micahhahn micahhahn marked this pull request as ready for review June 15, 2026 14:28
Copilot AI review requested due to automatic review settings June 15, 2026 14:28
@micahhahn micahhahn enabled auto-merge June 15, 2026 14:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the public Test API by exposing a new hedgehog test constructor that accepts a raw Hedgehog.Gen, enabling consumers to use Hedgehog’s native generator combinators directly (e.g., Gen.recursive) while still running through the existing Test runner.

Changes:

  • Added hedgehog :: Hedgehog.Gen a -> Text -> (a -> Expectation) -> Test to Test.Internal.
  • Refactored fuzz, fuzz2, and fuzz3 to pass the underlying Hedgehog.Gen directly into fuzzBody.
  • Re-exported hedgehog from the public Test module.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
nri-prelude/src/Test/Internal.hs Introduces hedgehog and adjusts fuzzBody/fuzz helpers to operate on Hedgehog.Gen directly.
nri-prelude/src/Test.hs Re-exports Internal.hedgehog as part of the public API.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +265 to +267
-- | Run a fuzz test using a hedgehog generator
hedgehog :: (Stack.HasCallStack, Show a) => Hedgehog.Gen a -> Text -> (a -> Expectation) -> Test
hedgehog gen name expectation =
@micahhahn micahhahn requested a review from omnibs June 15, 2026 19:59
@omnibs

omnibs commented Jun 16, 2026

Copy link
Copy Markdown
Member

💭 Why not wrap recursive, alphaNum and element in our types?

Our API tries to expose things with sane defaults (e.g. list and text limits) and in hedgehog you will be forced to use Gens which are free-for-all.

@micahhahn

Copy link
Copy Markdown
Member Author

@omnibs

I thought about it! One thing I'm caught up on is I'm not sure what the intent of the design of the library is at this point. Originally, along with most things in nri-prelude, I assume the intent was to mimic the corresponding Elm library as closely as possible.

The problem is that nri-prelude.Fuzz is half baked compared to Elm's Fuzz module (missing things like examples, stringOfLength, listOfLength, filter, lazy). It's also extremely half baked compared to the flexibility of Hedgehog.Gen.

I guess I think I would prefer the free-for-all Gen for this project? I need to generated a lot of Maps of recursive datatypes and honestly the only number of elements that really are like sizes 0-5 elements. Being forced to use a list primitive that could have 100 elements is overkill (yes I know 100 is unlikely cause it's exponential but still the point stands).

To elaborate on the constructive philosophy a little bit: I need to create strings in specific shapes (e.g. conforming to JSON Pointer specification). This would be much easier to do with a primitive like Hedgehog.Gen.text which allows specifying desired size range of the string and taking a generator to produce individual characters.

text :: MonadGen m => Range Int -> m Char -> m Text

Trying to do this with nri-preludes' Fuzz or Elm's Fuzz is harder because, as written right now, we need to construct a general string and filter away values / characters we don't want. This leads to bad shrinking and even fuzzer failure if something like filter fails to generate a value that passes through the filter.

Could we wrap a bunch of functions to give us more flexibility? Sure. But at a certain point why not just use the underlying library itself?

@omnibs omnibs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context

One thing I'm caught up on is I'm not sure what the intent of the design of the library is at this point. Originally, along with most things in nri-prelude, I assume the intent was to mimic the corresponding Elm library as closely as possible.

As with other nri-prelude things, the goal was creating a subset of the haskell ecosystem that's easy to use and learn, explicit, consistent, predictable, and safe. Part of the effort Jasper, Stö and Glass put into this was keeping footguns out of our codebase.

We should feel free to extend nri-prelude keeping these principles in mind, if we think something here is half-baked.

I don't know hedgehog well enough to point at footguns folks were trying to keep out when designing Fuzz, but from the list of things Claude says it can't do, one seemingly intentional choice was blocking monadic generators.

Practical feedback

It seems like we could re-expose everything you're missing, without bringing any footguns onboard, right?

I won't oppose merging Fuzz.hedgehog, but I worry folks Claudes will reach out for it even when not necessary and write unbounded lists and bad do block generators.

Can you help keep an eye on how this ends up being used?

@micahhahn micahhahn added this pull request to the merge queue Jun 16, 2026
Merged via the queue into trunk with commit d1fc5e6 Jun 16, 2026
4 of 5 checks passed
@micahhahn

Copy link
Copy Markdown
Member Author

I'll tell you what, why don't I implement my json-render spec fuzzer in terms of hedgehog and then I'll have a clearer picture of what (if any) things we would want to bring into our Fuzz library.

PD-3068: Consider re-exposing key hedgehog functions into our Fuzz library

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants