pbt 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Check
5
+ # Configuration for Pbt.
6
+ Configuration = Struct.new(
7
+ :verbose,
8
+ :worker,
9
+ :num_runs,
10
+ :seed,
11
+ :thread_report_on_exception,
12
+ keyword_init: true
13
+ ) do
14
+ # @param verbose [Boolean] Whether to print verbose output. Default is `false`.
15
+ # @param worker [Symbol] The concurrency method to use. :ractor`, `:thread`, `:process` and `:none` are supported. Default is `:ractor`.
16
+ # @param num_runs [Integer] The number of runs to perform. Default is `100`.
17
+ # @param seed [Integer] The seed to use for random number generation. It's useful to reproduce failed test with the seed you'd pick up from failure messages. Default is a random seed.
18
+ # @param thread_report_on_exception [Boolean] Whether to report exceptions in threads. It's useful to suppress error logs on Ractor that reports many errors. Default is `false`.
19
+ def initialize(
20
+ verbose: false,
21
+ worker: :ractor,
22
+ num_runs: 100,
23
+ seed: Random.new.seed,
24
+ thread_report_on_exception: false
25
+ )
26
+ super
27
+ end
28
+ end
29
+
30
+ module ConfigurationMethods
31
+ # Return the current configuration.
32
+ # If you modify the configuration, it will affect all future property-based tests.
33
+ #
34
+ # @example
35
+ # config = Pbt.configuration
36
+ # config.num_runs = 20
37
+ #
38
+ # @return [Configuration]
39
+ def configuration
40
+ @configuration ||= Configuration.new
41
+ end
42
+
43
+ # Return the current configuration.
44
+ # If you modify the configuration, it will affect all future property-based tests.
45
+ #
46
+ # @example
47
+ # Pbt.configure do |config|
48
+ # config.num_runs = 20
49
+ # end
50
+ #
51
+ # @yield [configuration] The current configuration.
52
+ # @yieldparam configuration [Configuration]
53
+ def configure
54
+ yield(configuration)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Check
5
+ # Represents a property to be tested.
6
+ # This class holds an arbitrary to generate values and a predicate to test them.
7
+ class Property
8
+ # @param arb [Array<Arbitrary>]
9
+ # @param predicate [Proc] Predicate proc to test the generated values. Library users write this.
10
+ def initialize(arb, &predicate)
11
+ @arb = arb
12
+ @predicate = predicate
13
+ end
14
+
15
+ # Generate a next value to test.
16
+ #
17
+ # @param rng [Random] Random number generator.
18
+ # @return [Object]
19
+ def generate(rng)
20
+ @arb.generate(rng)
21
+ end
22
+
23
+ # Shrink the `val` to a smaller one.
24
+ # This is used to find the smallest failing case after a failure.
25
+ #
26
+ # @param val [Object]
27
+ # @return [Enumerator<Object>]
28
+ def shrink(val)
29
+ @arb.shrink(val)
30
+ end
31
+
32
+ # Run the predicate with the generated `val`.
33
+ #
34
+ # @param val [Object]
35
+ # @return [void]
36
+ def run(val)
37
+ @predicate.call(val)
38
+ end
39
+
40
+ # Run the predicate with the generated `val` in a Ractor.
41
+ # This is used only for parallel testing with Ractors.
42
+ #
43
+ # @param val [Object]
44
+ # @return [Ractor]
45
+ def run_in_ractor(val)
46
+ Ractor.new(val, &@predicate)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "pbt/reporter/run_execution"
5
+
6
+ module Pbt
7
+ module Check
8
+ # This class is an iterator of the generated/shrunken values.
9
+ #
10
+ # @private
11
+ # @!attribute [r] run_execution [Reporter::RunExecution]
12
+ class RunnerIterator < DelegateClass(Array)
13
+ attr_reader :run_execution
14
+
15
+ # @param source_values [Enumerator] Enumerator of generated values by arbitrary. This is used to determine initial N (=`num_runs`) cases.
16
+ # @param property [Property] Property to test. This is used to shrink the failed case.
17
+ # @param verbose [Boolean] Controls the verbosity of the output.
18
+ def initialize(source_values, property, verbose)
19
+ @run_execution = Reporter::RunExecution.new(verbose)
20
+ @property = property
21
+ @next_values = source_values
22
+ enumerator = Enumerator.new do |y|
23
+ loop do
24
+ y.yield @next_values.next
25
+ end
26
+ end
27
+ super(enumerator) # delegate `#each` and etc. to enumerator
28
+ end
29
+
30
+ # Check if there is a next value to test.
31
+ # If there is no next value, it returns false. Otherwise true.
32
+ #
33
+ # @return [Boolean]
34
+ def has_next?
35
+ @next_values.peek
36
+ true
37
+ rescue StopIteration
38
+ false
39
+ end
40
+
41
+ # Handle result of a test.
42
+ # When a test is successful, it records the success.
43
+ # When a test is failed, it records the failure and set up the next values to test with property#shrink.
44
+ #
45
+ # @param c [Case]
46
+ # @return [void]
47
+ def handle_result(c)
48
+ if c.exception
49
+ # failed run
50
+ @run_execution.record_failure(c)
51
+ @next_values = @property.shrink(c.val)
52
+ else
53
+ # successful run
54
+ @run_execution.record_success
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pbt/check/runner_iterator"
4
+ require "pbt/check/tosser"
5
+ require "pbt/check/case"
6
+ require "pbt/reporter/run_details_reporter"
7
+
8
+ module Pbt
9
+ module Check
10
+ # Module to be
11
+ module RunnerMethods
12
+ include Check::Tosser
13
+
14
+ # Run a property based test and report its result.
15
+ #
16
+ # @see Check::Configuration
17
+ # @param options [Hash] Optional parameters to customize the execution.
18
+ # @param property [Proc] Proc that returns Property instance.
19
+ # @return [void]
20
+ # @raise [PropertyFailure]
21
+ def assert(**options, &property)
22
+ out = check(**options, &property)
23
+ Reporter::RunDetailsReporter.new(out).report_run_details
24
+ end
25
+
26
+ # Run a property based test and return its result.
27
+ # This doesn't throw contrary to `assert`.
28
+ # Use `assert` unless you want to handle the result.
29
+ #
30
+ # @see RunnerMethods#assert
31
+ # @see Check::Configuration
32
+ # @param options [Hash] Optional parameters to customize the execution.
33
+ # @param property [Proc] Proc that returns Property instance.
34
+ # @return [RunDetails]
35
+ def check(**options, &property)
36
+ property = property.call
37
+ config = Pbt.configuration.to_h.merge(options.to_h)
38
+
39
+ initial_values = toss(property, config[:seed])
40
+ source_values = Enumerator.new(config[:num_runs]) do |y|
41
+ config[:num_runs].times do
42
+ y.yield initial_values.next
43
+ end
44
+ end
45
+
46
+ suppress_exception_report_for_ractor(config) do
47
+ run_it(property, source_values, config).to_run_details(config)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # If using Ractor, so many exception reports happen in Ractor and a console gets too messy. Suppress them to avoid that.
54
+ #
55
+ # @param config [Hash] Configuration parameters.
56
+ # @param block [Proc]
57
+ def suppress_exception_report_for_ractor(config, &block)
58
+ if config[:worker] == :ractor
59
+ original_report_on_exception = Thread.report_on_exception
60
+ Thread.report_on_exception = config[:thread_report_on_exception]
61
+ end
62
+
63
+ yield
64
+ ensure
65
+ Thread.report_on_exception = original_report_on_exception if config[:worker] == :ractor
66
+ end
67
+
68
+ # Run the property test for each value.
69
+ #
70
+ # @param property [Proc] Property to test.
71
+ # @param source_values [Enumerator] Enumerator of values to test.
72
+ # @param config [Hash] Configuration parameters.
73
+ # @return [RunExecution] Result of the test.
74
+ def run_it(property, source_values, config)
75
+ runner = Check::RunnerIterator.new(source_values, property, config[:verbose])
76
+ while runner.has_next?
77
+ case config[:worker]
78
+ in :ractor
79
+ run_it_in_ractors(property, runner)
80
+ in :process
81
+ run_it_in_processes(property, runner)
82
+ in :thread
83
+ run_it_in_threads(property, runner)
84
+ in :none
85
+ run_it_in_sequential(property, runner)
86
+ end
87
+ end
88
+ runner.run_execution
89
+ end
90
+
91
+ # @param property [Proc]
92
+ # @param runner [RunnerIterator]
93
+ # @return [void]
94
+ def run_it_in_ractors(property, runner)
95
+ runner.map.with_index { |val, index|
96
+ Case.new(val:, index:, ractor: property.run_in_ractor(val))
97
+ }.each do |c|
98
+ c.ractor.take
99
+ runner.handle_result(c)
100
+ rescue => e
101
+ c.exception = e.cause # Ractor error is wrapped in a Ractor::RemoteError. We need to get the cause.
102
+ runner.handle_result(c)
103
+ break # Ignore the rest of the cases. Just pick up the first failure.
104
+ end
105
+ end
106
+
107
+ # @param property [Proc]
108
+ # @param runner [RunnerIterator]
109
+ # @return [void]
110
+ def run_it_in_threads(property, runner)
111
+ require_parallel
112
+
113
+ Parallel.map_with_index(runner, in_threads: Parallel.processor_count) do |val, index|
114
+ Case.new(val:, index:).tap do |c|
115
+ property.run(val)
116
+ rescue => e
117
+ c.exception = e
118
+ # It's possible to break this loop here by raising `Parallel::Break`.
119
+ # But if it raises, we cannot fetch all cases' result. So this loop continues until the end.
120
+ end
121
+ end.each do |c|
122
+ runner.handle_result(c)
123
+ break if c.exception
124
+ # Ignore the rest of the cases. Just pick up the first failure.
125
+ end
126
+ end
127
+
128
+ # @param property [Proc]
129
+ # @param runner [RunnerIterator]
130
+ # @return [void]
131
+ def run_it_in_processes(property, runner)
132
+ require_parallel
133
+
134
+ Parallel.map_with_index(runner, in_processes: Parallel.processor_count) do |val, index|
135
+ Case.new(val:, index:).tap do |c|
136
+ property.run(val)
137
+ rescue => e
138
+ c.exception = e
139
+ # It's possible to break this loop here by raising `Parallel::Break`.
140
+ # But if it raises, we cannot fetch all cases' result. So this loop continues until the end.
141
+ end
142
+ end.each do |c|
143
+ runner.handle_result(c)
144
+ break if c.exception
145
+ # Ignore the rest of the cases. Just pick up the first failure.
146
+ end
147
+ end
148
+
149
+ # @param property [Proc]
150
+ # @param runner [RunnerIterator]
151
+ # @return [void]
152
+ def run_it_in_sequential(property, runner)
153
+ runner.each_with_index do |val, index|
154
+ c = Case.new(val:, index:)
155
+ begin
156
+ property.run(val)
157
+ runner.handle_result(c)
158
+ rescue => e
159
+ c.exception = e
160
+ runner.handle_result(c)
161
+ break # Ignore the rest of the cases. Just pick up the first failure.
162
+ end
163
+ end
164
+ end
165
+
166
+ # Load Parallel gem. If it's not installed, raise an error.
167
+ # @see https://github.com/grosser/parallel
168
+ # @raise [InvalidConfiguration]
169
+ def require_parallel
170
+ require "parallel"
171
+ rescue LoadError
172
+ raise InvalidConfiguration,
173
+ "Parallel gem (https://github.com/grosser/parallel) is required to use worker `:process` or `:thread`. Please add `gem 'parallel'` to your Gemfile."
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Check
5
+ # Module to be included in classes that need to generate values to test.
6
+ module Tosser
7
+ # Generate values.
8
+ #
9
+ # @param arb [Arbitrary] Arbitrary to generate a value.
10
+ # @param seed [Integer] Random number generator's seed.
11
+ # @return [Enumerator]
12
+ def toss(arb, seed)
13
+ Enumerator.new do |enum|
14
+ rng = Random.new(seed)
15
+ loop do
16
+ enum.yield toss_next(arb, rng)
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # Generate next value.
24
+ #
25
+ # @param arb [Arbitrary] Arbitrary to generate a value.
26
+ # @param rng [Random] Random number generator.
27
+ def toss_next(arb, rng)
28
+ arb.generate(rng)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Reporter
5
+ # Details of a single run of a property test.
6
+ RunDetails = Struct.new(
7
+ :failed,
8
+ :num_runs,
9
+ :num_shrinks,
10
+ :seed,
11
+ :counterexample,
12
+ :counterexample_path,
13
+ :error_message,
14
+ :error_instance,
15
+ :failures,
16
+ :verbose,
17
+ :run_configuration,
18
+ keyword_init: true
19
+ )
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Reporter
5
+ # Reporter for the run details of a property test.
6
+ class RunDetailsReporter
7
+ # @param run_details [Pbt::Reporter::RunExecution]
8
+ def initialize(run_details)
9
+ @run_details = run_details
10
+ end
11
+
12
+ # Report the run details of a property test.
13
+ # If the property test failed, raise a PropertyFailure.
14
+ #
15
+ # @raise [PropertyFailure]
16
+ def report_run_details
17
+ if @run_details.failed
18
+ message = []
19
+
20
+ message << <<~EOS
21
+ Property failed after #{@run_details.num_runs} test(s)
22
+ { seed: #{@run_details.seed} }
23
+ Counterexample: #{@run_details.counterexample}
24
+ Shrunk #{@run_details.num_shrinks} time(s)
25
+ Got #{@run_details.error_instance.class}: #{@run_details.error_message}
26
+ EOS
27
+
28
+ if @run_details.verbose
29
+ message << " \n#{@run_details.error_instance.backtrace_locations.join("\n ")}"
30
+ message << "\nEncountered failures were:"
31
+ message << @run_details.failures.map { |f| "- #{f.val}" }
32
+ end
33
+
34
+ raise PropertyFailure, message.join("\n") if message.size > 0
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pbt/reporter/run_details"
4
+
5
+ module Pbt
6
+ module Reporter
7
+ # Represents the result of a single run of a property test.
8
+ class RunExecution
9
+ # @param verbose [Boolean] Whether to print verbose output.
10
+ def initialize(verbose)
11
+ @verbose = verbose
12
+ @path_to_failure = []
13
+ @failure = nil
14
+ @failures = []
15
+ @num_successes = 0
16
+ end
17
+
18
+ # Record a failure in the run.
19
+ #
20
+ # @param c [Pbt::Check::Case]
21
+ def record_failure(c)
22
+ @path_to_failure << c.index
23
+ @failures << c
24
+
25
+ # value and failure can be updated through shrinking
26
+ @value = c.val
27
+ @failure = c.exception
28
+ end
29
+
30
+ # Record a successful run.
31
+ #
32
+ # @return [void]
33
+ def record_success
34
+ @num_successes += 1
35
+ end
36
+
37
+ # Whether the test was successful.
38
+ #
39
+ # @return [Boolean]
40
+ def success?
41
+ !@failure
42
+ end
43
+
44
+ # Convert execution to run details.
45
+ #
46
+ # @param config [Hash] Configuration parameters used for the run.
47
+ # @return [RunDetails] Details of the run.
48
+ def to_run_details(config)
49
+ if success?
50
+ RunDetails.new(
51
+ failed: false,
52
+ num_runs: @num_successes,
53
+ num_shrinks: 0,
54
+ seed: config[:seed],
55
+ counterexample: nil,
56
+ counterexample_path: nil,
57
+ error_message: nil,
58
+ error_instance: nil,
59
+ failures: @failures,
60
+ verbose: @verbose,
61
+ run_configuration: config
62
+ )
63
+ else
64
+ RunDetails.new(
65
+ failed: true,
66
+ num_runs: @path_to_failure[0] + 1,
67
+ num_shrinks: @path_to_failure.size - 1,
68
+ seed: config[:seed],
69
+ counterexample: @value,
70
+ counterexample_path: @path_to_failure.join(":"),
71
+ error_message: @failure.message,
72
+ error_instance: @failure,
73
+ failures: @failures,
74
+ verbose: @verbose,
75
+ run_configuration: config
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
data/lib/pbt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pbt
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/pbt.rb CHANGED
@@ -1,13 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pbt/version"
4
- require_relative "pbt/generator"
5
- require_relative "pbt/runner"
4
+ require_relative "pbt/arbitrary/arbitrary_methods"
5
+ require_relative "pbt/check/runner_methods"
6
+ require_relative "pbt/check/property"
7
+ require_relative "pbt/check/configuration"
6
8
 
7
9
  module Pbt
8
- class CaseFailure < StandardError; end
10
+ # Represents a property-based test failure.
11
+ class PropertyFailure < StandardError; end
9
12
 
10
- def self.forall(generator, &block)
11
- Runner.new(generator, &block).run
13
+ # Represents an invalid configuration.
14
+ class InvalidConfiguration < StandardError; end
15
+
16
+ extend Arbitrary::ArbitraryMethods
17
+ extend Check::RunnerMethods
18
+ extend Check::ConfigurationMethods
19
+
20
+ # Create a property-based test with arbitraries. To run the test, pass the returned value to `Pbt.assert` method.
21
+ # Be aware that using both positional and keyword arguments is not supported.
22
+ #
23
+ # @example Basic usage
24
+ # Pbt.property(Pbt.integer) do |n|
25
+ # # your test code here
26
+ # end
27
+ #
28
+ # @example Use multiple arbitraries
29
+ # Pbt.property(Pbt.string, Pbt.symbol) do |str, sym|
30
+ # # your test code here
31
+ # end
32
+ #
33
+ # @example Use hash arbitraries
34
+ # Pbt.property(x: Pbt.integer, y: Pbt.integer) do |x, y|
35
+ # # your test code here
36
+ # end
37
+ #
38
+ # @param args [Array<Arbitrary>] Arbitraries to generate values. You can pass one or more arbitraries.
39
+ # @param kwargs [Hash<Symbol,Arbitrary>] Arbitraries to generate values. You can pass arbitraries with keyword arguments.
40
+ # @param predicate [Proc] Test code that receives generated values and runs the test.
41
+ # @return [Property]
42
+ def self.property(*args, **kwargs, &predicate)
43
+ arb = to_arbitrary(args, kwargs)
44
+ Check::Property.new(arb, &predicate)
45
+ end
46
+
47
+ class << self
48
+ private
49
+
50
+ # Convert arguments to suitable arbitrary.
51
+ # If multiple arguments are given, wrap them by tuple arbitrary.
52
+ # If keyword arguments are given, wrap them by fixed hash arbitrary.
53
+ # Else, return the single arbitrary.
54
+ #
55
+ # @param args [Array<Arbitrary>]
56
+ # @param kwargs [Hash<Symbol,Arbitrary>]
57
+ # @return [Arbitrary]
58
+ # @raise [ArgumentError] When both positional and keyword arguments are given
59
+ def to_arbitrary(args, kwargs)
60
+ if args == [] && kwargs != {}
61
+ fixed_hash(kwargs)
62
+ elsif args != [] && kwargs == {}
63
+ # wrap by tuple arbitrary so that property class doesn't have to take care of an array
64
+ (args.size == 1) ? args.first : tuple(*args)
65
+ else
66
+ raise ArgumentError, <<~MSG
67
+ It's not supported to use both positional and keyword arguments at the same time.
68
+ cf. https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
69
+ MSG
70
+ end
71
+ end
12
72
  end
13
73
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pbt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ohbarye
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-01-27 00:00:00.000000000 Z
11
+ date: 2024-04-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -25,8 +25,27 @@ files:
25
25
  - README.md
26
26
  - Rakefile
27
27
  - lib/pbt.rb
28
- - lib/pbt/generator.rb
29
- - lib/pbt/runner.rb
28
+ - lib/pbt/arbitrary/arbitrary.rb
29
+ - lib/pbt/arbitrary/arbitrary_methods.rb
30
+ - lib/pbt/arbitrary/array_arbitrary.rb
31
+ - lib/pbt/arbitrary/choose_arbitrary.rb
32
+ - lib/pbt/arbitrary/constant.rb
33
+ - lib/pbt/arbitrary/constant_arbitrary.rb
34
+ - lib/pbt/arbitrary/filter_arbitrary.rb
35
+ - lib/pbt/arbitrary/fixed_hash_arbitrary.rb
36
+ - lib/pbt/arbitrary/integer_arbitrary.rb
37
+ - lib/pbt/arbitrary/map_arbitrary.rb
38
+ - lib/pbt/arbitrary/one_of_arbitrary.rb
39
+ - lib/pbt/arbitrary/tuple_arbitrary.rb
40
+ - lib/pbt/check/case.rb
41
+ - lib/pbt/check/configuration.rb
42
+ - lib/pbt/check/property.rb
43
+ - lib/pbt/check/runner_iterator.rb
44
+ - lib/pbt/check/runner_methods.rb
45
+ - lib/pbt/check/tosser.rb
46
+ - lib/pbt/reporter/run_details.rb
47
+ - lib/pbt/reporter/run_details_reporter.rb
48
+ - lib/pbt/reporter/run_execution.rb
30
49
  - lib/pbt/version.rb
31
50
  - sig/pbt.rbs
32
51
  homepage: https://github.com/ohbarye/pbt
data/lib/pbt/generator.rb DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pbt
4
- module Generator
5
- def self.integer(low = nil, high = nil)
6
- rng = Random.new
7
- size = 100
8
- if low && high
9
- -> { rng.rand(low..high) }
10
- else
11
- -> { rng.rand(-size..size) }
12
- end
13
- end
14
- end
15
- end