Composing Generators

I've mentioned in the last section how we need tuple and vector to generate tuples and vectors of values respectively. In this chapter, we'll use these to build a slightly more complex example.

Let's take a look at a more complicated object and an associated function, like this one for example:

struct Student
   name::String
   age::Int
   grades::Dict{String,Int} # subject => points
end

"""
    passes(s::Student)

Checks whether a student passes this grade. At most one subject may have a failing grade with less than 51 points.
"""
function passes(s::Student)
    count(<(51), values(s.grades)) <= 1
end
Main.passes

First, we're going to need a custom generator for our grades:

grade = map(Base.splat(Pair), PropCheck.interleave(itype(String), isample(0:100))) # random subject, with points in 0:100
gradegen = map(Base.splat(Dict{String,Int}), PropCheck.tuple(isample(1:10), grade))
Integrated{PropCheck.Tree{Dict{String, Int64}}}(genF)
Different ranges

isample(2:10, PropCheck.shrinkTowards(2)) gives us an integrated shrinker producing elements in the range 2:10. They'll shrink towards 2.

Now to our student:

students = map(Base.splat(Student), PropCheck.interleave(itype(String), itype(Int), gradegen))
check(passes, students)
Main.Student("", 0, Dict("\0" => 0, "" => 0))

And we can see that just generic shrinking produced the minimal student that doesn't pass. A nameless, ageless student who received no points on two subjects. Note that due to us using a dictionary (which forces unique keys), the two subjects have different names!

PropCheck tries to be fast when it can, so this reduction barely took any time:

pairs(@timed @time check(passes, students))
pairs(::NamedTuple) with 5 entries:
  :value   => Student("", 0, Dict("\0"=>0, ""=>0))
  :time    => 2.7658
  :bytes   => 151738848
  :gctime  => 0.0163801
  :gcstats => GC_Diff(151738848, 0, 0, 1609645, 2053, 0, 16380098, 3, 0)

Let's say now that we expect our students to be between 10-18 years old, have a name consisting of 5-20 lowercase ASCII letters and having between 5 and 10 subjects of 5-15 lowercase ASCII letters. We could build them like this:

subj_name = PropCheck.str(isample(5:15), isample('a':'z'))
grade = map(Base.splat(Pair), PropCheck.interleave(subj_name, isample(0:100))) # random subject, with points in 0:100
gradegen = map(Base.splat(Dict{String,Int}), PropCheck.vector(isample(5:10), grade))
stud_name = PropCheck.str(isample(5:20), isample('a':'z')) # we don't want names shorter than 5 characters
stud_age = isample(10:18) # our youngest student can only be 10 years old
students = map(Base.splat(Student), PropCheck.interleave(stud_name, stud_age, gradegen))
collect(Iterators.take(students, 5))
5-element Vector{PropCheck.Tree{Main.Student}}:
 Tree(Main.Student("ldohxlnvhzf", 11, Dict("njlhlk" => 10, "jyazudgfwjm" => 34, "kldjdofbqm" => 61, "mklwz" => 1, "hwhcsjlysw" => 51, "oivbnjlwjzfhkz" => 53, "znhznqya" => 18)))
 Tree(Main.Student("rdgbruemsbyve", 11, Dict("szmppfsaaol" => 11, "wkvhycvxif" => 29, "gpufc" => 74, "ddstcehimm" => 79, "qauybcrjscb" => 0, "cxghfryoaeipmam" => 95, "awkdrsxooqevoa" => 2)))
 Tree(Main.Student("oyhvaundfeife", 17, Dict("celzedkj" => 69, "vgtwxvxqrrzfu" => 35, "sbcgmqxqfw" => 5, "vnsoctu" => 44, "kumdfizlgqpzhgx" => 38, "efjykojruz" => 42, "mpqgovf" => 81, "snzmowngejfeb" => 46, "eczlxa" => 20)))
 Tree(Main.Student("zlvxhnmlrqxywaijbmv", 16, Dict("vekpgowbt" => 57, "vganhsxshlu" => 37, "vdzzgr" => 43, "aieblg" => 5, "lsjbdpyg" => 31, "pxpbk" => 71, "ituuj" => 76, "zlhymore" => 72, "gnmbxjpkty" => 31)))
 Tree(Main.Student("napyagmehawc", 13, Dict("qamhqzjpadhtrq" => 36, "zpgjyc" => 0, "ltjeqw" => 27, "zumtwuxawiy" => 67, "psqwx" => 90, "oqorpocqxaxsb" => 58, "ksvvafhzxjkvzuv" => 80, "bkvyaldoedppuj" => 36, "fwusstukwrqpm" => 93, "yvylpxxyy" => 68…)))

which will preserve the invariants described during generation when shrinking:

check(passes, students)
Main.Student("aaaaa", 10, Dict("aaaaa" => 0, "aaaab" => 0))

The student returned has a name with 5 characters, is 10 years old, has taken two distinct subjects and received 0 points in both of them. We can do much better if we modify our generators a bit, at the cost of having a smaller pool of possible tests:

# sample their classes
subj_name = isample(["Geography", "Mathematics", "English", "Arts & Crafts", "Music", "Science"], PropCheck.noshrink)

# random subject, with points in 0:100
grade = map(Base.splat(Pair), PropCheck.interleave(subj_name, isample(0:100)))

# generate their grades
gradegen = map(Base.splat(Dict{String,Int}), PropCheck.vector(isample(5:10), grade))

# give them a name that doesn't vanish
stud_name = isample(["Alice", "Bob", "Claire", "Devon"], PropCheck.noshrink)

# our youngest student can only be 10 years old
stud_age = isample(10:18)

# create our students
students = map(Base.splat(Student), PropCheck.interleave(stud_name, stud_age, gradegen))

# and check that not all students pass
using Test
@testset "All students pass" begin
    @test check(passes, students)
end
┌ Info: Found counterexample for 'passes', beginning shrinking...
└   Counterexample = Main.Student("Bob", 18, Dict("English" => 14, "Mathematics" => 91, "Science" => 14, "Geography" => 61, "Music" => 65, "Arts & Crafts" => 27))
[ Info: 46 counterexamples found for passes
All students pass: Error During Test at properties.md:99
  Expression evaluated to non-Boolean
  Expression: check(passes, students)
       Value: Main.Student("Bob", 10, Dict("English" => 0, "Mathematics" => 0, "Science" => 0, "Geography" => 0, "Music" => 0))
Test Summary:     | Error  Total  Time
All students pass |     1      1  0.4s
Dictionaries

While this example directly splats a vector into the Dict{String,Int} constructor, this is in general not optimal. Dict will delete previously set values if a key is duplicated, so it's usually better to generate a list of unique keys first, which is then combined with a seperately generated list of values. In order to generate that list of unique keys, you can use iunique.

Test stdlib and `@test`

Currently, check returns the minimized failing testcase, so that @test displays that the test has evaluated to a non-Boolean. This is suboptimal and misuses the @test macro. In the future, this may be replaced by a @check macro, which creates a custom TestSet for recording what kind of failure was experienced.