# frozen_string_literal: true require_relative 'hypothesis/junkdrawer' require_relative 'hypothesis/errors' require_relative 'hypothesis/possible' require_relative 'hypothesis/testcase' require_relative 'hypothesis/engine' require_relative 'hypothesis/world' module Phase SHRINK = :shrink module_function def all [SHRINK] end def excluding(*phases) unknown_phases = phases.reject { |phase| Phase.all.include?(phase) } unless unknown_phases.empty? raise( ArgumentError, "Attempting to exclude unknown phases: #{unknown_phases}" ) end all - phases end end # This is the main module for using Hypothesis. # It is expected that you will include this in your # tests, but its methods are also available on the # module itself. # # The main entry point for using this is the # {Hypothesis#hypothesis} method. All of the other # methods make sense only inside blocks passed to # it. module Hypothesis # @!visibility private HYPOTHESIS_LOCATION = File.dirname(__FILE__) # rubocop:disable ClassVars @@setup_called = false # rubocop:enable RuleByName def self.setup_called @@setup_called == true end def self.included(*) if setup_called == false Rutie.new(:hypothesis_ruby_core).init( 'Init_rutie_hypothesis_core', __dir__ ) end @@setup_called = true end # @!visibility private def hypothesis_stable_identifier # Attempt to get a "stable identifier" for any any # call into hypothesis. We use these to create # database keys (or will when we have a database) that # are stable across runs, so that when a test that # previously failed is rerun, we can fetch and reuse # the previous examples. # Note that essentially any answer to this method is # "fine" in that the failure mode is that sometimes we # just won't run the same test, but it's nice to keep # this as stable as possible if the code isn't changing. # Minitest makes it nice and easy to create a stable # test identifier, because it follows the classic xunit # pattern where a test is just a method invocation on a # fresh test class instance and it's easy to find out # which invocation that was. return "#{self.class.name}::#{@NAME}" if defined? @NAME # If we are running in an rspec example then, sadly, # rspec take the entirely unreasonable stance that # the correct way to pass data to a test is by passing # it as a function argument. Honestly, what is this, # Haskell? Ahem. Perfectly reasonable design decisions # on rspec's part, this creates some annoying difficulties # for us. We solve this through brute force and ignorance # by relying on the information we want being in the # inspect for the Example object, even if it's just there # as a string. begin is_rspec = is_a? RSpec::Core::ExampleGroup # We do our coverage testing inside rspec, so this will # never trigger! Though we also don't currently have a # test that covers it outside of rspec... # :nocov: rescue NameError is_rspec = false end # :nocov: if is_rspec return [ self.class.description, inspect.match(/"([^"]+)"/)[1] ].join(' ') end # Fallback time! We just walk the stack until we find the # entry point into code we control. This will typically be # where "hypothesis" was called. Thread.current.backtrace.each do |line| return line unless line.include?(Hypothesis::HYPOTHESIS_LOCATION) end # This should never happen unless something very strange is # going on. # :nocov: raise 'BUG: Somehow we have no caller!' # :nocov: end # Run a test using Hypothesis. # # For example: # # ```ruby # hypothesis do # x = any integer # y = any integer(min: x) # expect(y).to be >= x # end # ``` # # The arguments to `any` are `Possible` instances which # specify the range of value values for it to return. # # Typically you would include this inside some test in your # normal testing framework - e.g. in an rspec it block or a # minitest test method. # # This will run the block many times with integer values for # x and y, and each time it will pass because we specified that # y had a minimum value of x. # If we changed it to `expect(y).to be > x` we would see output # like the following: # # ``` # Failure/Error: expect(y).to be > x # # Given #1: 0 # Given #2: 0 # expected: > 0 # got: 0 # ``` # # In more detail: # # hypothesis calls its provided block many times. Each invocation # of the block is a *test case*. # A test case has three important features: # # * *givens* are the result of a call to self.any, and are the # values that make up the test case. These might be values such # as strings, integers, etc. or they might be values specific to # your application such as a User object. # * *assumptions*, where you call `self.assume(some_condition)`. If # an assumption fails (`some_condition` is false), then the test # case is considered invalid, and is discarded. # * *assertions* are anything that will raise an error if the test # case should be considered a failure. These could be e.g. RSpec # expectations or minitest matchers, but anything that throws an # exception will be treated as a failed assertion. # # A test case which satisfies all of its assumptions and assertions # is *valid*. A test-case which satisfies all of its assumptions but # fails one of its assertions is *failing*. # # A call to hypothesis does the following: # # 1. It first tries to *reuse* failing test cases for previous runs. # 2. If there were no previous failing test cases then it tries to # *generate* new failing test cases. # 3. If either of the first two phases found failing test cases then # it will *shrink* those failing test cases. # 4. Finally, it will *display* the shrunk failing test case by # the error from its failing assertion, modified to show the # givens of the test case. # # Reuse uses an internal representation of the test case, so examples # from previous runs will obey all of the usual invariants of generation. # However, this means that if you change your test then reuse may not # work. Test cases that have become invalid or passing will be cleaned # up automatically. # # Generation consists of randomly trying test cases until one of # three things has happened: # # 1. It has found a failing test case. At this point it will start # *shrinking* the test case (see below). # 2. It has found enough valid test cases. At this point it will # silently stop. # 3. It has found so many invalid test cases that it seems unlikely # that it will find any more valid ones in a reasonable amount of # time. At this point it will either silently stop or raise # `Hypothesis::Unsatisfiable` depending on how many valid # examples it found. # # *Shrinking* is when Hypothesis takes a failing test case and tries # to make it easier to understand. It does this by replacing the givens # in the test case with smaller and simpler values. These givens will # still come from the possible values, and will obey all the usual # constraints. # In general, shrinking is automatic and you shouldn't need to care # about the details of it. If the test case you're shown at the end # is messy or needlessly large, please file a bug explaining the problem! # # @param max_valid_test_cases [Integer] The maximum number of valid test # cases to run without finding a failing test case before stopping. # # @param database [String, nil, false] A path to a directory where Hypothesis # should store previously failing test cases. If it is nil, Hypothesis # will use a default of .hypothesis/examples in the current directory. # May also be set to false to disable the database functionality. def hypothesis( max_valid_test_cases: 200, phases: Phase.all, database: nil, &block ) unless World.current_engine.nil? raise UsageError, 'Cannot nest hypothesis calls' end begin World.current_engine = Engine.new( hypothesis_stable_identifier, max_examples: max_valid_test_cases, phases: phases, database: database ) World.current_engine.run(&block) ensure World.current_engine = nil end end # Supplies a value to be used in your hypothesis. # @note It is invalid to call this method outside of a hypothesis block. # @return [Object] A value provided by the possible argument. # @param possible [Possible] A possible that specifies the possible values # to return. # @param name [String, nil] An optional name to show next to the result on # failure. This can be helpful if you have a lot of givens in your # hypothesis, as it makes it easier to keep track of which is which. def any(possible, name: nil, &block) if World.current_engine.nil? raise UsageError, 'Cannot call any outside of a hypothesis block' end World.current_engine.current_source.any( possible, name: name, &block ) end # Specify an assumption of your test case. Only test cases which satisfy # their assumptions will treated as valid, and all others will be # discarded. # @note It is invalid to call this method outside of a hypothesis block. # @note Try to use this only with "easy" conditions. If the condition is # too hard to satisfy this can make your testing much worse, because # Hypothesis will have to retry the test many times and will struggle # to find "interesting" test cases. For example `assume(x != y)` is # typically fine, and `assume(x == y)` is rarely a good idea. # @param condition [Boolean] The condition to assume. If this is false, # the current test case will be treated as invalid and the block will # exit by throwing an exception. The next test case will then be run # as normal. def assume(condition) if World.current_engine.nil? raise UsageError, 'Cannot call assume outside of a hypothesis block' end World.current_engine.current_source.assume(condition) end end