(std test prop)

Reference

Overview

Property tests: for-all runs a body with many generated values, shrinks a failing case, and stores it for the next run.

(import (std prelude))
(import (std test))
(import (std test gen))
(import (std test prop))

(deftest "reverse twice is the identity"
  (for-all ((: xs (List int)))
    (check (= (list-reverse (list-reverse xs)) xs))))

(deftest "a die roll is in range"
  (for-all ((n (gen-int-range 1 6)))
    (def r (roll-with n))
    (check (>= r 1))
    (check (<= r 6))))

(deftest "orderedmap-set then ref"
  (for-all #:runs 500 ((: m (OrderedMap string int)) (: k string) (: v int))
    (check (= (orderedmap-ref (orderedmap-set m k v) k) v))))

(run-tests)

for-all is a form in the body of a deftest. It runs its body many times, each time with new values from the generators of (std test gen). The checks of (std test) in the body tell whether a run passes. When a run fails, for-all shrinks it to a small case, reports that case under the test, and stores it beside the test file. The next run of the test tries the stored case first.

Runs. A for-all runs its body 100 times, or --runs n times, or #:runs n times. Each run has a size, from 1 at the first run to 100 at the last, so the first runs try small values. A run fails when a check in it fails, when it raises an exception, or when it leaves a resource open. The body of each run runs in a scope of its own, inside the scope of the test. The scope waits for the fibers that the run started, so the next run does not share them. The test has #:open-ok to allow open resources.

The first run that fails stops the random runs. A failed for-all does not stop its test, as a failed check does not. A test can have more than one for-all.

The report. A failure shows where the for-all is, which run failed and the seed of the run, the values of the bindings after shrinking, and the failures of the shrunk case:

FAIL reverse is not the identity (list_test.bjo:6)
  for-all at list_test.bjo:7 failed on run 4 of 100 (seed 1234567890)
  shrunk 9 time(s) to:
    xs = (0 1)
  list_test.bjo:8: (check (= (list-reverse xs) xs))
    (list-reverse xs) => (1 0)
    xs                => (0 1)

The second line says with: when shrinking found no smaller case. It says shrunk n time(s), then stopped, out of time or replays, at: when a limit stopped shrinking. When the generators raise an exception that is not a discard, the report shows the message and no values.

Discarded runs. assume discards a run, and so does a generator that gives up, such as gen-such-that. A discarded run is not counted. When more than 10 times the runs are discarded, the for-all fails, because the property was hardly tested.

Shrinking. Shrinking does not change the values. It changes the draws that the generators recorded, and replays them. Because a smaller draw gives a simpler value (see (std test gen)), a smaller vector of draws gives a simpler case. One round of shrinking does these steps, in this order:

A change is kept when the replay fails the same way as the first failed run, and the draws that the replay used are smaller than the best so far. Two failures are the same when the first check that failed is at the same place, or when the run raised an exception of the same type. A smaller case that fails another way shows another bug, and reporting it would hide the bug that was found. Fewer draws are smaller. Of two vectors of the same length, the one that is smaller at the first place where they differ is smaller. Every kept change makes the draws smaller, so shrinking ends. The rounds repeat until a round keeps nothing.

Two limits stop shrinking early: --shrink-limit n replays, 2000 when not given, and the deadline of the test. Shrinking stops when the time left is not more than a fifth of the time that was left when shrinking started. The report then shows the smallest case found.

Flaky properties. Before it shrinks, for-all replays the failed run. When the replay passes, the property depends on something other than its draws, such as the time, a shared state or the scheduler. The report then says that the case passed when it ran again, and shows the values and the failures without shrinking. Nothing is stored.

Seeds. Each for-all draws from a source made from the seed of its test, the source text of its bindings, and its index among the for-all forms of the test with the same bindings. The seed of the test comes from the seed of the run and the name of the test (see test-seed). So choosing tests with filters, or adding a test, does not change what the other tests draw. A report shows the seed of the run. Run the test file again with --seed and that seed, and the name of the test as a filter, to repeat the run.

The failure file. A shrunk failure is stored in a file beside the test file: list_test.bjo stores to list_test.failed.bjodat. The file holds the draws, not the values, so no value has to be written as text:

(failed
  (case (test "reverse is not the identity") (bindings "((: xs (List int)))") (index 0) (seed "1234567890") (size 4) (draws [1 0 1 1 0]))
  (case (test "parse round trip") (bindings "((e gen-expr))") (index 0) (seed "1234567890") (size 37) (draws [2 7 0])))

A for-all first replays each case that the file has for it. The cases that pass are removed, and the cases that fail stay. The first that fails is shrunk again and reported, its draws are updated, and the random runs do not run. When no case fails, the random runs run, and a new shrunk failure is added at the end.

The file is read when a for-all first needs it. It is written at the end of (run-tests), through at-run-end!, and only when its text changed. When it has no case left, it is deleted. A run without filters also removes the cases of tests that no longer exist. run-test alone does not write the file: the cases stay in memory, and a later run in the same program sees them. With --no-store, the file is not read or written.

A file that does not read as cases is treated as empty. Whether to commit the file is the choice of the project. When it is committed, CI tries every case that anyone found.

Statistics. classify and collect count the runs that passed. With --stats, each for-all prints its counts when it ends:

for-all at list_test.bjo:7: 100 run(s)
  23% empty
  4% long

See also: for-all, assume, classify, label

Reference

Functions

Macros

Functions

assume

function ↑ Functions

(: assume (-> bool void))
(assume ok)

Discards the run when ok is #f. A discarded run is not counted.

ok
The condition that the values of the run must satisfy.
raises InvalidOperationException
When it is not in a run of a for-all.
(for-all ((: xs (List int)))
  (assume (not (list-empty? xs)))
  (check (= (list-length (list-tail xs)) (- (list-length xs) 1))))

It leaves the run with an exception. Do not catch System.Exception around it, or the run goes on.

When more than 10 times the runs of a for-all are discarded, the for-all fails. A condition that most values do not satisfy tests little. Prefer a generator that makes good values directly.

See also: for-all, gen-such-that

classify

function ↑ Functions

(: classify (-> string bool void))
(classify name ok)

Counts the run under name when ok is #t. –stats prints the counts.

name
The name of the class.
ok
Whether the run is in the class.
(for-all ((: xs (List int)))
  (classify "empty" (list-empty? xs))
  (classify "long" (> (list-length xs) 10))
  (check (= (list-reverse (list-reverse xs)) xs)))

Only the random runs that pass are counted, not the replays of shrinking or of stored cases. Outside a run, it does nothing.

The counts show whether the generators make the values that the property is meant to test. A passing test prints nothing, so the counts are printed only with --stats, as a percentage of the runs, the largest first.

See also: collect, for-all

collect

function ↑ Functions

(: collect (-> %a void))
(collect value)

Counts the run under the value, as text. –stats prints the counts.

value
The value. Runs with equal texts are counted together.
(for-all ((: xs (List int)))
  (collect (list-length xs))
  (check (= (list-reverse (list-reverse xs)) xs)))

(collect v) is (classify (displayed v) #t).

See also: classify

run-for-all!

function ↑ Functions

(: run-for-all! (-> string int int string (List string) Property void))
(run-for-all! file line runs bindings names prop)

Runs a property: the stored cases first, then the random runs, then shrinking and the report.

file
The source file of the for-all.
line
The line of the for-all.
runs
The number of runs that the form gives, or 0 when it gives none.
bindings
The source text of the bindings, which finds the stored cases of the for-all.
names
The names of the bindings, in order, for the report.
prop
A function from the choices of a run to the texts of the values and the body of the run.
raises InvalidOperationException
When no test is running.

It is exported because the template of for-all names it. Write for-all, not a call to this function.

See also: for-all

with-label

function ↑ Functions

(: with-label (-> string (-> void) void))
(with-label name body)

Runs a function, and reports each failure in it under a name.

name
The name that the report puts above each failure.
body
The function.

It is exported because the template of label names it. Write label, not a call to this function.

See also: label

prop-draw

function ↑ Functions

(: prop-draw (-> (Gen %a) Choices %a))
(prop-draw g c)

The value of a generator, drawn from the choices of a run.

g
The generator.
c
The choices.
returns
What (gen-run g c) answers.

It is exported because the template of for-all names it, for a binding (name gen).

See also: for-all

prop-arbitrary

function ↑ Functions

(: prop-arbitrary (-> Choices %a))
(prop-arbitrary c)

The value of the Arbitrary generator of the result type, drawn from the choices of a run.

c
The choices.
returns
What (gen-run (arbitrary) c) answers. The type of the result chooses the instance.

It is exported because the template of for-all names it, for a binding (: name Type).

See also: for-all, Arbitrary

Macros

for-all

macro ↑ Macros

(for-all ((name gen) ...) body ...)
(for-all ((: name Type) ...) body ...)
(for-all #:runs n ((name gen) ...) body ...)

Runs the body with many generated values. A failing case is shrunk, reported and stored.

name
The name that the body sees the value by. The report shows the value under this name.
gen
A generator, a (Gen T), that makes the value.
Type
A type with an Arbitrary instance. Its arbitrary makes the value.
n
The number of runs. When not given, --runs, and else 100.
body ...
The forms of a run. At least one form. Their values are dropped.
(deftest "sort keeps the length"
  (for-all ((: xs (List int)))
    (check (= (list-length (list-sort xs)) (list-length xs)))))

(deftest "a sum of small numbers"
  (for-all #:runs 1000 ((a (gen-int-range 0 100)) (b (gen-int-range 0 100)))
    (check (<= (+ a b) 200))))

Write it in the body of a deftest. It finds its test when it runs. Outside a test it raises System.InvalidOperationException.

The two kinds of bindings can be mixed in one for-all. All bindings draw in order, from the choices of the run. To make values that depend on each other, use gen-let in one binding.

For each run, the bindings are drawn, and then the body runs in a scope of its own. A run passes when no check in it fails, it raises no exception, and it leaves no resource open. The failures of a run are not added to the test. Only the failures of the shrunk case are added. See the module for the runs, the report, shrinking and the failure file.

The report shows each value with displayed.

See also: assume, classify, collect, label, deftest

label

macro ↑ Macros

(label name body ...)

Runs the body, and reports each failure in it under the name.

name
A string: the name that the report puts above each failure.
body ...
The forms. At least one form. Their values are dropped.
(for-all ((: m (OrderedMap string int)) (: k string) (: v int))
  (def m2 (orderedmap-set m k v))
  (label "after insert"
    (check (= (orderedmap-ref m2 k) v))))

A failure in the body is reported as in name: and then the failure. When the body raises an exception, the exception is reported under the name as a failure, and the forms after the label still run. It works in any test, also outside a for-all.

See also: for-all, with-label