├── .tool-versions ├── .rspec ├── lib ├── prop_check │ ├── version.rb │ ├── property │ │ ├── output_formatter.rb │ │ ├── configuration.rb │ │ └── shrinker.rb │ ├── helper.rb │ ├── hooks.rb │ ├── lazy_tree.rb │ ├── generator.rb │ ├── property.rb │ └── generators.rb └── prop_check.rb ├── Rakefile ├── .rubocop.yml ├── bin ├── setup ├── console └── rspec ├── spec ├── prop_check │ ├── lazy_tree_spec.rb │ ├── generators_spec.rb │ └── generator_spec.rb ├── spec_helper.rb └── prop_check_spec.rb ├── .gitignore ├── Justfile ├── Gemfile ├── LICENSE.txt ├── .github └── workflows │ └── run_tests.yaml ├── prop_check.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.2 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/prop_check/version.rb: -------------------------------------------------------------------------------- 1 | module PropCheck 2 | VERSION = '1.0.2' 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | Metrics/LineLength: 4 | Max: 120 5 | Style/AccessModifierDeclarations: 6 | EnforcedStyle: inline 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/prop_check/lazy_tree_spec.rb: -------------------------------------------------------------------------------- 1 | require 'doctest2/rspec' 2 | 3 | LazyTree = PropCheck::LazyTree 4 | 5 | RSpec.describe PropCheck::LazyTree do 6 | doctest PropCheck::LazyTree 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | # .gem version files 14 | *.gem 15 | Gemfile.lock -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | setup_and_test: setup test 2 | 3 | setup: 4 | bin/setup 5 | 6 | test: 7 | bundle exec rspec 8 | 9 | console: 10 | bin/console 11 | 12 | install: 13 | bundle exec rake install 14 | 15 | release: 16 | bundle exec rake release 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in prop_check.gemspec 4 | gemspec 5 | 6 | gem "bundler", "~> 2.0" 7 | 8 | group :test do 9 | gem "rake", "~> 12.3", require: false 10 | gem "rspec", "~> 3.0", require: false 11 | gem "doctest2-rspec", require: false 12 | gem "simplecov", require: false 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "prop_check" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require 'simplecov' 3 | SimpleCov.start 4 | 5 | require "prop_check" 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /lib/prop_check.rb: -------------------------------------------------------------------------------- 1 | require "prop_check/version" 2 | require 'prop_check/property' 3 | require 'prop_check/generator' 4 | require 'prop_check/generators' 5 | require 'prop_check/helper' 6 | ## 7 | # Main module of the PropCheck library. 8 | # 9 | # You probably want to look at the documentation of 10 | # PropCheck::Generator and PropCheck::Generators 11 | # to find out more about how to use generators. 12 | # 13 | # Common usage is to call `extend PropCheck` in your (testing) modules. 14 | # 15 | # This will: 16 | # 1. Add the local method `forall` which will call `PropCheck.forall` 17 | # 2. `include PropCheck::Generators`. 18 | # 19 | module PropCheck 20 | module Errors 21 | class Error < StandardError; end 22 | class UserError < Error; end 23 | class GeneratorExhaustedError < UserError; end 24 | class MaxShrinkStepsExceededError < UserError; end 25 | end 26 | 27 | extend self 28 | 29 | ## 30 | # Runs a property. 31 | # 32 | # See the README for more details. 33 | def forall(*args, **kwargs, &block) 34 | PropCheck::Property.forall(*args, **kwargs, &block) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Qqwy/Wiebe-Marten Wijnja 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Ruby RSpec tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | # NOTE: We're stopping testing Ruby < 3.0 since prop_check version 1.0.0 19 | # It will _probably_ still work but as they're end-of-life, no guarantees! 20 | ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4'] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Ruby 25 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 26 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby-version }} 30 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 31 | - name: Run tests & push test coverage to Codeclimate 32 | uses: paambaati/codeclimate-action@v3.2.0 33 | env: 34 | CC_TEST_REPORTER_ID: '9d18f5b43e49eecd6c3da64d85ea9c765d3606c129289d7c8cadf6d448713311' 35 | with: 36 | coverageCommand: bundle exec rake 37 | -------------------------------------------------------------------------------- /spec/prop_check/generators_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PropCheck::Generators do 4 | describe '#date' do 5 | subject(:date) { described_class.date } 6 | 7 | it 'produces valid dates' do 8 | PropCheck.forall(date) do |val| 9 | expect(val).to be_a(Date) 10 | end 11 | end 12 | end 13 | 14 | describe '#time' do 15 | subject(:time) { described_class.time } 16 | 17 | it 'produces valid times' do 18 | PropCheck.forall(time) do |val| 19 | expect(val).to be_a(Time) 20 | end 21 | end 22 | end 23 | 24 | describe '#date_time' do 25 | subject(:date_time) { described_class.date_time } 26 | 27 | it 'produces valid date_times' do 28 | PropCheck.forall(date_time) do |val| 29 | expect(val).to be_a(DateTime) 30 | end 31 | end 32 | end 33 | 34 | describe '#array' do 35 | it 'produces array that respect the min property' do 36 | n_int = described_class.nonnegative_integer 37 | PropCheck.forall(n_int) do |val| 38 | val += 1 if val == 0 39 | 40 | result = described_class.array( 41 | n_int, 42 | min: val, 43 | empty: false 44 | ).sample 45 | 46 | expect(result).to all have_attributes(length: (a_value >= val)) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/prop_check/property/output_formatter.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # @api private 3 | require 'pp' 4 | 5 | module PropCheck::Property::OutputFormatter 6 | extend self 7 | 8 | def pre_output(output, n_successful, generated_root, problem) 9 | output.puts "" 10 | output.puts "(after #{n_successful} successful property test runs)" 11 | output.puts "Failed on: " 12 | output.puts "`#{print_roots(generated_root)}`" 13 | output.puts "" 14 | output.puts "Exception message:\n---\n#{problem}" 15 | output.puts "---" 16 | output.puts "" 17 | 18 | output 19 | end 20 | 21 | def post_output(output, n_shrink_steps, shrunken_result, shrunken_exception) 22 | if n_shrink_steps == 0 23 | output.puts '(shrinking impossible)' 24 | else 25 | output.puts '' 26 | output.puts "Shrunken input (after #{n_shrink_steps} shrink steps):" 27 | output.puts "`#{print_roots(shrunken_result)}`" 28 | output.puts "" 29 | output.puts "Shrunken exception:\n---\n#{shrunken_exception}" 30 | output.puts "---" 31 | output.puts "" 32 | end 33 | output 34 | end 35 | 36 | def print_roots(lazy_tree_val) 37 | data = 38 | if lazy_tree_val.is_a?(Array) && lazy_tree_val.length == 1 && lazy_tree_val[0].is_a?(Hash) 39 | lazy_tree_val[0] 40 | else 41 | lazy_tree_val 42 | end 43 | 44 | PP.pp(data, '') 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/prop_check/helper.rb: -------------------------------------------------------------------------------- 1 | module PropCheck 2 | ## 3 | # Helper functions that have no other place to live 4 | module Helper 5 | extend self 6 | ## 7 | # Creates a (potentially lazy) Enumerator 8 | # starting with `elem` 9 | # with each consecutive element obtained 10 | # by calling `operation` on the previous element. 11 | # 12 | # >> Helper.scanl(0, &:next).take(10).force 13 | # => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 14 | # >> Helper.scanl([0, 1]) { |curr, next_elem| [next_elem, curr + next_elem] }.map(&:first).take(10).force 15 | # => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] 16 | def scanl(elem, &operation) 17 | Enumerator.new do |yielder| 18 | acc = elem 19 | loop do 20 | # p acc 21 | yielder << acc 22 | acc = operation.call(acc) 23 | end 24 | end.lazy 25 | end 26 | 27 | ## 28 | # allow lazy appending of two (potentially lazy) enumerators: 29 | # >> PropCheck::Helper::LazyAppend.lazy_append([1,2,3],[4,5.6]).to_a 30 | # => [1,2,3,4,5,6] 31 | def lazy_append(this_enumerator, other_enumerator) 32 | [this_enumerator, other_enumerator].lazy.flat_map(&:lazy) 33 | end 34 | 35 | def call_splatted(val, &block) 36 | # Handle edge case where Ruby >= 3 behaves differently than Ruby <= 2 37 | # c.f. https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/#other-minor-changes-empty-hash 38 | return block.call({}) if val.is_a?(Hash) && val.empty? 39 | return block.call(**val) if val.is_a?(Hash) && val.keys.all? { |k| k.is_a?(Symbol) } 40 | 41 | block.call(val) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/prop_check/generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'doctest2/rspec' 2 | 3 | 4 | RSpec.describe PropCheck::Generator do 5 | Generator = PropCheck::Generator 6 | Generators = PropCheck::Generators 7 | doctest PropCheck::Generator 8 | 9 | # Used in a PropCheck::Generators doctest 10 | class User 11 | attr_accessor :name, :age 12 | def initialize(name: , age: ) 13 | @name = name 14 | @age = age 15 | end 16 | 17 | def inspect 18 | "" 19 | end 20 | end 21 | doctest PropCheck::Generators 22 | 23 | describe "#where" do 24 | it "filters out results we do not like" do 25 | no_fizzbuzz = PropCheck::Generators.integer.where { |val| val % 3 != 0 && val % 5 != 0 } 26 | PropCheck::forall(num: no_fizzbuzz) do |num:| 27 | expect(num).to_not be(3) 28 | expect(num).to_not be(5) 29 | expect(num).to_not be(6) 30 | expect(num).to_not be(9) 31 | expect(num).to_not be(10) 32 | expect(num).to_not be(15) 33 | end 34 | end 35 | 36 | it "might cause a Generator Exhaustion if we filter too much" do 37 | never = PropCheck::Generators.integer().where { |val| val == nil } 38 | expect do 39 | PropCheck::forall(never) {} 40 | end.to raise_error do |error| 41 | expect(error).to be_a(PropCheck::Errors::GeneratorExhaustedError) 42 | end 43 | end 44 | 45 | it 'can be mapped over' do 46 | PG = PropCheck::Generators 47 | user_gen = 48 | PG.fixed_hash(name: PG.string, age: PG.integer) 49 | .map { |name:, age:| [name, age]} 50 | end 51 | 52 | describe "while shrinking" do 53 | it "will never allow filtered results" do 54 | PG = PropCheck::Generators 55 | # gen = PG.fixed_hash(x: PG.printable_string(empty: false).where { |str| !/\A[[:space:]]*\z/.match?(str) }) 56 | gen = PG.integer.where { |val| val.odd? } 57 | expect(gen.generate.to_a).to_not include(:"_PropCheck.filter_me") 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /prop_check.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "prop_check/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "prop_check" 8 | spec.version = PropCheck::VERSION 9 | spec.authors = ["Qqwy/Marten Wijnja"] 10 | spec.email = ["w-m@wmcode.nl"] 11 | 12 | spec.summary = %q{PropCheck allows you to do property-based testing, including shrinking.} 13 | spec.description = %q{PropCheck allows you to do property-based testing, including shrinking. (akin to Haskell's QuickCheck, Erlang's PropEr, Elixir's StreamData). This means that your test are run many times with different, autogenerated inputs, and as soon as a failing case is found, this input is simplified, in the end giving you back the simplest input that made the test fail.} 14 | spec.homepage = "https://github.com/Qqwy/ruby-prop_check/" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["source_code_uri"] = "https://github.com/Qqwy/ruby-prop_check/" 22 | spec.metadata["changelog_uri"] = "https://github.com/Qqwy/ruby-prop_check/CHANGELOG.md" 23 | else 24 | raise "RubyGems 2.0 or newer is required to protect against " \ 25 | "public gem pushes." 26 | end 27 | 28 | # Specify which files should be added to the gem when it is released. 29 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 30 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 31 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 32 | end 33 | spec.bindir = "exe" 34 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 35 | spec.require_paths = ["lib"] 36 | 37 | spec.required_ruby_version = '>= 2.5.1' 38 | end 39 | -------------------------------------------------------------------------------- /lib/prop_check/property/configuration.rb: -------------------------------------------------------------------------------- 1 | module PropCheck 2 | class Property 3 | ## Configure PropCheck 4 | # 5 | # Configurations can be set globally, 6 | # but also overridden on a per-generator basis. 7 | # c.f. PropCheck.configure, PropCheck.configuration and PropCheck::Property#with_config 8 | # 9 | # ## Available options 10 | # - `verbose:` When true, shows detailed options of the data generation and shrinking process. (Default: false) 11 | # - `n_runs:` The amount of iterations each `forall` is being run. 12 | # - `max_generate_attempts:` The amount of times the library tries a generator in total 13 | # before raising `Errors::GeneratorExhaustedError`. c.f. `PropCheck::Generator#where`. (Default: 10_000) 14 | # - `max_shrink_steps:` The amount of times shrinking is attempted. (Default: 10_000) 15 | # - `max_consecutive_attempts:` The amount of times the library tries a filtered generator consecutively 16 | # again before raising `Errors::GeneratorExhaustedError`. c.f. `PropCheck::Generator#where`. (Default: 10_000) 17 | # - `default_epoch:` The 'base' value to use for date/time generators like 18 | # `PropCheck::Generators#date` `PropCheck::Generators#future_date` `PropCheck::Generators#time`, etc. 19 | # (Default: `DateTime.now`) 20 | # - `resize_function:` A proc that can be used to resize _all_ generators. 21 | # Takes the current size as integer and should return a new integer. 22 | # (Default: `proc { |size| size }`) 23 | Configuration = Struct.new( 24 | :verbose, 25 | :n_runs, 26 | :max_generate_attempts, 27 | :max_shrink_steps, 28 | :max_consecutive_attempts, 29 | :default_epoch, 30 | :resize_function, 31 | keyword_init: true 32 | ) do 33 | def initialize( 34 | verbose: false, 35 | n_runs: 100, 36 | max_generate_attempts: 10_000, 37 | max_shrink_steps: 10_000, 38 | max_consecutive_attempts: 30, 39 | default_epoch: DateTime.now, 40 | resize_function: proc { |size| size } 41 | ) 42 | super 43 | end 44 | 45 | def merge(other) 46 | Configuration.new(**to_h.merge(other.to_h)) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/prop_check/property/shrinker.rb: -------------------------------------------------------------------------------- 1 | require 'prop_check/helper' 2 | class PropCheck::Property::Shrinker 3 | def initialize(bindings_tree, io, hooks, config) 4 | @problem_child = bindings_tree 5 | @io = io 6 | @siblings = @problem_child.children.lazy 7 | @parent_siblings = nil 8 | @problem_exception = nil 9 | @shrink_steps = 0 10 | @hooks = hooks 11 | @config = config 12 | end 13 | 14 | def self.call(bindings_tree, io, hooks, config, &block) 15 | self 16 | .new(bindings_tree, io, hooks, config) 17 | .call(&block) 18 | end 19 | 20 | def call(&block) 21 | @io.puts 'Shrinking...' if @config.verbose 22 | 23 | shrink(&block) 24 | 25 | print_shrinking_exceeded_message if @shrink_steps >= @config.max_shrink_steps 26 | 27 | [@problem_child.root, @problem_exception, @shrink_steps] 28 | end 29 | 30 | private def shrink(&block) 31 | wrapped_enum.each do 32 | instruction, sibling = safe_read_sibling 33 | break if instruction == :break 34 | next if instruction == :next 35 | 36 | inc_shrink_step 37 | 38 | safe_call_block(sibling, &block) 39 | end 40 | end 41 | 42 | private def wrapped_enum 43 | @hooks.wrap_enum(0..@config.max_shrink_steps).lazy 44 | end 45 | 46 | private def inc_shrink_step 47 | @shrink_steps += 1 48 | @io.print '.' if @config.verbose 49 | end 50 | 51 | private def safe_read_sibling 52 | begin 53 | sibling = @siblings.next 54 | [:continue, sibling] 55 | rescue StopIteration 56 | return [:break, nil] if @parent_siblings.nil? 57 | 58 | @siblings = @parent_siblings.lazy 59 | @parent_siblings = nil 60 | [:next, nil] 61 | end 62 | end 63 | 64 | private def safe_call_block(sibling, &block) 65 | begin 66 | PropCheck::Helper.call_splatted(sibling.root, &block) 67 | # It is correct that we want to rescue _all_ Exceptions 68 | # not only 'StandardError's 69 | rescue Exception => e 70 | @problem_child = sibling 71 | @parent_siblings = @siblings 72 | @siblings = @problem_child.children.lazy 73 | @problem_exception = e 74 | end 75 | end 76 | 77 | private def print_shrinking_exceeded_message 78 | @io.puts "(Note: Exceeded #{@config.max_shrink_steps} shrinking steps, the maximum.)" 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at w-m@wmcode.nl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/prop_check/hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # @api private 5 | # Contains the logic to combine potentially many before/after/around hooks 6 | # into a single pair of procedures called `before` and `after`. 7 | # 8 | # _Note: This module is an implementation detail of PropCheck._ 9 | # 10 | # These can be invoked by manually calling `#before` and `#after`. 11 | # Important: 12 | # - Always call first `#before` and then `#after`. 13 | # This is required to make sure that `around` callbacks will work properly. 14 | # - Make sure that if you call `#before`, to also call `#after`. 15 | # It is thus highly recommended to call `#after` inside an `ensure`. 16 | # This is to make sure that `around` callbacks indeed perform their proper cleanup. 17 | # 18 | # Alternatively, check out `PropCheck::Hooks::Enumerable` which allows 19 | # wrapping the elements of an enumerable with hooks. 20 | class PropCheck::Hooks 21 | # attr_reader :before, :after, :around 22 | def initialize(before: proc {}, after: proc {}, around: proc { |*args, &block| block.call(*args) }) 23 | @before = before 24 | @after = after 25 | @around = around 26 | freeze 27 | end 28 | 29 | def wrap_enum(enumerable) 30 | PropCheck::Hooks::Enumerable.new(enumerable, self) 31 | end 32 | 33 | 34 | ## 35 | # Wraps a block with all hooks that were configured this far. 36 | # 37 | # This means that whenever the block is called, 38 | # the before/around/after hooks are called before/around/after it. 39 | def wrap_block(&block) 40 | proc { |*args| call(*args, &block) } 41 | end 42 | 43 | ## 44 | # Wraps a block with all hooks that were configured this far, 45 | # and immediately calls it using the given `*args`. 46 | # 47 | # See also #wrap_block 48 | def call(*args, &block) 49 | begin 50 | @before.call() 51 | @around.call do 52 | block.call(*args) 53 | end 54 | ensure 55 | @after.call() 56 | end 57 | end 58 | 59 | ## 60 | # Adds `hook` to the `before` proc. 61 | # It is called after earlier-added `before` procs. 62 | def add_before(&hook) 63 | # old_before = @before 64 | new_before = proc { 65 | @before.call 66 | hook.call 67 | } 68 | # self 69 | self.class.new(before: new_before, after: @after, around: @around) 70 | end 71 | 72 | ## 73 | # Adds `hook` to the `after` proc. 74 | # It is called before earlier-added `after` procs. 75 | def add_after(&hook) 76 | # old_after = @after 77 | new_after = proc { 78 | hook.call 79 | @after.call 80 | } 81 | # self 82 | self.class.new(before: @before, after: new_after, around: @around) 83 | end 84 | 85 | ## 86 | # Adds `hook` to the `around` proc. 87 | # It is called _inside_ earlier-added `around` procs. 88 | def add_around(&hook) 89 | # old_around = @around 90 | new_around = proc do |&block| 91 | @around.call do |*args| 92 | hook.call(*args, &block) 93 | end 94 | end 95 | # self 96 | self.class.new(before: @before, after: @after, around: new_around) 97 | end 98 | 99 | ## 100 | # @api private 101 | # Wraps enumerable `inner` with a `PropCheck::Hooks` object 102 | # such that the before/after/around hooks are called 103 | # before/after/around each element that is fetched from `inner`. 104 | # 105 | # This is very helpful if you need to perform cleanup logic 106 | # before/after/around e.g. data is generated or fetched. 107 | # 108 | # Note that whatever is after a `yield` in an `around` hook 109 | # is not guaranteed to be called (for instance when a StopIteration is raised). 110 | # Thus: make sure you use `ensure` to clean up resources. 111 | class Enumerable 112 | include ::Enumerable 113 | 114 | def initialize(inner, hooks) 115 | @inner = inner 116 | @hooks = hooks 117 | end 118 | 119 | def each(&task) 120 | return to_enum(:each) unless block_given? 121 | 122 | enum = @inner.to_enum 123 | 124 | wrapped_yielder = @hooks.wrap_block do 125 | yield enum.next(&task) 126 | end 127 | 128 | loop(&wrapped_yielder) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - 1.0.2 2 | - Fixes: 3 | - When calling `Generator#sample` or `Generator#call`, the options are now merged with the default options _once_, resulting in a slight speedup. (c.f. [#24](https://github.com/Qqwy/ruby-prop_check/pull/24), thank you, @niku!) 4 | - Documentation fixes: 5 | - Ordering of Property::Configuration options is now alphabetical. (c.f. [#23](https://github.com/Qqwy/ruby-prop_check/pull/23). Thank you, @niku!) 6 | - 1.0.1 7 | - Fixes: 8 | - The invariants of the of the `min` option for the `array` generator were not checked correctly, sometimes causing arrays with too small lengths to be generated. (c.f. [#26](https://github.com/Qqwy/ruby-prop_check/pull/26). Thank you, @olafura!) 9 | - 1.0.0 10 | - Changes: 11 | - Pretty-print failures using Ruby's builtin `PP`, so `prop_check` no longer depends on the `awesome_print` gem. (c.f. #19) 12 | - 0.18.2 13 | - Documentation updates: 14 | - Adding an example of using prop_check with the `test-unit` testing framework to the README. (c.f. #18, thank you, @niku!) 15 | - Fixing typos in various parts of the documentation. (c.f. #16, #17, #21. Thank you, @meganemura, @niku and @harlantwood!) 16 | - 0.18.1 17 | - Fixes: 18 | - Compatibility with Ruby 3.2: 19 | - Use `Random` instead of no-longer-available `Random::DEFAULT` on Ruby 3.x. 20 | - Ensure when a hash is passed (such as in `PropCheck.forall(hash_of(integer, string)) { |hash| ... }` that when an empty hash is generated, `hash` is still `{}` and not `nil`. ([Ruby 3.x treats `fun(**{})` differently than Ruby 2.x](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/#other-minor-changes-empty-hash)) 21 | - 0.18.0 22 | - Features: 23 | - Allows calling `PropCheck::Property#check` without a block, which will just return `self`. This is useful for writing wrapper functions that use `before/after/around/with_config` etc hooks which might themselves optionally want a block so they can be chained. (See the `forall_with_db` snippet in the README for an example) 24 | - 0.17.0 25 | - Features: 26 | - Recursive generation using `PropCheck::Generators.tree`. 27 | - 0.16.0 28 | - Features: 29 | - New option in `PropCheck::Property::Configuration` to resize all generators at once. 30 | - Wrapper functions to modify this easily in `PropCheck::Property` called `#resize`, `#grow_fast`, `#grow_slowly`, `#grow_exponentially`, `#grow_quadratically`, `#grow_logarithmically`. 31 | - 0.15.0 32 | - Features: 33 | - Generators for `Date`, `Time` and `DateTime`. 34 | - Basic work done by @Haniyya. Thank you very much! 35 | - Extra functions to generate dates/times/datetimes in the future or the past. 36 | - Allow overriding the epoch that is used. 37 | - A new option in `PropCheck::Property::Configuration` to set the default epoch. 38 | - Generator to generate `Set`s. 39 | - New builtin float generators (positive, negative, nonzero, nonnegative, nonpositive). Both in 'normal' flavor and in 'real' flavor (that will never generate infinity or other special values). 40 | - `PropCheck::Generator#with_config` which enables the possibility to inspect and act on the current `PropCheck::Property::Configuration` while generating values. 41 | - Fixes: 42 | - Preserve backwards compatibility with Ruby 2.5 by not using infinite ranges internally (c.f. #8, thank you, @hlaf!) 43 | - Make a flaky test deterministic by fixing the RNG. (c.f. #9, thank you, @hlaf!) 44 | - Fix a crash when using a hash where not all keys are symbols. (c.f. #7, thank you, @Haniyya!) 45 | - Fix situations in which `PropCheck::Generators.array` would for certain config values never generate empty arrays. 46 | - 0.14.1 - Swap `awesome_print` for `amazing_print` which is a fork of the former that is actively maintained. 47 | - 0.14.0 - Adds `uniq: true` option to `Generators.array`. Makes `PropCheck::Property` an immutable object that returns copies that have changes whenever reconfiguring, allowing re-usable configuration. 48 | - 0.13.0 - Adds Generator#resize 49 | - 0.12.1 - Fixes shrinking when filtering bug. 50 | - 0.12.0 - `PropCheck::Generators#instance` 51 | - 0.11.0 - Improved syntax to support Ruby 2.7 and up without deprecation warnings, full support for `#where`. 52 | - 0.10.0 - Some bugfixes, support for `#where` 53 | - 0.8.0 - New syntax that is more explicit, passng generated values to blocks as parameters. 54 | -------------------------------------------------------------------------------- /lib/prop_check/lazy_tree.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PropCheck 4 | ## 5 | # A Rose tree with the root being eager, 6 | # and the children computed lazily, on demand. 7 | class LazyTree 8 | require 'prop_check/helper' 9 | 10 | include Enumerable 11 | 12 | attr_accessor :root 13 | def initialize(root, children = [].lazy) 14 | @root = root 15 | @children = children 16 | end 17 | 18 | def children 19 | @children.reject { |child| child.root == :"_PropCheck.filter_me" } 20 | end 21 | 22 | ## 23 | # Maps `block` eagerly over `root` and lazily over `children`, returning a new LazyTree as result. 24 | # 25 | # >> LazyTree.new(1, [LazyTree.new(2, [LazyTree.new(3)]), LazyTree.new(4)]).map(&:next).to_a 26 | # => LazyTree.new(2, [LazyTree.new(3, [LazyTree.new(4)]), LazyTree.new(5)]).to_a 27 | def map(&block) 28 | new_root = block.call(root) 29 | new_children = children.map { |child_tree| child_tree.map(&block) } 30 | LazyTree.new(new_root, new_children) 31 | end 32 | 33 | ## 34 | # Turns a tree of trees 35 | # in a single flattened tree, with subtrees that are closer to the root 36 | # and the left subtree earlier in the list of children. 37 | # TODO: Check for correctness 38 | # def flatten 39 | # root_tree = root 40 | # root_root = root_tree.root 41 | 42 | # root_children = root_tree.children 43 | # flattened_children = children.map(&:flatten) 44 | 45 | # combined_children = PropCheck::Helper.lazy_append(root_children, flattened_children) 46 | 47 | # LazyTree.new(root_root, combined_children) 48 | # end 49 | 50 | def self.wrap(val) 51 | LazyTree.new(val) 52 | end 53 | 54 | def bind(&fun) 55 | inner_tree = fun.call(root) 56 | inner_root = inner_tree.root 57 | inner_children = inner_tree.children 58 | mapped_children = children.map { |child| child.bind(&fun) } 59 | 60 | combined_children = PropCheck::Helper.lazy_append(inner_children, mapped_children) 61 | 62 | LazyTree.new(inner_root, combined_children) 63 | end 64 | 65 | ## 66 | # Turns a LazyTree in a long lazy enumerable, with the root first followed by its children 67 | # (and the first children's result before later children; i.e. a depth-first traversal.) 68 | # 69 | # Be aware that this lazy enumerable is potentially infinite, 70 | # possibly uncountably so. 71 | # 72 | # >> LazyTree.new(1, [LazyTree.new(2, [LazyTree.new(3)]), LazyTree.new(4)]).each.force 73 | # => [1, 4, 2, 3] 74 | def each(&block) 75 | self.to_enum(:each) unless block_given? 76 | 77 | squish([]) 78 | .each(&block) 79 | end 80 | 81 | protected def squish(arr) 82 | new_children = self.children.reduce(arr) { |acc, elem| elem.squish(acc) } 83 | PropCheck::Helper.lazy_append([self.root], new_children) 84 | end 85 | 86 | ## 87 | # Fully evaluate the LazyTree into an eager array, with the root first followed by its children 88 | # (and the first children's result before later children; i.e. a depth-first traversal.) 89 | # 90 | # Be aware that calling this might make Ruby attempt to evaluate an infinite collection. 91 | # Therefore, it is mostly useful for debugging; in production you probably want to use 92 | # the other mechanisms this class provides.. 93 | # 94 | # >> LazyTree.new(1, [LazyTree.new(2, [LazyTree.new(3)]), LazyTree.new(4)]).to_a 95 | # => [1, 4, 2, 3] 96 | def to_a 97 | each 98 | .force 99 | end 100 | 101 | # TODO: fix implementation 102 | def self.zip(trees) 103 | # p "TREES: " 104 | # p trees.to_a 105 | # p "END TREES" 106 | # raise "Boom!" unless trees.to_a.is_a?(Array) && trees.to_a.first.is_a?(LazyTree) 107 | # p self 108 | new_root = trees.to_a.map(&:root) 109 | # p new_root 110 | # new_children = trees.permutations.flat_map(&:children) 111 | new_children = permutations(trees).map { |children| LazyTree.zip(children) } 112 | # p new_children 113 | LazyTree.new(new_root, new_children) 114 | end 115 | 116 | private_class_method def self.permutations(trees) 117 | # p trees 118 | trees.lazy.each_with_index.flat_map do |tree, index| 119 | tree.children.map do |child| 120 | child_trees = trees.to_a.clone 121 | child_trees[index] = child 122 | # p "CHILD TREES:" 123 | # p child_trees 124 | child_trees.lazy 125 | end 126 | end 127 | end 128 | 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/prop_check/generator.rb: -------------------------------------------------------------------------------- 1 | module PropCheck 2 | ## 3 | # A `Generator` is a special kind of 'proc' that, 4 | # given a size an random number generator state, 5 | # will generate a (finite) LazyTree of output values: 6 | # 7 | # The root of this tree is the value to be used during testing, 8 | # and the children are 'smaller' values related to the root, 9 | # to be used during the shrinking phase. 10 | class Generator 11 | @@default_size = 10 12 | @@default_rng = 13 | # Backwards compatibility: Random::DEFAULT is deprecated in Ruby 3.x 14 | # but required in Ruby 2.x and 1.x 15 | if RUBY_VERSION.to_i >= 3 16 | Random 17 | else 18 | Random::DEFAULT 19 | end 20 | @@max_consecutive_attempts = 100 21 | @@default_kwargs = { size: @@default_size, rng: @@default_rng, 22 | max_consecutive_attempts: @@max_consecutive_attempts } 23 | 24 | ## 25 | # Being a special kind of Proc, a Generator wraps a block. 26 | def initialize(&block) 27 | @block = block 28 | end 29 | 30 | ## 31 | # Given a `size` (integer) and a random number generator state `rng`, 32 | # generate a LazyTree. 33 | def generate(**kwargs) 34 | kwargs = @@default_kwargs.merge(kwargs) 35 | max_consecutive_attempts = kwargs[:max_consecutive_attempts] 36 | 37 | (0..max_consecutive_attempts).each do 38 | res = @block.call(**kwargs) 39 | next if res.root == :"_PropCheck.filter_me" 40 | 41 | return res 42 | end 43 | 44 | raise Errors::GeneratorExhaustedError, ''" 45 | Exhausted #{max_consecutive_attempts} consecutive generation attempts. 46 | 47 | Probably too few generator results were adhering to a `where` condition. 48 | "'' 49 | end 50 | 51 | ## 52 | # Generates a value, and only return this value 53 | # (drop information for shrinking) 54 | # 55 | # >> Generators.integer.call(size: 1000, rng: Random.new(42)) 56 | # => 126 57 | def call(**kwargs) 58 | generate(**kwargs).root 59 | end 60 | 61 | ## 62 | # Returns `num_of_samples` values from calling this Generator. 63 | # This is mostly useful for debugging if a generator behaves as you intend it to. 64 | def sample(num_of_samples = 10, **kwargs) 65 | num_of_samples.times.map do 66 | call(**kwargs) 67 | end 68 | end 69 | 70 | ## 71 | # Creates a 'constant' generator that always returns the same value, 72 | # regardless of `size` or `rng`. 73 | # 74 | # Keen readers may notice this as the Monadic 'pure'/'return' implementation for Generators. 75 | # 76 | # >> Generators.integer.bind { |a| Generators.integer.bind { |b| Generator.wrap([a , b]) } }.call(size: 100, rng: Random.new(42)) 77 | # => [2, 79] 78 | def self.wrap(val) 79 | Generator.new { LazyTree.wrap(val) } 80 | end 81 | 82 | ## 83 | # Create a generator whose implementation depends on the output of another generator. 84 | # this allows us to compose multiple generators. 85 | # 86 | # Keen readers may notice this as the Monadic 'bind' (sometimes known as '>>=') implementation for Generators. 87 | # 88 | # >> Generators.integer.bind { |a| Generators.integer.bind { |b| Generator.wrap([a , b]) } }.call(size: 100, rng: Random.new(42)) 89 | # => [2, 79] 90 | def bind(&generator_proc) 91 | # Generator.new do |size, rng| 92 | # outer_result = generate(size, rng) 93 | # outer_result.map do |outer_val| 94 | # inner_generator = generator_proc.call(outer_val) 95 | # inner_generator.generate(size, rng) 96 | # end.flatten 97 | # end 98 | Generator.new do |**kwargs| 99 | outer_result = generate(**kwargs) 100 | outer_result.bind do |outer_val| 101 | inner_generator = generator_proc.call(outer_val) 102 | inner_generator.generate(**kwargs) 103 | end 104 | end 105 | end 106 | 107 | ## 108 | # Creates a new Generator that returns a value by running `proc` on the output of the current Generator. 109 | # 110 | # >> Generators.choose(32..128).map(&:chr).call(size: 10, rng: Random.new(42)) 111 | # => "S" 112 | def map(&proc) 113 | Generator.new do |**kwargs| 114 | result = generate(**kwargs) 115 | result.map(&proc) 116 | end 117 | end 118 | 119 | ## 120 | # Turns a generator returning `x` into a generator returning `[x, config]` 121 | # where `config` is the current `PropCheck::Property::Configuration`. 122 | # This can be used to inspect the configuration inside a `#map` or `#where` 123 | # and act on it. 124 | # 125 | # >> example_config = PropCheck::Property::Configuration.new(default_epoch: Date.new(2022, 11, 22)) 126 | # >> generator = Generators.choose(0..100).with_config.map { |int, conf| Date.jd(conf[:default_epoch].jd + int) } 127 | # >> generator.call(size: 10, rng: Random.new(42), config: example_config) 128 | # => Date.new(2023, 01, 12) 129 | def with_config 130 | Generator.new do |**kwargs| 131 | result = generate(**kwargs) 132 | result.map { |val| [val, kwargs[:config]] } 133 | end 134 | end 135 | 136 | ## 137 | # Creates a new Generator that only produces a value when the block `condition` returns a truthy value. 138 | def where(&condition) 139 | map do |result| 140 | # if condition.call(*result) 141 | if PropCheck::Helper.call_splatted(result, &condition) 142 | result 143 | else 144 | :"_PropCheck.filter_me" 145 | end 146 | end 147 | end 148 | 149 | ## 150 | # Resizes the generator to either grow faster or smaller than normal. 151 | # 152 | # `proc` takes the current size as input and is expected to return the new size. 153 | # a size should always be a nonnegative integer. 154 | # 155 | # >> Generators.integer.resize{} 156 | def resize(&proc) 157 | Generator.new do |size:, **other_kwargs| 158 | new_size = proc.call(size) 159 | generate(**other_kwargs, size: new_size) 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/prop_check_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PropCheck do 2 | it 'has a version number' do 3 | expect(PropCheck::VERSION).not_to be nil 4 | end 5 | 6 | describe PropCheck do 7 | describe '.forall' do 8 | it 'returns a Property when called without a block' do 9 | expect(PropCheck.forall(x: PropCheck::Generators.integer)).to be_a(PropCheck::Property) 10 | end 11 | 12 | it 'runs the property test when called with a block' do 13 | expect { |block| PropCheck.forall(x: PropCheck::Generators.integer, &block) }.to yield_control 14 | end 15 | 16 | it 'accepts simple arguments' do 17 | expect do 18 | PropCheck.forall(PropCheck::Generators.integer, PropCheck::Generators.float) do |x, y| 19 | expect(x).to be_a Integer 20 | expect(y).to be_a Float 21 | end 22 | end.not_to raise_error 23 | end 24 | 25 | it 'accepts keyword arguments' do 26 | expect do 27 | PropCheck.forall(x: PropCheck::Generators.integer, y: PropCheck::Generators.float) do |x:, y:| 28 | expect(x).to be_a Integer 29 | expect(y).to be_a Float 30 | end 31 | end.not_to raise_error 32 | end 33 | 34 | it 'raises when receiving both simple and keyword arguments at the same time' do 35 | expect do 36 | PropCheck.forall(PropCheck::Generators.integer, a: PropCheck::Generators.integer) do |int, hash| 37 | expect(int).to be_a Integer 38 | expect(hash[:x]).to be_a Float 39 | end 40 | end.to raise_error(ArgumentError) 41 | end 42 | 43 | it 'will not shrink upon encountering a SystemExit' do 44 | expect do 45 | PropCheck.forall(x: PropCheck::Generators.integer) do |x:| 46 | raise SystemExit if x > 3 47 | end 48 | end.to raise_error do |error| 49 | expect(error).to be_a(SystemExit) 50 | 51 | # Check for no shrinking: 52 | expect(defined?(error.prop_check_info)).to be_nil 53 | end 54 | end 55 | 56 | it 'will not shrink upon encountering a SignalException' do 57 | expect do 58 | PropCheck.forall(x: PropCheck::Generators.integer) do |x:| 59 | Process.kill('HUP', Process.pid) if x > 3 60 | end 61 | end.to raise_error do |error| 62 | expect(error).to be_a(SignalException) 63 | 64 | # Check for no shrinking: 65 | expect(defined?(error.prop_check_info)).to be_nil 66 | end 67 | end 68 | 69 | it 'shrinks and returns an exception with the #prop_check_info method upon finding a failure case' do 70 | class MyCustomError < StandardError; end 71 | expected_keys = %i[original_input original_exception_message shrunken_input shrunken_exception 72 | n_successful n_shrink_steps] 73 | exploding_val = nil 74 | shrunken_val = nil 75 | 76 | expect do 77 | PropCheck.forall(x: PropCheck::Generators.float) do |x:| 78 | if x > 3.1415 79 | exploding_val ||= x 80 | shrunken_val = x 81 | raise MyCustomError, 'I do not like this number' 82 | end 83 | end 84 | end.to raise_error do |error| 85 | expect(error).to be_a(MyCustomError) 86 | expect(defined?(error.prop_check_info)).to eq('method') 87 | info = error.prop_check_info 88 | expect(info.keys).to contain_exactly(*expected_keys) 89 | 90 | expect(info[:original_exception_message]).to eq('I do not like this number') 91 | expect(info[:original_input]).to eq({ x: exploding_val }) 92 | expect(info[:shrunken_input]).to eq({ x: shrunken_val }) 93 | expect(info[:n_successful]).to be_a(Integer) 94 | expect(info[:n_shrink_steps]).to be_a(Integer) 95 | end 96 | end 97 | end 98 | 99 | describe 'Property' do 100 | describe '#with_config' do 101 | it 'updates the configuration' do 102 | p = PropCheck.forall(x: PropCheck::Generators.integer) 103 | expect(p.configuration[:verbose]).to be false 104 | expect(p.with_config(verbose: true).configuration[:verbose]).to be true 105 | end 106 | it 'Runs the property test when called with a block' do 107 | expect do |block| 108 | PropCheck.forall(x: PropCheck::Generators.integer).with_config(**{}, &block) 109 | end.to yield_control 110 | end 111 | end 112 | 113 | describe '#check' do 114 | it 'generates an error that Rspec can pick up' do 115 | expect do 116 | PropCheck.forall(x: PropCheck::Generators.nonnegative_integer).with_config(n_runs: 1_000) do |x:| 117 | expect(x).to be < 10 118 | end 119 | end.to raise_error do |error| 120 | expect(error).to be_a(RSpec::Expectations::ExpectationNotMetError) 121 | expect(error.message).to match(/\(after \d+ successful property test runs\)/m) 122 | expect(error.message).to match(/Exception message:/m) 123 | 124 | # Test basic shrinking real quick: 125 | expect(error.message).to match(/Shrunken input \(after \d+ shrink steps\):/m) 126 | expect(error.message).to match(/Shrunken exception:/m) 127 | 128 | expect(defined?(error.prop_check_info)).to eq('method') 129 | # p error.prop_check_info 130 | end 131 | end 132 | 133 | it "generates an error with 'shrinking impossible' if the value cannot be shrunk further" do 134 | srand(42) 135 | expect do 136 | PropCheck.forall(PropCheck::Generators.array(PropCheck::Generators.integer)) do |array| 137 | array.sum / array.length 138 | end 139 | end.to raise_error do |error| 140 | expect(error).to be_a(ZeroDivisionError) 141 | expect(error.message).to match(/\(shrinking impossible\)/) 142 | end 143 | end 144 | end 145 | 146 | describe '#where' do 147 | it 'filters results' do 148 | PropCheck.forall(x: PropCheck::Generators.integer, 149 | y: PropCheck::Generators.positive_integer).where do |x:, y:| 150 | x != y 151 | end.check do |x:, y:| 152 | expect(x).to_not eq y 153 | end 154 | end 155 | 156 | it 'raises an error if too much was filtered' do 157 | expect do 158 | PropCheck.forall(x: PropCheck::Generators.positive_integer).where { |x:| x == 0 }.check do 159 | end 160 | end.to raise_error do |error| 161 | expect(error).to be_a(PropCheck::Errors::GeneratorExhaustedError) 162 | # Check for no shrinking: 163 | expect(defined?(error.prop_check_info)).to be_nil 164 | end 165 | end 166 | 167 | it 'crashes when doing nonesense in the where block' do 168 | expect do 169 | PropCheck.forall(x: PropCheck::Generators.negative_integer).where { |x:| x.unexistentmethod == 3 }.check do 170 | end 171 | end.to raise_error do |error| 172 | expect(error).to be_a(NoMethodError) 173 | # Check for no shrinking: 174 | expect(defined?(error.prop_check_info)).to be_nil 175 | end 176 | end 177 | end 178 | 179 | describe '.configure' do 180 | it 'configures all checks done from that point onward' do 181 | PropCheck::Property.configure do |config| 182 | config.n_runs = 42 183 | end 184 | 185 | expect(PropCheck.forall(foo: PropCheck::Generators.integer).configuration.n_runs).to be 42 186 | end 187 | end 188 | describe 'hooks' do 189 | describe '#before' do 190 | it 'calls the before block before every generated value (even filtered ones)' do 191 | expect do |before_hook| 192 | PropCheck.forall(PropCheck::Generators.integer) 193 | .with_config(n_runs: 100) 194 | .before(&before_hook) 195 | .where { |x| x.odd? } 196 | .check do 197 | end 198 | end.to yield_control.exactly(100).times 199 | end 200 | end 201 | 202 | describe '#after' do 203 | it 'calls the after block after every generated value (even filtered ones)' do 204 | expect do |after_hook| 205 | PropCheck.forall(PropCheck::Generators.integer) 206 | .with_config(n_runs: 100) 207 | .after(&after_hook) 208 | .where { |x| x.even? } 209 | .check do 210 | end 211 | end.to yield_control.exactly(100).times 212 | end 213 | end 214 | 215 | describe '#around' do 216 | it 'calls the around block around every generated value (even filtered ones)' do 217 | before_calls = 0 218 | after_calls = 0 219 | inner_calls = 0 220 | around_hook = proc do |&block| 221 | before_calls += 1 222 | block.call 223 | ensure 224 | after_calls += 1 225 | end 226 | PropCheck.forall(PropCheck::Generators.integer) 227 | .with_config(n_runs: 100) 228 | .around(&around_hook) 229 | .where { |x| x.odd? } 230 | .check do 231 | inner_calls += 1 232 | end 233 | expect(before_calls).to eq(100) 234 | expect(after_calls).to eq(100) 235 | expect(inner_calls).to eq(100) 236 | end 237 | end 238 | end 239 | end 240 | 241 | describe 'including PropCheck in a testing-environment' do 242 | include PropCheck 243 | include PropCheck::Generators 244 | it 'adds forall to the example scope and brings generators inside PropCheck::Generators into scope`' do 245 | thing = nil 246 | forall(x: integer) do |x:| 247 | expect(x).to be_a(Integer) 248 | thing = true 249 | end 250 | expect(thing).to be true 251 | end 252 | end 253 | 254 | describe 'generating a hash' do 255 | include PropCheck::Generators 256 | 257 | it 'does not fail when calling call_splatted with non-symbol keys' do 258 | PropCheck.forall(hash_of(integer, integer)) do |h| 259 | expect(h).to be_a(Hash) 260 | end 261 | end 262 | end 263 | 264 | describe 'resizing' do 265 | include PropCheck::Generators 266 | 267 | it 'will not find a counter-example if resized to logarithmically while iterating only 100 times' do 268 | PropCheck.forall(integer).growing_logarithmically do |val| 269 | expect(val).to be < 6 270 | end 271 | end 272 | end 273 | end 274 | end 275 | -------------------------------------------------------------------------------- /lib/prop_check/property.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | 3 | require 'prop_check/property/configuration' 4 | require 'prop_check/property/output_formatter' 5 | require 'prop_check/property/shrinker' 6 | require 'prop_check/hooks' 7 | module PropCheck 8 | ## 9 | # Create and run property-checks. 10 | # 11 | # For simple usage, see `.forall`. 12 | # 13 | # For advanced usage, call `PropCheck::Property.new(...)` and then configure it to your liking 14 | # using e.g. `#with_config`, `#before`, `#after`, `#around` etc. 15 | # Each of these methods will return a new `Property`, so earlier properties are not mutated. 16 | # This allows you to re-use configuration and hooks between multiple tests. 17 | class Property 18 | ## 19 | # Main entry-point to create (and possibly immediately run) a property-test. 20 | # 21 | # This method accepts a list of generators and a block. 22 | # The block will then be executed many times, passing the values generated by the generators 23 | # as respective arguments: 24 | # 25 | # ``` 26 | # include PropCheck::Generators 27 | # PropCheck.forall(integer(), float()) { |x, y| ... } 28 | # ``` 29 | # 30 | # It is also possible (and recommended when having more than a few generators) to use a keyword-list 31 | # of generators instead: 32 | # 33 | # ``` 34 | # include PropCheck::Generators 35 | # PropCheck.forall(x: integer(), y: float()) { |x:, y:| ... } 36 | # ``` 37 | # 38 | # 39 | # If you do not pass a block right away, 40 | # a Property object is returned, which you can call the other instance methods 41 | # of this class on before finally passing a block to it using `#check`. 42 | # (so `forall(Generators.integer) do |val| ... end` and forall(Generators.integer).check do |val| ... end` are the same) 43 | def self.forall(*bindings, **kwbindings, &block) 44 | new(*bindings, **kwbindings) 45 | .check(&block) 46 | end 47 | 48 | ## 49 | # Returns the default configuration of the library as it is configured right now 50 | # for introspection. 51 | # 52 | # For the configuration of a single property, check its `configuration` instance method. 53 | # See PropCheck::Property::Configuration for more info on available settings. 54 | def self.configuration 55 | @configuration ||= Configuration.new 56 | end 57 | 58 | ## 59 | # Yields the library's configuration object for you to alter. 60 | # See PropCheck::Property::Configuration for more info on available settings. 61 | def self.configure 62 | yield(configuration) 63 | end 64 | 65 | def initialize(*bindings, **kwbindings) 66 | @config = self.class.configuration 67 | @hooks = PropCheck::Hooks.new 68 | 69 | @gen = gen_from_bindings(bindings, kwbindings) unless bindings.empty? && kwbindings.empty? 70 | freeze 71 | end 72 | 73 | # [:condition, :config, :hooks, :gen].each do |symbol| 74 | # define_method(symbol) do 75 | # self.instance_variable_get("@#{symbol}") 76 | # end 77 | 78 | # protected define_method("#{symbol}=") do |value| 79 | # duplicate = self.dup 80 | # duplicate.instance_variable_set("@#{symbol}", value) 81 | # duplicate 82 | # end 83 | 84 | ## 85 | # Returns the configuration of this property 86 | # for introspection. 87 | # 88 | # See PropCheck::Property::Configuration for more info on available settings. 89 | def configuration 90 | @config 91 | end 92 | 93 | ## 94 | # Allows you to override the configuration of this property 95 | # by giving a hash with new settings. 96 | # 97 | # If no other changes need to occur before you want to check the property, 98 | # you can immediately pass a block to this method. 99 | # (so `forall(a: Generators.integer).with_config(verbose: true) do ... end` is the same as `forall(a: Generators.integer).with_config(verbose: true).check do ... end`) 100 | def with_config(**config, &block) 101 | duplicate = dup 102 | duplicate.instance_variable_set(:@config, @config.merge(config)) 103 | duplicate.freeze 104 | 105 | duplicate.check(&block) 106 | end 107 | 108 | ## 109 | # Resizes all generators in this property with the given function. 110 | # 111 | # Shorthand for manually wrapping `PropCheck::Property::Configuration.resize_function` with the new function. 112 | def resize(&block) 113 | raise '#resize called without a block' unless block_given? 114 | 115 | orig_fun = @config.resize_function 116 | with_config(resize_function: block) 117 | end 118 | 119 | ## 120 | # Resizes all generators in this property. The new size is `2.pow(orig_size)` 121 | # 122 | # c.f. #resize 123 | def growing_exponentially(&block) 124 | orig_fun = @config.resize_function 125 | fun = proc { |size| 2.pow(orig_fun.call(size)) } 126 | with_config(resize_function: fun, &block) 127 | end 128 | 129 | ## 130 | # Resizes all generators in this property. The new size is `orig_size * orig_size` 131 | # 132 | # c.f. #resize 133 | def growing_quadratically(&block) 134 | orig_fun = @config.resize_function 135 | fun = proc { |size| orig_fun.call(size).pow(2) } 136 | with_config(resize_function: fun, &block) 137 | end 138 | 139 | ## 140 | # Resizes all generators in this property. The new size is `2 * orig_size` 141 | # 142 | # c.f. #resize 143 | def growing_fast(&block) 144 | orig_fun = @config.resize_function 145 | fun = proc { |size| orig_fun.call(size) * 2 } 146 | with_config(resize_function: fun, &block) 147 | end 148 | 149 | ## 150 | # Resizes all generators in this property. The new size is `0.5 * orig_size` 151 | # 152 | # c.f. #resize 153 | def growing_slowly(&block) 154 | orig_fun = @config.resize_function 155 | fun = proc { |size| orig_fun.call(size) * 0.5 } 156 | with_config(resize_function: fun, &block) 157 | end 158 | 159 | ## 160 | # Resizes all generators in this property. The new size is `Math.log2(orig_size)` 161 | # 162 | # c.f. #resize 163 | def growing_logarithmically(&block) 164 | orig_fun = @config.resize_function 165 | fun = proc { |size| Math.log2(orig_fun.call(size)) } 166 | with_config(resize_function: fun, &block) 167 | end 168 | 169 | def with_bindings(*bindings, **kwbindings) 170 | raise ArgumentError, 'No bindings specified!' if bindings.empty? && kwbindings.empty? 171 | 172 | duplicate = dup 173 | duplicate.instance_variable_set(:@gen, gen_from_bindings(bindings, kwbindings)) 174 | duplicate.freeze 175 | duplicate 176 | end 177 | 178 | ## 179 | # filters the generator using the given `condition`. 180 | # The final property checking block will only be run if the condition is truthy. 181 | # 182 | # If wanted, multiple `where`-conditions can be specified on a property. 183 | # Be aware that if you filter away too much generated inputs, 184 | # you might encounter a GeneratorExhaustedError. 185 | # Only filter if you have few inputs to reject. Otherwise, improve your generators. 186 | def where(&condition) 187 | unless @gen 188 | raise ArgumentError, 189 | 'No generator bindings specified! #where should be called after `#forall` or `#with_bindings`.' 190 | end 191 | 192 | duplicate = dup 193 | duplicate.instance_variable_set(:@gen, @gen.where(&condition)) 194 | duplicate.freeze 195 | duplicate 196 | end 197 | 198 | ## 199 | # Calls `hook` before each time a check is run with new data. 200 | # 201 | # This is useful to add setup logic 202 | # When called multiple times, earlier-added hooks will be called _before_ `hook` is called. 203 | def before(&hook) 204 | duplicate = dup 205 | duplicate.instance_variable_set(:@hooks, @hooks.add_before(&hook)) 206 | duplicate.freeze 207 | duplicate 208 | end 209 | 210 | ## 211 | # Calls `hook` after each time a check is run with new data. 212 | # 213 | # This is useful to add teardown logic 214 | # When called multiple times, earlier-added hooks will be called _after_ `hook` is called. 215 | def after(&hook) 216 | duplicate = dup 217 | duplicate.instance_variable_set(:@hooks, @hooks.add_after(&hook)) 218 | duplicate.freeze 219 | duplicate 220 | end 221 | 222 | ## 223 | # Calls `hook` around each time a check is run with new data. 224 | # 225 | # `hook` should `yield` to the passed block. 226 | # 227 | # When called multiple times, earlier-added hooks will be wrapped _around_ `hook`. 228 | # 229 | # Around hooks will be called after all `#before` hooks 230 | # and before all `#after` hooks. 231 | # 232 | # Note that if the block passed to `hook` raises an exception, 233 | # it is possible for the code after `yield` not to be called. 234 | # So make sure that cleanup logic is wrapped with the `ensure` keyword. 235 | def around(&hook) 236 | duplicate = dup 237 | duplicate.instance_variable_set(:@hooks, @hooks.add_around(&hook)) 238 | duplicate.freeze 239 | duplicate 240 | end 241 | 242 | ## 243 | # Checks the property (after settings have been altered using the other instance methods in this class.) 244 | def check(&block) 245 | return self unless block_given? 246 | 247 | n_runs = 0 248 | n_successful = 0 249 | 250 | # Loop stops at first exception 251 | attempts_enum(@gen).each do |generator_result| 252 | n_runs += 1 253 | check_attempt(generator_result, n_successful, &block) 254 | n_successful += 1 255 | end 256 | 257 | ensure_not_exhausted!(n_runs) 258 | end 259 | 260 | private def gen_from_bindings(bindings, kwbindings) 261 | if bindings == [] && kwbindings != {} 262 | PropCheck::Generators.fixed_hash(**kwbindings) 263 | elsif bindings != [] && kwbindings == {} 264 | if bindings.size == 1 265 | bindings.first 266 | else 267 | PropCheck::Generators.tuple(*bindings) 268 | end 269 | else 270 | raise ArgumentError, 271 | 'Attempted to use both normal and keyword bindings at the same time. 272 | This is not supported because of the separation of positional and keyword arguments 273 | (the old behaviour is deprecated in Ruby 2.7 and will be removed in 3.0) 274 | c.f. https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/ 275 | ' 276 | end 277 | end 278 | 279 | private def ensure_not_exhausted!(n_runs) 280 | return if n_runs >= @config.n_runs 281 | 282 | raise_generator_exhausted! 283 | end 284 | 285 | private def raise_generator_exhausted! 286 | raise Errors::GeneratorExhaustedError, ''" 287 | Could not perform `n_runs = #{@config.n_runs}` runs, 288 | (exhausted #{@config.max_generate_attempts} tries) 289 | because too few generator results were adhering to 290 | the `where` condition. 291 | 292 | Try refining your generators instead. 293 | "'' 294 | end 295 | 296 | private def check_attempt(generator_result, n_successful, &block) 297 | PropCheck::Helper.call_splatted(generator_result.root, &block) 298 | 299 | # immediately stop (without shrinnking) for when the app is asked 300 | # to close by outside intervention 301 | rescue SignalException, SystemExit 302 | raise 303 | 304 | # We want to capture _all_ exceptions (even low-level ones) here, 305 | # so we can shrink to find their cause. 306 | # don't worry: they all get reraised 307 | rescue Exception => e 308 | output, shrunken_result, shrunken_exception, n_shrink_steps = show_problem_output(e, generator_result, 309 | n_successful, &block) 310 | output_string = output.is_a?(StringIO) ? output.string : e.message 311 | 312 | e.define_singleton_method :prop_check_info do 313 | { 314 | original_input: generator_result.root, 315 | original_exception_message: e.message, 316 | shrunken_input: shrunken_result, 317 | shrunken_exception: shrunken_exception, 318 | n_successful: n_successful, 319 | n_shrink_steps: n_shrink_steps 320 | } 321 | end 322 | 323 | raise e, output_string, e.backtrace 324 | end 325 | 326 | private def attempts_enum(binding_generator) 327 | @hooks 328 | .wrap_enum(raw_attempts_enum(binding_generator)) 329 | .lazy 330 | .take(@config.n_runs) 331 | end 332 | 333 | private def raw_attempts_enum(binding_generator) 334 | rng = Random.new 335 | size = 1 336 | (0...@config.max_generate_attempts) 337 | .lazy 338 | .map do 339 | generator_size = @config.resize_function.call(size).to_i 340 | binding_generator.generate( 341 | size: generator_size, 342 | rng: rng, 343 | max_consecutive_attempts: @config.max_consecutive_attempts, 344 | config: @config 345 | ) 346 | end 347 | .map do |result| 348 | size += 1 349 | 350 | result 351 | end 352 | end 353 | 354 | private def show_problem_output(problem, generator_results, n_successful, &block) 355 | output = @config.verbose ? STDOUT : StringIO.new 356 | output = PropCheck::Property::OutputFormatter.pre_output(output, n_successful, generator_results.root, problem) 357 | shrunken_result, shrunken_exception, n_shrink_steps = shrink(generator_results, output, &block) 358 | output = PropCheck::Property::OutputFormatter.post_output(output, n_shrink_steps, shrunken_result, 359 | shrunken_exception) 360 | 361 | [output, shrunken_result, shrunken_exception, n_shrink_steps] 362 | end 363 | 364 | private def shrink(bindings_tree, io, &block) 365 | PropCheck::Property::Shrinker.call(bindings_tree, io, @hooks, @config, &block) 366 | end 367 | end 368 | end 369 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PropCheck 2 | 3 | PropCheck allows you to do Property Testing in Ruby. 4 | 5 | [![Gem](https://img.shields.io/gem/v/prop_check.svg)](https://rubygems.org/gems/prop_check) 6 | [![Ruby RSpec tests build status](https://github.com/Qqwy/ruby-prop_check/actions/workflows/run_tests.yaml/badge.svg)](https://github.com/Qqwy/ruby-prop_check/actions/workflows/run_tests.yaml) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/71897f5e6193a5124a53/maintainability)](https://codeclimate.com/github/Qqwy/ruby-prop_check/maintainability) 8 | [![RubyDoc](https://img.shields.io/badge/%F0%9F%93%9ARubyDoc-documentation-informational.svg)](https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/) 9 | 10 | It features: 11 | 12 | - Generators for most common Ruby datatypes. 13 | - An easy DSL to define your own generators (by combining existing ones, as well as completely custom ones). 14 | - Shrinking to a minimal counter-example on failure. 15 | - Hooks to perform extra set-up/cleanup logic before/after every example case. 16 | 17 | It requires _no_ external dependencies, and integrates well with all common test frameworks (see below). 18 | 19 | ## What is PropCheck? 20 | 21 | PropCheck is a Ruby library to create unit tests which are simpler to write and more powerful when run, finding edge-cases in your code you wouldn't have thought to look for. 22 | 23 | It works by letting you write tests that assert that something should be true for _every_ case, rather than just the ones you happen to think of. 24 | 25 | 26 | A normal unit test looks something like the following: 27 | 28 | 1. Set up some data. 29 | 2. Perform some operations on the data. 30 | 3. Assert something about the result 31 | 32 | PropCheck lets you write tests which instead look like this: 33 | 34 | 1. For all data matching some specification. 35 | 2. Perform some operations on the data. 36 | 3. Assert something about the result. 37 | 38 | This is often called property-based testing. It was popularised by the Haskell library [QuickCheck](https://hackage.haskell.org/package/QuickCheck). 39 | PropCheck takes further inspiration from Erlang's [PropEr](https://hex.pm/packages/proper), Elixir's [StreamData](https://hex.pm/packages/stream_data) and Python's [Hypothesis](https://hypothesis.works/). 40 | 41 | It works by generating arbitrary data matching your specification and checking that your assertions still hold in that case. If it finds an example where they do not, it takes that example and shrinks it down, simplifying it to find the smallest example that still causes the problem. 42 | 43 | Writing these kinds of tests usually consists of deciding on guarantees that your code should have -- properties that should always hold true, regardless of wat the world throws at you. Some examples are: 44 | 45 | - Your code should never crash. 46 | - If you remove an object, you can no longer see it 47 | - If you serialize and then deserialize a value, you get the same value back. 48 | 49 | 50 | ## Implemented and still missing features 51 | 52 | Before releasing v1.0, we want to finish the following: 53 | 54 | - [x] Finalize the testing DSL. 55 | - [x] Testing the library itself (against known 'true' axiomatically correct Ruby code.) 56 | - [x] Customization of common settings 57 | - [x] Filtering generators. 58 | - [x] Customize the max. of samples to run. 59 | - [x] Stop after a ludicrous amount of generator runs, to prevent malfunctioning (infinitely looping) generators from blowing up someone's computer. 60 | - [x] Look into customization of settings from e.g. command line arguments. 61 | - [x] Good, unicode-compliant, string generators. 62 | - [x] Filtering generator outputs. 63 | - [x] Before/after/around hooks to add setup/teardown logic to be called before/after/around each time a check is run with new data. 64 | - [x] Possibility to resize generators. 65 | - [x] `#instance` generator to allow the easy creation of generators for custom datatypes. 66 | - [x] Builtin generation of `Set`s 67 | - [x] Builtin generation of `Date`s, `Time`s and `DateTime`s. 68 | - [x] Configuration option to resize all generators given to a particular Property instance. 69 | - [x] A simple way to create recursive generators 70 | - [ ] A usage guide. 71 | 72 | ## Nice-to-haves 73 | 74 | - Stateful property testing. If implemented at some point, will probably happen in a separate add-on library. 75 | 76 | 77 | ## Installation 78 | 79 | Add this line to your application's Gemfile: 80 | 81 | ```ruby 82 | gem 'prop_check' 83 | ``` 84 | 85 | And then execute: 86 | 87 | $ bundle 88 | 89 | Or install it yourself as: 90 | 91 | $ gem install prop_check 92 | 93 | ## Usage 94 | 95 | 96 | ### Using PropCheck for basic testing 97 | 98 | Propcheck exposes the `forall` method. 99 | It takes any number of generators as arguments (or keyword arguments), as well as a block to run. 100 | The value(s) generated from the generator(s) passed to the `forall` will be given to the block as arguments. 101 | 102 | Raise an exception from the block if there is a problem. If there is no problem, just return normally. 103 | 104 | ```ruby 105 | G = PropCheck::Generators 106 | # testing that Enumerable#sort sorts in ascending order 107 | PropCheck.forall(G.array(G.integer)) do |numbers| 108 | sorted_numbers = numbers.sort 109 | 110 | # Check that no number is smaller than the previous number 111 | sorted_numbers.each_cons(2) do |former, latter| 112 | raise "Elements are not sorted! #{latter} is < #{former}" if latter > former 113 | end 114 | end 115 | ``` 116 | 117 | 118 | Here is another example, using it inside a test case. 119 | Here we check if `naive_average` indeed always returns an integer for all arrays of numbers we can pass it: 120 | 121 | ```ruby 122 | # Somewhere you have this function definition: 123 | def naive_average(array) 124 | array.sum / array.length 125 | end 126 | ``` 127 | 128 | The test case, using RSpec: 129 | ``` ruby 130 | require 'rspec' 131 | 132 | RSpec.describe "#naive_average" do 133 | G = PropCheck::Generators 134 | 135 | it "returns an integer for any input" do 136 | PropCheck.forall(G.array(G.integer)) do |numbers| 137 | result = naive_average(numbers) 138 | 139 | expect(result).to be_a(Integer) 140 | end 141 | end 142 | end 143 | ``` 144 | 145 | The test case, using MiniTest: 146 | ``` ruby 147 | require 'minitest/autorun' 148 | class NaiveAverageTest < MiniTest::Unit::TestCase 149 | G = PropCheck::Generators 150 | 151 | def test_that_it_returns_an_integer_for_any_input() 152 | PropCheck.forall(G.array(G.integer)) do |numbers| 153 | result = naive_average(numbers) 154 | 155 | assert_instance_of(Integer, result) 156 | end 157 | end 158 | end 159 | ``` 160 | 161 | The test case, using test-unit: 162 | ``` ruby 163 | require "test-unit" 164 | 165 | class TestNaiveAverage < Test::Unit::TestCase 166 | G = PropCheck::Generators 167 | 168 | def test_that_it_returns_an_integer_for_any_input 169 | PropCheck.forall(G.array(G.integer)) do |numbers| 170 | result = naive_average(numbers) 171 | 172 | assert_instance_of(Integer, result) 173 | end 174 | end 175 | end 176 | ``` 177 | 178 | The test case, using only vanilla Ruby: 179 | ```ruby 180 | # And then in a test case: 181 | G = PropCheck::Generators 182 | 183 | PropCheck.forall(G.array(G.integer)) do |numbers| 184 | result = naive_average(numbers) 185 | 186 | raise "Expected the average to be an integer!" unless result.is_a?(Integer) 187 | end 188 | ``` 189 | 190 | When running this particular example PropCheck very quickly finds out that we have made a programming mistake: 191 | ```ruby 192 | ZeroDivisionError: 193 | (after 6 successful property test runs) 194 | Failed on: 195 | `{ 196 | :numbers => [] 197 | }` 198 | 199 | Exception message: 200 | --- 201 | divided by 0 202 | --- 203 | 204 | (shrinking impossible) 205 | --- 206 | ``` 207 | 208 | Clearly we forgot to handle the case of an empty array being passed to the function. 209 | This is a good example of the kind of conceptual bugs that PropCheck (and property-based testing in general) 210 | are able to check for. 211 | 212 | 213 | #### Shrinking 214 | 215 | When a failure is found, PropCheck will re-run the block given to `forall` to test 216 | 'smaller' inputs, in an attempt to give you a minimal counter-example, 217 | from which the problem can be easily understood. 218 | 219 | For instance, when a failure happens with the input `x = 100`, 220 | PropCheck will see if the failure still happens with `x = 50`. 221 | If it does , it will try `x = 25`. If not, it will try `x = 75`, and so on. 222 | 223 | This means for example that if something only goes for wrong for `x >= 8`, the program will try: 224 | - `x = 100`(fails), 225 | - `x = 50`(fails), 226 | - `x = 25`(fails), 227 | - `x = 12`(fails), 228 | - `x = 6`(succeeds), `x = 9` (fails) 229 | - `x = 7`(succeeds), `x = 8` (fails). 230 | 231 | and thus the simplified case of `x = 8` is shown in the output. 232 | 233 | The documentation of the provided generators explain how they shrink. 234 | A short summary: 235 | - Integers shrink to numbers closer to zero. 236 | - Negative integers also attempt their positive alternative. 237 | - Floats shrink similarly to integers. 238 | - Arrays and hashes shrink to fewer elements, as well as shrinking their elements. 239 | - Strings shrink to shorter strings, as well as characters earlier in their alphabet. 240 | 241 | ### Builtin Generators 242 | 243 | PropCheck comes with [many builtin generators in the PropCheck::Generators](https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators) module. 244 | 245 | It contains generators for: 246 | - (any, positive, negative, etc.) integers, 247 | - (any, only real-valued) floats, 248 | - (any, printable only, alphanumeric only, etc) strings and symbols 249 | - fixed-size arrays and hashes 250 | - as well as varying-size arrays, hashes and sets. 251 | - dates, times, datetimes. 252 | - and many more! 253 | 254 | It is common and recommended to set up a module alias by using `G = PropCheck::Generators` in e.g. your testing-suite files to be able to refer to all of them. 255 | _(Earlier versions of the library recommended including the module instead. But this will make it very simple to accidentally shadow a generator with a local variable named `float` or `array` and similar.)_ 256 | 257 | ### Writing Custom Generators 258 | 259 | As described in the previous section, PropCheck already comes bundled with a bunch of common generators. 260 | 261 | However, you can easily adapt them to generate your own datatypes: 262 | 263 | #### Generators#constant / Generator#wrap 264 | 265 | Always returns the given value. No shrinking. 266 | 267 | #### Generator#map 268 | 269 | Allows you to take the result of one generator and transform it into something else. 270 | 271 | >> G.choose(32..128).map(&:chr).sample(1, size: 10, rng: Random.new(42)) 272 | => ["S"] 273 | 274 | #### Generator#bind 275 | 276 | Allows you to create one or another generator conditionally on the output of another generator. 277 | 278 | >> G.integer.bind { |a| G.integer.bind { |b| G.constant([a , b]) } }.sample(1, size: 100, rng: Random.new(42) 279 | => [[2, 79]] 280 | 281 | This is an advanced feature. Often, you can use a combination of `Generators.tuple` and `Generator#map` instead: 282 | 283 | >> G.tuple(G.integer, G.integer).sample(1, size: 100, rng: Random.new(42)) 284 | => [[2, 79]] 285 | 286 | #### Generators.one_of 287 | 288 | Useful if you want to be able to generate a value to be one of multiple possibilities: 289 | 290 | 291 | >> G.one_of(G.constant(true), G.constant(false)).sample(5, size: 10, rng: Random.new(42)) 292 | => [true, false, true, true, true] 293 | 294 | (Note that for this example, you can also use `G.boolean`. The example happens to show how it is implemented under the hood.) 295 | 296 | #### Generators.frequency 297 | 298 | If `one_of` does not give you enough flexibility because you want some results to be more common than others, 299 | you can use `Generators.frequency` which takes a hash of (integer_frequency => generator) keypairs. 300 | 301 | >> G.frequency(5 => G.integer, 1 => G.printable_ascii_char).sample(size: 10, rng: Random.new(42)) 302 | => [4, -3, 10, 8, 0, -7, 10, 1, "E", 10] 303 | 304 | #### Others 305 | 306 | There are even more functions in the `Generator` class and the `Generators` module that you might want to use, 307 | although above are the most generally useful ones. 308 | 309 | [PropCheck::Generator documentation](https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generator) 310 | [PropCheck::Generators documentation](https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators) 311 | 312 | 313 | ## Usage within Rails / with a database 314 | 315 | Using PropCheck for unit tests in a Rails, Sinatra, Hanami, etc. project is very easy. 316 | Here are some simple recommendations for the best results: 317 | - Tests that do not need to use the DB at all are usually 10x-100x faster. Faster tests means that you can configure PropCheck to do more test runs. 318 | - If you do need to use the database, use the [database_cleaner](https://github.com/DatabaseCleaner/database_cleaner) gem, preferibly with the fast `:transaction` strategy if your RDBMS supports it. To make sure the DB is cleaned around each generated example, you can write the following helper: 319 | ``` ruby 320 | # Version of PropCheck.forall 321 | # which ensures records persisted to the DB in one generated example 322 | # do not affect any other 323 | def forall_with_db(*args, **kwargs, &block) 324 | PropCheck.forall(*args, **kwargs) 325 | .before { DatabaseCleaner.start } 326 | .after { DatabaseCleaner.clean } 327 | .check(&block) 328 | end 329 | ``` 330 | - Other setup/cleanup should also usually happen around each generated example rather than around the whole test: Instead of using the hooks exposed by RSpec/MiniTest/test-unit/etc., use the before/after/around hooks exposed by PropCheck. 331 | 332 | ## Development 333 | 334 | After checking out the repo, use the [just](https://github.com/casey/just) command runner for common tasks: 335 | 336 | - `just setup`: Installs dev dependencies 337 | - `just test`: Runs the test suite 338 | - `just console`: Opens an IRb console with the gem loaded for experimenting. 339 | - `just install`: Install the gem on your local machine. 340 | - `just release`: Create and push a new release to the git repo and Rubygems. (Be sure to increase the version number in `version.rb` first!) 341 | 342 | ## Contributing 343 | 344 | Bug reports and pull requests are welcome on GitHub at https://github.com/Qqwy/ruby-prop_check . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 345 | 346 | ## License 347 | 348 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 349 | 350 | ## Code of Conduct 351 | 352 | Everyone interacting in the PropCheck project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/Qqwy/ruby-prop_check/blob/main/CODE_OF_CONDUCT.md). 353 | 354 | ## Attribution and Thanks 355 | 356 | I want to thank the original creators of QuickCheck (Koen Claessen, John Hughes) as well as the authors of many great property testing libraries that I was/am able to use as inspiration. 357 | I also want to greatly thank Thomasz Kowal who made me excited about property based testing [with his great talk about stateful property testing](https://www.youtube.com/watch?v=q0wZzFUYCuM), 358 | as well as Fred Herbert for his great book [Property-Based Testing with PropEr, Erlang and Elixir](https://propertesting.com/) which is really worth the read (regardless of what language you are using). 359 | 360 | The implementation and API of PropCheck takes a lot of inspiration from the following projects: 361 | 362 | - Haskell's [QuickCheck](https://hackage.haskell.org/package/QuickCheck) and [Hedgehog](https://hackage.haskell.org/package/hedgehog); 363 | - Erlang's [PropEr](https://hex.pm/packages/proper); 364 | - Elixir's [StreamData](https://hex.pm/packages/stream_data); 365 | - Python's [Hypothesis](https://hypothesis.works/). 366 | -------------------------------------------------------------------------------- /lib/prop_check/generators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require 'prop_check/generator' 5 | require 'prop_check/lazy_tree' 6 | module PropCheck 7 | ## 8 | # Contains common generators. 9 | # Use this module by including it in the class (e.g. in your test suite) 10 | # where you want to use them. 11 | module Generators 12 | module_function 13 | 14 | ## 15 | # Always returns the same value, regardless of `size` or `rng` (random number generator state) 16 | # 17 | # No shrinking (only considers the current single value `val`). 18 | # 19 | # >> Generators.constant("pie").sample(5, size: 10, rng: Random.new(42)) 20 | # => ["pie", "pie", "pie", "pie", "pie"] 21 | def constant(val) 22 | Generator.wrap(val) 23 | end 24 | 25 | private def integer_shrink(val) 26 | # 0 cannot shrink further; base case 27 | return [] if val.zero? 28 | 29 | # Numbers are shrunken by 30 | # subtracting themselves, their half, quarter, eight, ... (rounded towards zero!) 31 | # from themselves, until the number itself is reached. 32 | # So: for 20 we have [0, 10, 15, 18, 19, 20] 33 | halvings = 34 | Helper 35 | .scanl(val) { |x| (x / 2.0).truncate } 36 | .take_while { |x| !x.zero? } 37 | .map { |x| val - x } 38 | .map { |x| LazyTree.new(x, integer_shrink(x)) } 39 | 40 | # For negative numbers, we also attempt if the positive number has the same result. 41 | if val.abs > val 42 | [LazyTree.new(val.abs, halvings)].lazy 43 | else 44 | halvings 45 | end 46 | end 47 | 48 | ## 49 | # Returns a random integer in the given range (if a range is given) 50 | # or between 0..num (if a single integer is given). 51 | # 52 | # Does not scale when `size` changes. 53 | # This means `choose` is useful for e.g. picking an element out of multiple possibilities, 54 | # but for other purposes you probably want to use `integer` et co. 55 | # 56 | # Shrinks to integers closer to zero. 57 | # 58 | # >> r = Random.new(42); Generators.choose(0..5).sample(size: 10, rng: r) 59 | # => [3, 4, 2, 4, 4, 1, 2, 2, 2, 4] 60 | # >> r = Random.new(42); Generators.choose(0..5).sample(size: 20000, rng: r) 61 | # => [3, 4, 2, 4, 4, 1, 2, 2, 2, 4] 62 | def choose(range) 63 | Generator.new do |rng:, **| 64 | val = rng.rand(range) 65 | LazyTree.new(val, integer_shrink(val)) 66 | end 67 | end 68 | 69 | ## 70 | # A random integer which scales with `size`. 71 | # Integers start small (around 0) 72 | # and become more extreme (both higher and lower, negative) when `size` increases. 73 | # 74 | # 75 | # Shrinks to integers closer to zero. 76 | # 77 | # >> Generators.integer.call(size: 2, rng: Random.new(42)) 78 | # => 1 79 | # >> Generators.integer.call(size: 10000, rng: Random.new(42)) 80 | # => 5795 81 | # >> r = Random.new(42); Generators.integer.sample(size: 20000, rng: r) 82 | # => [-4205, -19140, 18158, -8716, -13735, -3150, 17194, 1962, -3977, -18315] 83 | def integer 84 | Generator.new do |size:, rng:, **| 85 | ensure_proper_size!(size) 86 | 87 | val = rng.rand(-size..size) 88 | LazyTree.new(val, integer_shrink(val)) 89 | end 90 | end 91 | 92 | private def ensure_proper_size!(size) 93 | return if size.is_a?(Integer) && size >= 0 94 | 95 | raise ArgumentError, "`size:` should be a nonnegative integer but got `#{size.inspect}`" 96 | end 97 | 98 | ## 99 | # Only returns integers that are zero or larger. 100 | # See `integer` for more information. 101 | def nonnegative_integer 102 | integer.map(&:abs) 103 | end 104 | 105 | ## 106 | # Only returns integers that are larger than zero. 107 | # See `integer` for more information. 108 | def positive_integer 109 | nonnegative_integer.map { |x| x + 1 } 110 | end 111 | 112 | ## 113 | # Only returns integers that are zero or smaller. 114 | # See `integer` for more information. 115 | def nonpositive_integer 116 | nonnegative_integer.map(&:-@) 117 | end 118 | 119 | ## 120 | # Only returns integers that are smaller than zero. 121 | # See `integer` for more information. 122 | def negative_integer 123 | positive_integer.map(&:-@) 124 | end 125 | 126 | private def fraction(num_a, num_b, num_c) 127 | num_a.to_f + num_b.to_f / (num_c.to_f.abs + 1.0) 128 | end 129 | 130 | ## 131 | # Generates floating-point numbers 132 | # These start small (around 0) 133 | # and become more extreme (large positive and large negative numbers) 134 | # 135 | # Will only generate 'reals', 136 | # that is: no infinity, no NaN, 137 | # no numbers testing the limits of floating-point arithmetic. 138 | # 139 | # Shrinks towards zero. 140 | # The shrinking strategy also moves towards 'simpler' floats (like `1.0`) from 'complicated' floats (like `3.76543`). 141 | # 142 | # >> Generators.real_float().sample(10, size: 10, rng: Random.new(42)) 143 | # => [-2.2, -0.2727272727272727, 4.0, 1.25, -3.7272727272727275, -8.833333333333334, -8.090909090909092, 1.1428571428571428, 0.0, 8.0] 144 | def real_float 145 | tuple(integer, integer, integer).map do |a, b, c| 146 | fraction(a, b, c) 147 | end 148 | end 149 | 150 | ## 151 | # Generates any real floating-point numbers, 152 | # but will never generate zero. 153 | # c.f. #real_float 154 | # 155 | # >> Generators.real_nonzero_float().sample(10, size: 10, rng: Random.new(43)) 156 | # => [-7.25, 7.125, -7.636363636363637, -3.0, -8.444444444444445, -6.857142857142857, 2.4545454545454546, 3.0, -7.454545454545455, -6.25] 157 | def real_nonzero_float 158 | real_float.where { |val| val != 0.0 } 159 | end 160 | 161 | ## 162 | # Generates real floating-point numbers which are never negative. 163 | # Shrinks towards 0 164 | # c.f. #real_float 165 | # 166 | # >> Generators.real_nonnegative_float().sample(10, size: 10, rng: Random.new(43)) 167 | # => [7.25, 7.125, 7.636363636363637, 3.0, 8.444444444444445, 0.0, 6.857142857142857, 2.4545454545454546, 3.0, 7.454545454545455] 168 | def real_nonnegative_float 169 | real_float.map(&:abs) 170 | end 171 | 172 | ## 173 | # Generates real floating-point numbers which are never positive. 174 | # Shrinks towards 0 175 | # c.f. #real_float 176 | # 177 | # >> Generators.real_nonpositive_float().sample(10, size: 10, rng: Random.new(44)) 178 | # => [-9.125, -2.3636363636363638, -8.833333333333334, -1.75, -8.4, -2.4, -3.5714285714285716, -1.0, -6.111111111111111, -4.0] 179 | def real_nonpositive_float 180 | real_nonnegative_float.map(&:-@) 181 | end 182 | 183 | ## 184 | # Generates real floating-point numbers which are always positive 185 | # Shrinks towards Float::MIN 186 | # 187 | # Does not consider denormals. 188 | # c.f. #real_float 189 | # 190 | # >> Generators.real_positive_float().sample(10, size: 10, rng: Random.new(42)) 191 | # => [2.2, 0.2727272727272727, 4.0, 1.25, 3.7272727272727275, 8.833333333333334, 8.090909090909092, 1.1428571428571428, 2.2250738585072014e-308, 8.0] 192 | def real_positive_float 193 | real_nonnegative_float.map { |val| val + Float::MIN } 194 | end 195 | 196 | ## 197 | # Generates real floating-point numbers which are always negative 198 | # Shrinks towards -Float::MIN 199 | # 200 | # Does not consider denormals. 201 | # c.f. #real_float 202 | # 203 | # >> Generators.real_negative_float().sample(10, size: 10, rng: Random.new(42)) 204 | # => [-2.2, -0.2727272727272727, -4.0, -1.25, -3.7272727272727275, -8.833333333333334, -8.090909090909092, -1.1428571428571428, -2.2250738585072014e-308, -8.0] 205 | def real_negative_float 206 | real_positive_float.map(&:-@) 207 | end 208 | 209 | @@special_floats = [Float::NAN, 210 | Float::INFINITY, 211 | -Float::INFINITY, 212 | Float::MAX, 213 | -Float::MAX, 214 | Float::MIN, 215 | -Float::MIN, 216 | Float::EPSILON, 217 | -Float::EPSILON, 218 | 0.0.next_float, 219 | 0.0.prev_float] 220 | ## 221 | # Generates floating-point numbers 222 | # Will generate NaN, Infinity, -Infinity, 223 | # as well as Float::EPSILON, Float::MAX, Float::MIN, 224 | # 0.0.next_float, 0.0.prev_float, 225 | # to test the handling of floating-point edge cases. 226 | # Approx. 1/50 generated numbers is a special one. 227 | # 228 | # Shrinks to smaller, real floats. 229 | # >> Generators.float().sample(10, size: 10, rng: Random.new(42)) 230 | # >> Generators.float().sample(10, size: 10, rng: Random.new(4)) 231 | # => [-8.0, 2.0, 2.7142857142857144, -4.0, -10.2, -6.666666666666667, -Float::INFINITY, -10.2, 2.1818181818181817, -6.2] 232 | def float 233 | frequency(49 => real_float, 1 => one_of(*@@special_floats.map(&method(:constant)))) 234 | end 235 | 236 | ## 237 | # Generates any nonzero floating-point number. 238 | # Will generate special floats (except NaN) from time to time. 239 | # c.f. #float 240 | def nonzero_float 241 | float.where { |val| val != 0.0 && val } 242 | end 243 | 244 | ## 245 | # Generates nonnegative floating point numbers 246 | # Will generate special floats (except NaN) from time to time. 247 | # c.f. #float 248 | def nonnegative_float 249 | float.map(&:abs).where { |val| val != Float::NAN } 250 | end 251 | 252 | ## 253 | # Generates nonpositive floating point numbers 254 | # Will generate special floats (except NaN) from time to time. 255 | # c.f. #float 256 | def nonpositive_float 257 | nonnegative_float.map(&:-@) 258 | end 259 | 260 | ## 261 | # Generates positive floating point numbers 262 | # Will generate special floats (except NaN) from time to time. 263 | # c.f. #float 264 | def positive_float 265 | nonnegative_float.where { |val| val != 0.0 && val } 266 | end 267 | 268 | ## 269 | # Generates negative floating point numbers 270 | # Will generate special floats (except NaN) from time to time. 271 | # c.f. #float 272 | def negative_float 273 | positive_float.map(&:-@).where { |val| val != 0.0 } 274 | end 275 | 276 | ## 277 | # Picks one of the given generators in `choices` at random uniformly every time. 278 | # 279 | # Shrinks to values earlier in the list of `choices`. 280 | # 281 | # >> Generators.one_of(Generators.constant(true), Generators.constant(false)).sample(5, size: 10, rng: Random.new(42)) 282 | # => [true, false, true, true, true] 283 | def one_of(*choices) 284 | choose(choices.length).bind do |index| 285 | choices[index] 286 | end 287 | end 288 | 289 | ## 290 | # Picks one of the choices given in `frequencies` at random every time. 291 | # `frequencies` expects keys to be numbers 292 | # (representing the relative frequency of this generator) 293 | # and values to be generators. 294 | # 295 | # Side note: If you want to use the same frequency number for multiple generators, 296 | # Ruby syntax requires you to send an array of two-element arrays instead of a hash. 297 | # 298 | # Shrinks to arbitrary elements (since hashes are not ordered). 299 | # 300 | # >> Generators.frequency(5 => Generators.integer, 1 => Generators.printable_ascii_char).sample(size: 10, rng: Random.new(42)) 301 | # => [4, -3, 10, 8, 0, -7, 10, 1, "E", 10] 302 | def frequency(frequencies) 303 | choices = frequencies.reduce([]) do |acc, elem| 304 | freq, val = elem 305 | acc + ([val] * freq) 306 | end 307 | one_of(*choices) 308 | end 309 | 310 | ## 311 | # Generates an array containing always exactly one value from each of the passed generators, 312 | # in the same order as specified: 313 | # 314 | # Shrinks element generators, one at a time (trying last one first). 315 | # 316 | # >> Generators.tuple(Generators.integer, Generators.real_float).call(size: 10, rng: Random.new(42)) 317 | # => [-4, 13.0] 318 | def tuple(*generators) 319 | Generator.new do |**kwargs| 320 | LazyTree.zip(generators.map do |generator| 321 | generator.generate(**kwargs) 322 | end) 323 | end 324 | end 325 | 326 | ## 327 | # Given a `hash` where the values are generators, 328 | # creates a generator that returns hashes 329 | # with the same keys, and their corresponding values from their corresponding generators. 330 | # 331 | # Shrinks element generators. 332 | # 333 | # >> Generators.fixed_hash(a: Generators.integer(), b: Generators.real_float(), c: Generators.integer()).call(size: 10, rng: Random.new(42)) 334 | # => {:a=>-4, :b=>13.0, :c=>-3} 335 | def fixed_hash(hash) 336 | keypair_generators = 337 | hash.map do |key, generator| 338 | generator.map { |val| [key, val] } 339 | end 340 | 341 | tuple(*keypair_generators) 342 | .map(&:to_h) 343 | end 344 | 345 | ## 346 | # Generates an array of elements, where each of the elements 347 | # is generated by `element_generator`. 348 | # 349 | # Shrinks to shorter arrays (with shrunken elements). 350 | # Accepted keyword arguments: 351 | # 352 | # `empty:` When false, behaves the same as `min: 1` 353 | # `min:` Ensures at least this many elements are generated. (default: 0) 354 | # `max:` Ensures at most this many elements are generated. When nil, an arbitrary count is used instead. (default: nil) 355 | # `uniq:` When `true`, ensures that all elements in the array are unique. 356 | # When given a proc, uses the result of this proc to check for uniqueness. 357 | # (matching the behaviour of `Array#uniq`) 358 | # If it is not possible to generate another unique value after the configured `max_consecutive_attempts` 359 | # an `PropCheck::Errors::GeneratorExhaustedError` will be raised. 360 | # (default: `false`) 361 | # 362 | # 363 | # >> Generators.array(Generators.positive_integer).sample(5, size: 1, rng: Random.new(42)) 364 | # => [[2], [2], [2], [1], [2]] 365 | # >> Generators.array(Generators.positive_integer).sample(5, size: 10, rng: Random.new(42)) 366 | # => [[10, 5, 1, 4], [5, 9, 1, 1, 11, 8, 4, 9, 11, 10], [6], [11, 11, 2, 2, 7, 2, 6, 5, 5], [2, 10, 9, 7, 9, 5, 11, 3]] 367 | # 368 | # >> Generators.array(Generators.positive_integer, empty: true).sample(5, size: 1, rng: Random.new(1)) 369 | # => [[], [2], [], [], [2]] 370 | # >> Generators.array(Generators.positive_integer, empty: false).sample(5, size: 1, rng: Random.new(1)) 371 | # => [[2], [1], [2], [1], [1]] 372 | # 373 | # >> Generators.array(Generators.boolean, uniq: true).sample(5, rng: Random.new(1)) 374 | # => [[true, false], [false, true], [true, false], [false, true], [false, true]] 375 | 376 | def array(element_generator, min: 0, max: nil, empty: true, uniq: false) 377 | min = 1 if min.zero? && !empty 378 | uniq = proc { |x| x } if uniq == true 379 | 380 | if max.nil? 381 | nonnegative_integer.bind { |count| make_array(element_generator, min, count, uniq) } 382 | else 383 | choose(min..max).bind { |count| make_array(element_generator, min, count, uniq) } 384 | end 385 | end 386 | 387 | private def make_array(element_generator, min, count, uniq) 388 | amount = min if min > (count - min) 389 | amount ||= (count - min) 390 | 391 | # Simple, optimized implementation: 392 | return make_array_simple(element_generator, amount) unless uniq 393 | 394 | # More complex implementation that filters duplicates 395 | make_array_uniq(element_generator, min, amount, uniq) 396 | end 397 | 398 | private def make_array_simple(element_generator, amount) 399 | generators = amount.times.map do 400 | element_generator.clone 401 | end 402 | 403 | tuple(*generators) 404 | end 405 | 406 | private def make_array_uniq(element_generator, min, amount, uniq_fun) 407 | Generator.new do |**kwargs| 408 | arr = [] 409 | uniques = Set.new 410 | count = 0 411 | 412 | if amount == 0 413 | LazyTree.new([]) 414 | else 415 | 0.step.lazy.map do 416 | elem = element_generator.clone.generate(**kwargs) 417 | if uniques.add?(uniq_fun.call(elem.root)) 418 | arr.push(elem) 419 | count = 0 420 | else 421 | count += 1 422 | end 423 | 424 | if count > kwargs[:max_consecutive_attempts] 425 | if arr.size >= min 426 | # Give up and return shorter array in this case 427 | amount = min 428 | else 429 | raise Errors::GeneratorExhaustedError, "Too many consecutive elements filtered by 'uniq:'." 430 | end 431 | end 432 | end 433 | .take_while { arr.size < amount } 434 | .force 435 | 436 | LazyTree.zip(arr).map { |array| array.uniq(&uniq_fun) } 437 | end 438 | end 439 | end 440 | 441 | ## 442 | # Generates a set of elements, where each of the elements 443 | # is generated by `element_generator`. 444 | # 445 | # Shrinks to smaller sets (with shrunken elements). 446 | # Accepted keyword arguments: 447 | # 448 | # `empty:` When false, behaves the same as `min: 1` 449 | # `min:` Ensures at least this many elements are generated. (default: 0) 450 | # `max:` Ensures at most this many elements are generated. When nil, an arbitrary count is used instead. (default: nil) 451 | # 452 | # In the set, elements are always unique. 453 | # If it is not possible to generate another unique value after the configured `max_consecutive_attempts` 454 | # a `PropCheck::Errors::GeneratorExhaustedError` will be raised. 455 | # 456 | # >> Generators.set(Generators.positive_integer).sample(5, size: 4, rng: Random.new(42)) 457 | # => [Set[2, 4], Set[], Set[3, 4], Set[], Set[4]] 458 | def set(element_generator, min: 0, max: nil, empty: true) 459 | array(element_generator, min: min, max: max, empty: empty, uniq: true).map(&:to_set) 460 | end 461 | 462 | ## 463 | # Generates a hash of key->values, 464 | # where each of the keys is made using the `key_generator` 465 | # and each of the values using the `value_generator`. 466 | # 467 | # Shrinks to hashes with less key/value pairs. 468 | # 469 | # >> Generators.hash(Generators.printable_ascii_string, Generators.positive_integer).sample(5, size: 3, rng: Random.new(42)) 470 | # => [{""=>2, "g\\4"=>4, "rv"=>2}, {"7"=>2}, {"!"=>1, "E!"=>1}, {"kY5"=>2}, {}] 471 | def hash(*args, **kwargs) 472 | if args.length == 2 473 | hash_of(*args, **kwargs) 474 | else 475 | super 476 | end 477 | end 478 | 479 | ## 480 | # 481 | # Alias for `#hash` that does not conflict with a possibly overridden `Object#hash`. 482 | # 483 | def hash_of(key_generator, value_generator, **kwargs) 484 | array(tuple(key_generator, value_generator), **kwargs) 485 | .map(&:to_h) 486 | end 487 | 488 | @@alphanumeric_chars = [('a'..'z'), ('A'..'Z'), ('0'..'9')].flat_map(&:to_a).freeze 489 | ## 490 | # Generates a single-character string 491 | # containing one of a..z, A..Z, 0..9 492 | # 493 | # Shrinks towards lowercase 'a'. 494 | # 495 | # >> Generators.alphanumeric_char.sample(5, size: 10, rng: Random.new(42)) 496 | # => ["M", "Z", "C", "o", "Q"] 497 | def alphanumeric_char 498 | one_of(*@@alphanumeric_chars.map(&method(:constant))) 499 | end 500 | 501 | ## 502 | # Generates a string 503 | # containing only the characters a..z, A..Z, 0..9 504 | # 505 | # Shrinks towards fewer characters, and towards lowercase 'a'. 506 | # 507 | # >> Generators.alphanumeric_string.sample(5, size: 10, rng: Random.new(42)) 508 | # => ["ZCoQ", "8uM", "wkkx0JNx", "v0bxRDLb", "Gl5v8RyWA6"] 509 | # 510 | # Accepts the same options as `array` 511 | def alphanumeric_string(**kwargs) 512 | array(alphanumeric_char, **kwargs).map(&:join) 513 | end 514 | 515 | @@printable_ascii_chars = (' '..'~').to_a.freeze 516 | 517 | ## 518 | # Generates a single-character string 519 | # from the printable ASCII character set. 520 | # 521 | # Shrinks towards ' '. 522 | # 523 | # >> Generators.printable_ascii_char.sample(size: 10, rng: Random.new(42)) 524 | # => ["S", "|", ".", "g", "\\", "4", "r", "v", "j", "j"] 525 | def printable_ascii_char 526 | one_of(*@@printable_ascii_chars.map(&method(:constant))) 527 | end 528 | 529 | ## 530 | # Generates strings 531 | # from the printable ASCII character set. 532 | # 533 | # Shrinks towards fewer characters, and towards ' '. 534 | # 535 | # >> Generators.printable_ascii_string.sample(5, size: 10, rng: Random.new(42)) 536 | # => ["S|.g", "rvjjw7\"5T!", "=", "!_[4@", "Y"] 537 | # 538 | # Accepts the same options as `array` 539 | def printable_ascii_string(**kwargs) 540 | array(printable_ascii_char, **kwargs).map(&:join) 541 | end 542 | 543 | @@ascii_chars = [ 544 | @@printable_ascii_chars, 545 | [ 546 | "\n", 547 | "\r", 548 | "\t", 549 | "\v", 550 | "\b", 551 | "\f", 552 | "\e", 553 | "\d", 554 | "\a" 555 | ] 556 | ].flat_map(&:to_a).freeze 557 | 558 | ## 559 | # Generates a single-character string 560 | # from the printable ASCII character set. 561 | # 562 | # Shrinks towards '\n'. 563 | # 564 | # >> Generators.ascii_char.sample(size: 10, rng: Random.new(42)) 565 | # => ["d", "S", "|", ".", "g", "\\", "4", "d", "r", "v"] 566 | def ascii_char 567 | one_of(*@@ascii_chars.map(&method(:constant))) 568 | end 569 | 570 | ## 571 | # Generates strings 572 | # from the printable ASCII character set. 573 | # 574 | # Shrinks towards fewer characters, and towards '\n'. 575 | # 576 | # >> Generators.ascii_string.sample(5, size: 10, rng: Random.new(42)) 577 | # => ["S|.g", "drvjjw\b\a7\"", "!w=E!_[4@k", "x", "zZI{[o"] 578 | # 579 | # Accepts the same options as `array` 580 | def ascii_string(**kwargs) 581 | array(ascii_char, **kwargs).map(&:join) 582 | end 583 | 584 | @@printable_chars = [ 585 | @@ascii_chars, 586 | "\u{A0}".."\u{D7FF}", 587 | "\u{E000}".."\u{FFFD}", 588 | "\u{10000}".."\u{10FFFF}" 589 | ].flat_map(&:to_a).freeze 590 | 591 | ## 592 | # Generates a single-character printable string 593 | # both ASCII characters and Unicode. 594 | # 595 | # Shrinks towards characters with lower codepoints, e.g. ASCII 596 | # 597 | # >> Generators.printable_char.sample(size: 10, rng: Random.new(42)) 598 | # => ["吏", "", "", "", "", "", "", "", "", "Ȍ"] 599 | def printable_char 600 | one_of(*@@printable_chars.map(&method(:constant))) 601 | end 602 | 603 | ## 604 | # Generates a printable string 605 | # both ASCII characters and Unicode. 606 | # 607 | # Shrinks towards shorter strings, and towards characters with lower codepoints, e.g. ASCII 608 | # 609 | # >> Generators.printable_string.sample(5, size: 10, rng: Random.new(42)) 610 | # => ["", "Ȍ", "𐁂", "Ȕ", ""] 611 | # 612 | # Accepts the same options as `array` 613 | def printable_string(**kwargs) 614 | array(printable_char, **kwargs).map(&:join) 615 | end 616 | 617 | ## 618 | # Generates a single unicode character 619 | # (both printable and non-printable). 620 | # 621 | # Shrinks towards characters with lower codepoints, e.g. ASCII 622 | # 623 | # >> Generators.printable_char.sample(size: 10, rng: Random.new(42)) 624 | # => ["吏", "", "", "", "", "", "", "", "", "Ȍ"] 625 | def char 626 | choose(0..0x10FFFF).map do |num| 627 | [num].pack('U') 628 | end 629 | end 630 | 631 | ## 632 | # Generates a string of unicode characters 633 | # (which might contain both printable and non-printable characters). 634 | # 635 | # Shrinks towards characters with lower codepoints, e.g. ASCII 636 | # 637 | # >> Generators.string.sample(5, size: 10, rng: Random.new(42)) 638 | # => ["\u{A3DB3}𠍜\u{3F46A}\u{1AEBC}", "􍙦𡡹󴇒\u{DED74}𪱣\u{43E97}ꂂ\u{50695}􏴴\u{C0301}", "\u{4FD9D}", "\u{C14BF}\u{193BB}𭇋󱣼\u{76B58}", "𦐺\u{9FDDB}\u{80ABB}\u{9E3CF}𐂽\u{14AAE}"] 639 | # 640 | # Accepts the same options as `array` 641 | def string(**kwargs) 642 | array(char, **kwargs).map(&:join) 643 | end 644 | 645 | ## 646 | # Generates either `true` or `false` 647 | # 648 | # Shrinks towards `false` 649 | # 650 | # >> Generators.boolean.sample(5, size: 10, rng: Random.new(42)) 651 | # => [false, true, false, false, false] 652 | def boolean 653 | one_of(constant(false), constant(true)) 654 | end 655 | 656 | ## 657 | # Generates always `nil`. 658 | # 659 | # Does not shrink. 660 | # 661 | # >> Generators.nil.sample(5, size: 10, rng: Random.new(42)) 662 | # => [nil, nil, nil, nil, nil] 663 | def nil 664 | constant(nil) 665 | end 666 | 667 | ## 668 | # Generates `nil` or `false`. 669 | # 670 | # Shrinks towards `nil`. 671 | # 672 | # >> Generators.falsey.sample(5, size: 10, rng: Random.new(42)) 673 | # => [nil, false, nil, nil, nil] 674 | def falsey 675 | one_of(constant(nil), constant(false)) 676 | end 677 | 678 | ## 679 | # Generates symbols consisting of lowercase letters and potentially underscores. 680 | # 681 | # Shrinks towards shorter symbols and the letter 'a'. 682 | # 683 | # >> Generators.simple_symbol.sample(5, size: 10, rng: Random.new(42)) 684 | # => [:tokh, :gzswkkxudh, :vubxlfbu, :lzvlyq__jp, :oslw] 685 | def simple_symbol 686 | alphabet = ('a'..'z').to_a 687 | alphabet << '_' 688 | array(one_of(*alphabet.map(&method(:constant)))) 689 | .map(&:join) 690 | .map(&:to_sym) 691 | end 692 | 693 | ## 694 | # Generates common terms that are not `nil` or `false`. 695 | # 696 | # Shrinks towards simpler terms, like `true`, an empty array, a single character or an integer. 697 | # 698 | # >> Generators.truthy.sample(5, size: 2, rng: Random.new(42)) 699 | # => [[2], {:gz=>0, :""=>0}, [1.0, 0.5], 0.6666666666666667, {"𦐺\u{9FDDB}"=>1, ""=>1}] 700 | def truthy 701 | one_of(constant(true), 702 | constant([]), 703 | char, 704 | integer, 705 | float, 706 | string, 707 | array(integer), 708 | array(float), 709 | array(char), 710 | array(string), 711 | hash(simple_symbol, integer), 712 | hash(string, integer), 713 | hash(string, string)) 714 | end 715 | 716 | ## 717 | # Generates whatever `other_generator` generates 718 | # but sometimes instead `nil`.` 719 | # 720 | # >> Generators.nillable(Generators.integer).sample(20, size: 10, rng: Random.new(42)) 721 | # => [9, 10, 8, 0, 10, -3, -8, 10, 1, -9, -10, nil, 1, 6, nil, 1, 9, -8, 8, 10] 722 | def nillable(other_generator) 723 | frequency(9 => other_generator, 1 => constant(nil)) 724 | end 725 | 726 | ## 727 | # Generates `Date` objects. 728 | # DateTimes start around the given `epoch:` and deviate more when `size` increases. 729 | # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now.to_date`. 730 | # 731 | # >> Generators.date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) 732 | # => [Date.new(2021, 12, 28), Date.new(2022, 01, 10)] 733 | def date(epoch: nil) 734 | date_from_offset(integer, epoch: epoch) 735 | end 736 | 737 | ## 738 | # variant of #date that only generates dates in the future (relative to `:epoch`). 739 | # 740 | # >> Generators.future_date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) 741 | # => [Date.new(2022, 01, 06), Date.new(2022, 01, 11)] 742 | def future_date(epoch: Date.today) 743 | date_from_offset(positive_integer, epoch: epoch) 744 | end 745 | 746 | ## 747 | # variant of #date that only generates dates in the past (relative to `:epoch`). 748 | # 749 | # >> Generators.past_date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) 750 | # => [Date.new(2021, 12, 27), Date.new(2021, 12, 22)] 751 | def past_date(epoch: Date.today) 752 | date_from_offset(negative_integer, epoch: epoch) 753 | end 754 | 755 | private def date_from_offset(offset_gen, epoch:) 756 | if epoch 757 | offset_gen.map { |offset| Date.jd(epoch.jd + offset) } 758 | else 759 | offset_gen.with_config.map do |offset, config| 760 | epoch = config.default_epoch.to_date 761 | Date.jd(epoch.jd + offset) 762 | end 763 | end 764 | end 765 | 766 | ## 767 | # Generates `DateTime` objects. 768 | # DateTimes start around the given `epoch:` and deviate more when `size` increases. 769 | # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now`. 770 | # 771 | # >> PropCheck::Generators.datetime(epoch: Date.new(2022, 11, 20)).sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) 772 | # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000"), DateTime.parse("2022-11-19 05:27:16.363618076 +0000")] 773 | def datetime(epoch: nil) 774 | datetime_from_offset(real_float, epoch: epoch) 775 | end 776 | 777 | ## 778 | # alias for `#datetime`, for backwards compatibility. 779 | # Prefer using `datetime`! 780 | def date_time(epoch: nil) 781 | datetime(epoch: epoch) 782 | end 783 | 784 | ## 785 | # Variant of `#datetime` that only generates datetimes in the future (relative to `:epoch`). 786 | # 787 | # >> PropCheck::Generators.future_datetime(epoch: Date.new(2022, 11, 20)).sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new).map(&:inspect) 788 | # => ["#", "#"] 789 | def future_datetime(epoch: nil) 790 | datetime_from_offset(real_positive_float, epoch: epoch) 791 | end 792 | 793 | ## 794 | # Variant of `#datetime` that only generates datetimes in the past (relative to `:epoch`). 795 | # 796 | # >> PropCheck::Generators.past_datetime(epoch: Date.new(2022, 11, 20)).sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) 797 | # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000"), DateTime.parse("2022-11-19 05:27:16.363618076 +0000")] 798 | def past_datetime(epoch: nil) 799 | datetime_from_offset(real_negative_float, epoch: epoch) 800 | end 801 | 802 | ## 803 | # Generates `Time` objects. 804 | # Times start around the given `epoch:` and deviate more when `size` increases. 805 | # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now`. 806 | # 807 | # >> PropCheck::Generators.time(epoch: Date.new(2022, 11, 20)).sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) 808 | # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000").to_time, DateTime.parse("2022-11-19 05:27:16.363618076 +0000").to_time] 809 | def time(epoch: nil) 810 | datetime(epoch: epoch).map(&:to_time) 811 | end 812 | 813 | ## 814 | # Variant of `#time` that only generates datetimes in the future (relative to `:epoch`). 815 | def future_time(epoch: nil) 816 | future_datetime(epoch: epoch).map(&:to_time) 817 | end 818 | 819 | ## 820 | # Variant of `#time` that only generates datetimes in the past (relative to `:epoch`). 821 | def past_time(epoch: nil) 822 | past_datetime(epoch: epoch).map(&:to_time) 823 | end 824 | 825 | private def datetime_from_offset(offset_gen, epoch:) 826 | if epoch 827 | offset_gen.map { |offset| DateTime.jd(epoch.ajd + offset) } 828 | else 829 | offset_gen.with_config.map do |offset, config| 830 | epoch = config.default_epoch.to_date 831 | DateTime.jd(epoch.ajd + offset) 832 | end 833 | end 834 | end 835 | 836 | ## 837 | # Generates an instance of `klass` 838 | # using `args` and/or `kwargs` 839 | # as generators for the arguments that are passed to `klass.new` 840 | # 841 | # ## Example: 842 | # 843 | # Given a class like this: 844 | # 845 | # 846 | # class User 847 | # attr_accessor :name, :age 848 | # def initialize(name: , age: ) 849 | # @name = name 850 | # @age = age 851 | # end 852 | # 853 | # def inspect 854 | # "" 855 | # end 856 | # end 857 | # 858 | # >> user_gen = Generators.instance(User, name: Generators.printable_ascii_string, age: Generators.nonnegative_integer) 859 | # >> user_gen.sample(3, rng: Random.new(42)).inspect 860 | # => "[, , ]" 861 | def instance(klass, *args, **kwargs) 862 | tuple(*args).bind do |vals| 863 | fixed_hash(**kwargs).map do |kwvals| 864 | if kwvals == {} 865 | klass.new(*vals) 866 | elsif vals == [] 867 | klass.new(**kwvals) 868 | else 869 | klass.new(*vals, **kwvals) 870 | end 871 | end 872 | end 873 | end 874 | 875 | ## 876 | # Helper to build recursive generators 877 | # 878 | # Given a `leaf_generator` 879 | # and a block which: 880 | # - is given a generator that generates subtrees. 881 | # - it should return the generator for intermediate tree nodes. 882 | # 883 | # This is best explained with an example. 884 | # Say we want to generate a binary tree of integers. 885 | # 886 | # If we have a struct representing internal nodes: 887 | # ```ruby 888 | # Branch = Struct.new(:left, :right, keyword_init: true) 889 | # ``` 890 | # we can generate trees like so: 891 | # ```ruby 892 | # Generators.tree(Generators.integer) do |subtree_gen| 893 | # G.instance(Branch, left: subtree_gen, right: subtree_gen) 894 | # end 895 | # ``` 896 | # 897 | # As another example, consider generating lists of integers: 898 | # 899 | # >> G = PropCheck::Generators 900 | # >> G.tree(G.integer) {|child_gen| G.array(child_gen) }.sample(5, size: 37, rng: Random.new(42)) 901 | # => [[7, [2, 3], -10], [[-2], [-2, [3]], [[2, 3]]], [], [0, [-2, -3]], [[1], -19, [], [1, -1], [1], [-1, -1], [1]]] 902 | # 903 | # And finally, here is how one could create a simple generator for parsed JSON data: 904 | # 905 | # ```ruby` 906 | # G = PropCheck::Generators 907 | # def json 908 | # G.tree(G.one_of(G.boolean, G.real_float, G.ascii_string)) do |json_gen| 909 | # G.one_of(G.array(json_gen), G.hash_of(G.ascii_string, json_gen)) 910 | # end 911 | # end 912 | # ``` 913 | # 914 | def tree(leaf_generator, &block) 915 | # Implementation is based on 916 | # https://hexdocs.pm/stream_data/StreamData.html#tree/2 917 | Generator.new do |size:, rng:, **other_kwargs| 918 | nodes_on_each_level = random_pseudofactors(size.pow(1.1).to_i, rng) 919 | result = nodes_on_each_level.reduce(leaf_generator) do |subtree_generator, nodes_on_this_level| 920 | frequency(1 => subtree_generator, 921 | 2 => block.call(subtree_generator).resize { |_size| nodes_on_this_level }) 922 | end 923 | 924 | result.generate(size: size, rng: rng, **other_kwargs) 925 | end 926 | end 927 | 928 | private def random_pseudofactors(size, rng) 929 | return [size].to_enum if size < 2 930 | 931 | Enumerator.new do |yielder| 932 | loop do 933 | factor = rng.rand(1..(Math.log2(size).to_i)) 934 | if factor == 1 935 | yielder << size 936 | break 937 | else 938 | yielder << factor 939 | size /= factor 940 | end 941 | end 942 | end 943 | end 944 | end 945 | end 946 | --------------------------------------------------------------------------------