flyingmutant / rapid

Rapid is a modern Go property-based testing library
https://pkg.go.dev/pgregory.net/rapid
Mozilla Public License 2.0
579 stars 25 forks source link

Add FlatMap function similar to Map function #61

Closed elfrucool closed 1 year ago

elfrucool commented 1 year ago

The issue I'm facing is the need to create a generator based on the output of another generator.

To illustrate, consider a situation where we have four or six potential scenarios. Each scenario produces arbitrary data but with different combinations. Additionally, all subsequent generators depend on the first one or between each other.

Please note:

  1. The rapid.Map function isn't sufficient in this case because sometimes we need to transform a value into another value that is not fixed.

  2. The rapid.Custom function also isn't enough because we need to track original values without keeping them fixed.

  3. By using rapid.FlatMap, it's straightforward to create another combinator, rapid.Map2. This combinator takes two generators and a function. The function combines the two generated values, producing another generator that depends on the first two.

var genBaz *rapid.Generator[Baz] = rapid.Map2[Foo, Bar, Baz](
    genFoo,
    genBar,
    func(f Foo, b Bar) Baz { return Baz { f, b } },
)

Consider the following use case, which is similar to a project I'm currently working on:

var enumValueGen *rapid.Generator[SomeEnumType] = rapid.SampledFrom(allValuesInEnum())

var aSecondValueBasedOnEnum *rapid.Generator[SomeOtherType] = rapid.FlatMap[SomeEnumType, SomeOtherType](
    enumValueGen,
    func (e SomeEnumType) *rapid.Generator[SomeOtherType] {
        switch e {
        case SomeEnumValue1:
            return generatorForScenario1
        case SomeEnumValue2:
            return generatorForScenario2
        // ...
        }
    },
)

To address this issue, I propose an implementation using only exported symbols. This is the approach I'm currently using in a project:

// FlatMap is a function that takes a generator, and a function that takes the element
// produced by the generator, and produces another generator
// this function returns another generator that produces elements
// of a second type
func FlatMap[A, B any](genA *rapid.Generator[A], f func(a A) *rapid.Generator[B]) *rapid.Generator[B] {
    return rapid.Custom[B](func(t *rapid.T) B {
        return rapid.Map(genA, f).Draw(t, "flatMap").Draw(t, "B")
    })
}
flyingmutant commented 1 year ago

I don't undestand why a special combinator is required for flatmap in rapid. For example, here is jqwik user guide for flatmap, here is the same thing expressed in rapid:

func stringsOfSameLength(t *rapid.T) []string {
    n := rapid.IntRange(0, 10).Draw(t, "len")
    runes := rapid.RuneFrom(nil, unicode.ASCII_Hex_Digit)
    return rapid.SliceOf(rapid.StringOfN(runes, n, n, n)).Draw(t, "strings")
}

func ExampleFlatMap() {
    gen := rapid.Custom(stringsOfSameLength)

    for i := 0; i < 10; i++ {
        fmt.Println(gen.Example(i))
    }
    // Output:
    // [                  ]
    // [102e 2314 1283]
    // []
    // [2 6 0 B 2 6 d 0 8 0 6]
    // [     ]
    // []
    // []
    // [e3 6A eC]
    // [0e02f8fE02 49338f7119 16080D8007 3Ecbb200d3 E20Fc0B111 bAAA07baAd 6c6051eA2f d770aCB30C 18260E21B5 3b619d9102 251F83b232 1483E0e018 10B0c0e45f 0f23AE9026]
    // [24f 0C7]
}