Generating custom data in FsCheck

Hi folks, I’ve got an FsCheck question:
I have the following record type (and I say upfront, I have been told that my Single-Case DUs are maybe an overkill, but I find them descriptive of the domain, and therefore necessary, and I will not remove them unless must):

type Name = Name of string
type Quality = Quality of int
type ShelfLife = Days of int
type Style = Plain | Aged | Legendary

type Item = {
    Name: Name
    Quality: Quality
    ShelfLife: ShelfLife
    Style: Style
}

Assuming I already have defined the functions repeat: Int -> ('a -> 'a) -> 'a and decreaseQuality: Item -> Item, I want to code an FsCheck test that checks the invariant: Any item with style OTHER THAN Legendary, after 100 days have passed, has quality of 0 .

My problem is I don’t know the following things about FsCheck :

  1. How do I define a custom generator that generates items whose style is not Legendary? And in contrast, how do I define items only of type Legendary (to test both types)?
    I’ve looked into:

     let itemGenerator = Arb.generate<Item>
     Gen.sample 80 5 itemGenerator
    

    but that creates Items that are plain weird, as the size controller, in the example, 80, controls also the length of the Name string and also produces Quality and ShelfLife values that are unacceptable to my domain (i.e. negative) as they are both defined as ... of int which the size also controls.
    (I’ve also looked into Gen.oneof..., but that turned out to also be a dud).

  2. How do I even define a test that only tests the Quality property of the record, even assuming I found out a way to generate custom data?.

Thanks!
(And as always, apologies if you already saw it somewhere else and commented there).

I’ve got a bit of code here that might illustrate the usage of FsCheck for your particular case:

open FsCheck

type Name = Name of string
type Quality = Quality of int
type ShelfLife = Days of int
type Style = Plain | Aged | Legendary

type Item = {
    Name: Name
    Quality: Quality
    ShelfLife: ShelfLife
    Style: Style
}
  with static member create n q l s = { Name = n; Quality = q; ShelfLife = l; Style = s }

let repeat (n: int) fn arg =
  let mutable counter = n
  let mutable arg = arg
  while counter > 0 do
    arg <- fn arg
    counter <- counter - 1
  arg

let decreaseQuality ({ Quality = Quality q } as i) =
  match q with
  | 0 -> i
  | n when n < 0 -> { i with Quality = Quality 0 }
  | n -> { i with Quality = Quality (n - 1) }

module Generators =
  open System

  type Styles =
    static member NonLegendary = Gen.elements [ Plain; Aged ]
    static member Legendary = Gen.constant Legendary

  type Qualities =
    static member NonZero: Gen<Quality> =
      // get an int
      Arb.generate
      // restrict the range
      |> Gen.filter (fun (i: int) -> i > 0)
      // make your quality
      |> Gen.map Quality

    static member NonZeroWithoutFilter: Gen<Quality> =
      Gen.choose (1, Int32.MaxValue)
      |> Gen.map Quality

  type Items =

    // a generator to generate a random item
    static member Any: Gen<Item> =
      Gen.map4 Item.create Arb.generate Arb.generate Arb.generate Arb.generate

    // generate non-legendary using a filter function
    // This is less efficient because you gen a whole item, then throw away ones that don't meet the filter
    // It can be useful for quick iteration, or when the thing you're generating doesn't take a lot of work to generate
    static member NonLegendaryUsingFilter = Items.Any |> Gen.filter (fun item -> item.Style <> Legendary) |> Arb.fromGen
    // generate non-legendary by using a specific allowed set of styles.
    // this will usually be faster because you're doing less throwing away of entire otherwise-valid Items
    static member NonLegendaryUsingAllowedStyles =
      Gen.map4 Item.create Arb.generate Qualities.NonZeroWithoutFilter Arb.generate Styles.NonLegendary
      |> Arb.fromGen

    static member Legendary =
      Gen.map4 Item.create Arb.generate Qualities.NonZeroWithoutFilter Arb.generate Styles.Legendary
      |> Arb.fromGen

let ``Any non-legendary item has zero quality after 100 iterations`` =
  Prop.forAll Generators.Items.NonLegendaryUsingAllowedStyles (fun (i: Item) ->
    let reduced = repeat 100 decreaseQuality i
    reduced.Quality = Quality 0
  )

Check.Quick(``Any non-legendary item has zero quality after 100 iterations``)

I’ve documented it a bit as I went along, but hopefully investigating the Generators module will show you examples of how you can create your own custom generators for types to constrain your domain a bit.

To answer your questions in sequence:

  1. Take a look at Generators.Items.NonLegendaryUsingXXXXXXX. I’ve shown two ways to get the result you want, but they both boil down to generating the individual fields of a record and constructing a new record instance from those fields. The difference comes in to where you constrain the Quality of the Item. You can do it after Item generation, as in NonLegendaryUsingFilter, or before Item generation, as in NonLegendaryUsingFilter. In addition, if you have constrains on the allowed integer values for Quality and/or ShelfLife, you should make custom generators for those types and use them. I’ve done some for Quality to illustrate. Note that my implementation of decreaseQuality here is definitely failing, because it doesn’t fulfill the property. If you run my entire snippet you should see the property fail.
  2. Look at the property function I wrote, Any non-legendary item has zero quality after 100 iterations. It’s a function wrapped in Prop.forAll. It asserts for any Item generated that’s non-legendary, the Quality of the item after decreasing it 100 times is 0. This naturally fails for my implementation for any quality over 100.

I hope this helps!

3 Likes

Thanks very much, @chethusk.
I’ve also received some helpful answers here (for future reference).

Really loving the F# community (I’m a n00b… half a year in all).

From a cursory reading of this post, it seems that at least one question relates to how to filter out one (or more) cases out of a discriminated union with FsCheck. FWIW, see this SO post for the appropriate way of doing that:

HTH