pbt 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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