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