1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
require "digest"
require "minitest"
require_relative "thesis/directory_db"
require_relative "thesis/error"
require_relative "thesis/possibility"
require_relative "thesis/status"
require_relative "thesis/test_case"
require_relative "thesis/testing_state"
require_relative "thesis/version"
module Minitest
module Thesis
class Test < Minitest::Test
# Runs a test. Usage is:
#
# run_test do |test_case|
# n = test_case.choice(1000)
# end
#
# The block takes a `TestCase` argument, and should raise an exception to
# indicate a test failure. It will either run silently or print drawn
# values and then fail with an exception if minithesis finds some test case
# that fails.
#
# Arguments:
# * max_examples: the maximum number of valid test cases to run for.
# Note that under some circumstances the test may run fewer test
# cases than this.
# * random: An instance of random.Random that will be used for all
# nondeterministic choices.
# * database: A Hash-like object in which results will be cached and resumed
# from, ensuring that if a test is run twice it fails in the same way.
# * quiet: Will not print anything on failure if True.
def run_test(
name,
max_examples: 100,
random: Random.new,
database: DirectoryDb.new(".minitest-thesis-cache"),
quiet: false,
&test
)
mark_failures_interesting = ->(test_case) do
test.(test_case)
rescue Exception
raise unless test_case.status.nil?
test_case.mark_status(Status::INTERESTING)
end
state = TestingState.new(random:, test_function: mark_failures_interesting, max_examples:)
prev_failure = database[name]
if prev_failure
choices = prev_failure.unpack("Q>*")
state.test_function(TestCase.for_choices(choices))
end
if state.result.nil?
state.run
end
if state.valid_test_cases.zero?
raise Unsatisfiable
end
if state.result.nil?
database.delete(name)
else
database[name] = state.result.pack("Q>*")
end
unless state.result.nil?
test.(TestCase.for_choices(state.result, print_results: !quiet))
end
end
# Any integer in the range [m, n] is possible
def integers(m, n) = Possibility.new("integers(#{m}, #{n})") {|tc| m + tc.choice(n - m) }
# Any lists whose elements are possible values from `elements` are possible.
def lists(elements, min_size: 0, max_size: Float::INFINITY)
Possibility.new("lists(#{elements.name})") {|test_case|
result = []
loop do
if result.length < min_size
test_case.forced_choice(1)
elsif result.length + 1 >= max_size
test_case.forced_choice(0)
break
elsif test_case.weighted(0.9).zero?
break
end
result << test_case.any(elements)
end
result
}
end
# Only `value` is possible.
def just(value) = Possibility.new("just(#{value})") { value }
# No possible values. i.e. Any call to `any` will reject the test case.
def nothing = Possibility.new("nothing") {|tc| tc.reject }
# Possible values can be any value possible for one of `possibilities`.
def mix_of(*possibilities)
return nothing if possibilities.empty?
Possibility.new("mix_of(#{possibilities.map(&:name).join(", ")})") {|tc|
tc.any(possibilities[tc.choice(possibilities.length - 1)])
}
end
# Any tuple t of of length len(possibilities) such that t[i] is possible
# for possibilities[i] is possible.
def tuples(*possibilities)
Possibility.new( "tuples(#{possibilities.map(&:name).join(", ")})") {|tc|
possibilities.map {|p| tc.any(p) }
}
end
end
end
end