QCheck 1/2 expect tests - #153
Conversation
jmid
commented
Aug 13, 2021
Here comes a number of observations. We can create issues for them if need be.
I added a few tests with a sized list generator from issue #64 which crashes the On the other hand, the test |
jmid
commented
Aug 13, 2021
I was surprised to discover this - illustrated by test This is a defect in |
jmid
commented
Aug 13, 2021
The reverse is also the case, however this is generally expected as integrated shrinking promises to only shrink to counterexamples that the generator can produce. When the first counterexample found is (* test from issue #59 *)let ints_smaller_209609 =Test.make ~name:"ints < 209609"~print:Print.int
(Gen.small_int_corners()) (funi -> i <209609)this results in different counterexamples reported by the two approaches: This behaviour is debatable. On the one hand as a user, I would like the smallest counterexample reported. Comparing the output of the test let long_shrink =let listgen =Gen.(list_size (int_range 100010000) int) inTest.make ~name:"long_shrink"~print:Print.(pair (listint) (listint))
(Gen.pair listgen listgen)
(fun (xs,ys) -> List.rev (xs@ys) = (List.rev xs)@(List.rev ys))the difference in counterexamples is much less debatable: Since the user explicitly asks for lists of length 1000-10000 list this QCheck2 behaviour seems quite reasonable. |
jmid
commented
Aug 16, 2021
Sub-optimal string shrinkers By design, with integrated shrinking a shrinker inherits the generator's structure. As a consequence, the reduced counterexamples are not as small as previously. Here's Since The first line lists the first random string with a Generally I think we should consider using a smarter default Notice the different strategy of repeated modifications of the original counter example. As a side note we could improve So that's another possible improvement we could consider. |
c-cube
commented
Aug 16, 2021
just a remark:
I think that jives with your remark earlier that we don't shrink |
jmid
commented
Aug 16, 2021
Unclear The interface says: valsmall_string : ?gen:chart -> stringt(** Builds a string generator, length is {!small_nat}. Accepts an optional character generator (the default is {!char}). Shrinks on the number of characters first, then on the characters.*)However I had problems using it without the optional argument. Then I noticed the implementation: letsmall_string?genst= string_size ?gen small_nat stThis implementation uses a labelled but required argument (notice there is no default value provided). letsmall_string?(gen = char)st= string_size ?gen small_nat st |
jmid
commented
Aug 16, 2021
I'm not sure I follow you. The letstringsyield=for i =0toString.length s-1dolet s' =Bytes.init (String.length s-1)
(funj -> if j<i then s.[j] else s.[j+1])
in
yield (Bytes.unsafe_to_string s')
doneWe nevertheless have a letcharcyield=ifChar.code c >0then yield (Char.chr (Char.code c-1))It just isn't used by letchar= make_scalar ~print:(sprintf "%C") ~shrink:Shrink.charGen.char
|
c-cube
commented
Aug 16, 2021
my bad, I also thought we didn't have a |
sir4ur0n
commented
Aug 17, 2021
About #153 (comment) in my opinion QCheck2 behaves correctly - and no, I'm not saying that because I rewrote/changed a big part of it 😄 small_int_cornersIn my opinion this is a weird, maybe even bad function to begin with 😅 (I understand the reason why it exists, but IMHO it's more a use case for Anyway, there's no way to know how to shrink once Note that the documentation of Shrinking of big list
I 💯 agree with you |
sir4ur0n
commented
Aug 17, 2021
Thank you @jmid for the PR and the analysis, it's really neat 👍 Well done! I have opened issues for what I believe are QCheck2 problems (the stackoverflow may or may not be an important issue; the string shrinking is not in my opinion) |
I just have a few more ones that I would like to add (they take a bit of time to analyze and write up though...): Sub-optimal list shrinkers Similar to let list_unique_elems =Test.make ~name:"lists have unique elems"~print:Print.(listint)
Gen.(list small_int)
(funxs -> let ys =List.sort_uniq Int.compare xs in
print_list xs; List.length xs =List.length ys)The reported counterexamples Above I've added a side-effect to the property that prints the tested input every time the property is tested. This gives a log of all shrink attempts - both successful and unsuccessful ones (the shrink log of #88 only logs successful ones). Here's the Using bisection the algorithm uses the first 7 steps to find The shrink log of There is a couple of things to observe here:
For 1, I really think we should consider
For 2, I tried to optimize the original list shrinker in #64 by removing such repeated empty tests for it. Back then this came at the price of trying let ints_are_0 =Test.make ~name:"ints are 0"~count:1000~print:Print.intGen.int (funi -> Printf.printf "%i\n" i; i =0)and its shrink log: So this aspect of the
As such, ideally I would prefer to keep |
jmid
commented
Aug 17, 2021
Sub-optimal function shrinkers A number of the function tests illustrate a difference in the function shrinker's output (terminal cuts off at width 160): The steps spent vary between the two approaches. The last two are perhaps most saying. It is clear that generating functions last in a tuple rather than first (a hard-learned lesson from issue #8) is still a clear win for integrated shrinking. Still, there is quite a few more bindings reported in the above counterexample compared to QCheck. letshrinks : (k, v) t Tree.t Seq.t =fun() ->
(* This only gets evaluated *after* the test was run for [tbl], meaning it is correctly populated with bindings recorded during the test already *)letcurrent_bindings : (k * v Tree.t) list =List.rev !(root.p_tree_bindings_rev) inlettake_at_most_tree : int Tree.t =Tree.make_primitive (Shrink.int_towards 0) (List.length current_bindings) inletcurrent_tree_bindings : (k * v) Tree.t list =List.map (fun (k, tree) -> Tree.map (funv -> (k, v)) tree) current_bindings inletshrunk_bindings_tree : (k * v) list Tree.t =Tree.bind take_at_most_tree (funtake_at_most -> Tree.applicative_take take_at_most current_tree_bindings) in(* During shrinking, we don't want to record/add bindings, so [~extend:false]. *)letshrunk_poly_tbl_tree : (k, v) t Tree.t =Tree.map (funbindings -> List.to_seq bindings |>T.of_seq |> make ~extend:false) shrunk_bindings_tree in(* [shrunk_poly_tbl_tree] is a bit misleading: its root *should* be the same as [root] but because of the required laziness induced by the mutation of bindings, we don't use it, only graft its children to the original [root]. *)Tree.children shrunk_poly_tbl_tree ()inTree.Tree (root, shrinks)This looks like a quick algorithmic It puzzled me that the I therefore added a shrink log of successful shrink attempts by replacing l.355 of QCheck_base_runner.run_tests ~colors:false~debug_shrink:(Some (open_out "funshrinklog.txt")) ~debug_shrink_list:["fold_left fold_right uncurried fun last"] ([After rerunning the tests, this revealed the following in _build/default/test/core/funshrinklog.txt: This looks fishy to me: while the first elements of the tuple are reduced, the last one (the function table) actually gets longer in each attempt! |
jmid
commented
Aug 17, 2021
The last two things I had noted were: Different The regression I believe this is expected, since (* Uniform random int generator *)let pint =ifSys.word_size =32thenfunst -> RS.bits st
else(* word size = 64 *)funst ->
RS.bits st (* Bottom 30 bits *)lor (RS.bits st lsl30) (* Middle 30 bits *)lor ((RS.bits st land3) lsl60) (* Top 2 bits *)(* top bit = 0 *)to QCheck2: letpint_raw (st: RS.t) : int =ifSys.word_size =32thenRS.bits st
else(* word size = 64 *)(* Bottom 30 bits *)let right =RS.bits st in(* Middle 30 bits *)let middle = (RS.bits st lsl30) in(* Technically we could write [3] but this is clearer *)let two_bits_mask =0b11in(* Top 2 bits *)let left = ((RS.bits st land two_bits_mask) lsl60) in
left lor middle lor rightAs far as I can tell, it is better documented (yay!) and the call order to
Counterexamples from Finally I noted that the reported counterexample of for the test: let ints_arent_0_mod_3 =Test.make ~name:"ints arent 0 mod 3"~count:1000int (funi -> i mod3<>0)I'll just remark here that this is another benefit of end of comments If you are happy with the PR, I'd like to merge it (rather than continue to rebase and resolve merge conflicts 😬). Going forward, I was imagining
|
c-cube
commented
Aug 17, 2021
My goodness, that's amazing work @jmid . I'm a bit scared of the size of it, but we're suddenly getting to best-in-class levels of testing 😁 . I think it'd be good to open separate issues for the actionable remarks (e.g. the |
This PR contains the beginnings of an expect test suite for both
QCheckandQCheck2.It consists of a bunch of positive and negative tests formulated with both.
With the expected output this makes a regression comparison possible, using:
For this reason it focuses on tests which can be phrased with both implementations.
The test suite started with the original tests from
example/QCheck_runner_test.mlOverall, the idea was to supplement the unit tests from @sir4ur0n in
test/core/test.mlwith a PBT test suite - and leave the
example-directory for ... examples ;-)There is still plenty more to do.
There's also results we should discuss - I'll try to mention them one-by-one below.