You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
128 lines
4.0 KiB
128 lines
4.0 KiB
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
|