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)
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
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
.
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.