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

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