moarspec 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cbe02b386d8bb5096462151db51d932f06787af44b10f3710113ca27ce107d91
4
+ data.tar.gz: 8329a3c29236662afa9614d31094389d13e2ddcc1611b1873dc2c48fa8053b50
5
+ SHA512:
6
+ metadata.gz: '000817d751bd76bd08cdb817f66f40fb57f93ac39cdf7c666c06fde4837aed7d3df21924e85a79a4932d79bece239315cdc50b3db201ab07a87876847de6132f'
7
+ data.tar.gz: 4424ee10f7129fe955efb375d50607eb95ac291e58afda48f2f2425c2ca3657e0a37f10c00973239e15a5b1426aebe622aef064c499dc9aad46b5003597fb147
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014-15 Victor 'Zverok' Shepelev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,15 @@
1
+ RSpec:
2
+ Language:
3
+ Examples:
4
+ Regular:
5
+ - its_block
6
+ - its_call
7
+ - its_map
8
+ Skipped:
9
+ - xits_block
10
+ - xits_call
11
+ - xits_map
12
+ Focused:
13
+ - fits_block
14
+ - fits_call
15
+ - fits_map
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module ExampleGroups
5
+ # Provides a shorter way to define a context and its `let` values in one statement.
6
+ #
7
+ # @example
8
+ # subject { x + y }
9
+ #
10
+ # instant_context 'with numeric values', lets: {x: 1, y: 2} do
11
+ # it { is_expected.to eq 3 }
12
+ # end
13
+ # # or, without an explicit description:
14
+ # instant_context lets: {x: 1, y: 2} do
15
+ # it { is_expected.to eq 3 }
16
+ # end
17
+ #
18
+ # # is equivalent to
19
+ #
20
+ # context 'with numeric values (x=1, y=2)' do
21
+ # let(:x) { 1 }
22
+ # let(:y) { 2 }
23
+ #
24
+ # it { is_expected.to eq 3 }
25
+ # end
26
+ #
27
+ # # without explicit description, it is equivalent to
28
+ # context 'with x=1, y=2' do
29
+ # # ...
30
+ # end
31
+ #
32
+ # See also {Moarspec::Its::With#it_with #it_with} for a way to define just one example with
33
+ # its `let`s.
34
+ module InstantContext
35
+ def instant_context(description = nil, lets:, **metadata, &block)
36
+ full_description = "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')}"
37
+ full_description = "#{description} (#{full_description})" if description
38
+ absolute_path, line_number = caller_locations.first.then { [_1.absolute_path, _1.lineno] }
39
+
40
+ context full_description, **metadata do
41
+ # Tricking RSpec to think this context was defined where `instant_context` was called,
42
+ # so `rspec that_spec.rb:123` knew it is related
43
+ self.metadata.merge!(absolute_file_path: absolute_path, line_number: line_number)
44
+
45
+ lets.each do |name, val|
46
+ let(name) { val }
47
+ end
48
+ instance_eval(&block)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ RSpec.configure do |rspec|
56
+ rspec.extend Moarspec::ExampleGroups::InstantContext
57
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/example_groups/instant_context}
58
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ # Wrapper module for all RSpec additions that adjust example groups (`context`) creation.
5
+ #
6
+ # ## {InstantContext#instant_context #instant_context}
7
+ #
8
+ # ```ruby
9
+ # subject { x + y }
10
+ #
11
+ # instant_context 'with numeric values', lets: {x: 1, y: 2} do
12
+ # it { is_expected.to eq 3 }
13
+ # end
14
+ # ```
15
+ #
16
+ module ExampleGroups
17
+ end
18
+ end
19
+
20
+ require_relative 'example_groups/instant_context'
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Its
5
+ module Block
6
+ # Creates nested example that redefines implicit `is_expected` to use subject as a block.
7
+ #
8
+ # @example
9
+ #
10
+ # subject { calc_something(params) }
11
+ #
12
+ # # without its_block
13
+ # context 'with this params' do
14
+ # it { expect { subject }.to change(some, :value).by(1) }
15
+ # end
16
+ #
17
+ # context 'with that params' do
18
+ # it { expect { subject }.to raise_error(SomeError) }
19
+ # end
20
+ #
21
+ # # with its_block
22
+ # context 'with this params' do
23
+ # its_block { is_expected.to change(some, :value).by(1) }
24
+ # end
25
+ #
26
+ # context 'with that params' do
27
+ # its_block { is_expected.to raise_error(SomeError) }
28
+ # end
29
+ #
30
+ # @param options Options (metadata) that can be passed to usual RSpec example.
31
+ # @param block [Proc] The test itself. Inside it, `is_expected` is a synonom
32
+ # for `expect { subject }`.
33
+ #
34
+ def its_block(*options, &block)
35
+ # rubocop:disable Lint/NestedMethodDefinition
36
+ describe('as block') do
37
+ # FIXME: Not necessary? (Previously, wrapped the subject in lambda, now just repeats it)
38
+ let(:__call_subject) do
39
+ subject
40
+ end
41
+
42
+ def is_expected
43
+ expect { __call_subject }
44
+ end
45
+
46
+ example(nil, *options, &block)
47
+ end
48
+ # rubocop:enable Lint/NestedMethodDefinition
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ RSpec.configure do |rspec|
55
+ rspec.extend Moarspec::Its::Block
56
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/its/block}
57
+ end
58
+
59
+ RSpec::SharedContext.include Moarspec::Its::Block
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Its
5
+ module BlockWith
6
+ # Creates a nested context + example with `let` values defined from the arguments, and
7
+ # the subject treated as a block.
8
+ #
9
+ # @example
10
+ #
11
+ # subject { x + y }
12
+ #
13
+ # its_block_with(x: 1, y: nil) { is_expected.to raise_error }
14
+ #
15
+ # # is equivalent to
16
+ #
17
+ # context "with x=1, y=2" do
18
+ # let(:x) { 1 }
19
+ # let(:y) { nil }
20
+ #
21
+ # it { expect { subject }.to raise_error }
22
+ # end
23
+ #
24
+ def its_block_with(**lets, &block)
25
+ context "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')} as block" do
26
+ lets.each do |name, val|
27
+ let(name) { val }
28
+ end
29
+
30
+ def is_expected # rubocop:disable Lint/NestedMethodDefinition
31
+ expect { subject }
32
+ end
33
+
34
+ example(nil, &block)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ RSpec.configure do |rspec|
42
+ rspec.extend Moarspec::Its::BlockWith
43
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/its/block_with}
44
+ end
45
+
46
+ RSpec::SharedContext.include Moarspec::Its::BlockWith
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Its
5
+ module Call
6
+ # For `#call`-able subject, creates nested example where subject is called with arguments
7
+ # provided, allowing to apply block matchers like `.to change(something)` or `.to raise_error`
8
+ # to different calls in a DRY way.
9
+ #
10
+ # Also, plays really well with {RSpec::Matchers#ret #ret} block matcher.
11
+ #
12
+ # @example
13
+ # let(:array) { %i[a b c] }
14
+ #
15
+ # describe '#[]' do
16
+ # subject { array.method(:[]) }
17
+ #
18
+ # its_call(1) { is_expected.to ret :b }
19
+ # its_call(1..-1) { is_expected.to ret %i[b c] }
20
+ # its_call('foo') { is_expected.to raise_error TypeError }
21
+ # end
22
+ #
23
+ # describe '#push' do
24
+ # subject { array.method(:push) }
25
+ # its_call(5) { is_expected.to change(array, :length).by(1) }
26
+ # end
27
+ #
28
+ def its_call(*args, **kwargs, &block)
29
+ # rubocop:disable Lint/NestedMethodDefinition
30
+ describe("(#{args.map(&:inspect).join(', ')})") do
31
+ let(:__call_subject) do
32
+ subject.call(*args, **kwargs)
33
+ end
34
+
35
+ def is_expected
36
+ expect { __call_subject }
37
+ end
38
+
39
+ example(nil, &block)
40
+ end
41
+ # rubocop:enable Lint/NestedMethodDefinition
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ RSpec.configure do |rspec|
48
+ rspec.extend Moarspec::Its::Call
49
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/its/call}
50
+ end
51
+
52
+ RSpec::SharedContext.include Moarspec::Its::Call
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Its
5
+ module Map
6
+ # Creates nested example which has current subject mapped
7
+ # by specified attribute as its subject.
8
+ #
9
+ # @example
10
+ #
11
+ # # with attribute
12
+ # subject { %w[test me please] }
13
+ # its_map(:length) { is_expected.to eq [4, 2, 6] }
14
+ #
15
+ # # with attribute chain
16
+ # its_map('reverse.upcase') { is_expected.to eq %w[TSET EM ESAELP] }
17
+ #
18
+ # # with Hash (or any other object responding to `#[]`)
19
+ # subject {
20
+ # [
21
+ # {title: 'Slaughterhouse Five', author: {first: 'Kurt', last: 'Vonnegut'}},
22
+ # {title: 'Hitchhickers Guide To The Galaxy', author: {first: 'Duglas', last: 'Adams'}}
23
+ # ]
24
+ # }
25
+ # its_map([:title]) { are_expected.to eq ['Slaughterhouse Five', 'Hitchhickers Guide To The Galaxy'] }
26
+ # # multiple attributes for nested hashes
27
+ # its_map([:author, :last]) { are_expected.to eq ['Vonnegut', 'Adams'] }
28
+ #
29
+ # @param attribute [String, Symbol, Array<String, Symbol>] Attribute name (String or Symbol), attribute chain
30
+ # (string separated with dots) or arguments to `#[]` method (Array)
31
+ # @param options Other options that can be passed to usual RSpec example.
32
+ # @param block [Proc] The test itself. Inside it, `is_expected` (or `are_expected`) is related to result
33
+ # of `map`ping the subject.
34
+ #
35
+ def its_map(attribute, *options, &block) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
+ # rubocop:disable Lint/NestedMethodDefinition
37
+ # TODO: better desciption for different cases
38
+ describe("map(&:#{attribute})") do
39
+ let(:__its_map_subject) do
40
+ if Array === attribute
41
+ if subject.all? { |s| Hash === s }
42
+ subject.map do |s|
43
+ attribute.inject(s) { |inner, attr| inner[attr] }
44
+ end
45
+ else
46
+ subject.map { |inner| inner[*attribute] }
47
+ end
48
+ else
49
+ attribute_chain = attribute.to_s.split('.').map(&:to_sym)
50
+ attribute_chain.inject(subject) do |inner_subject, attr|
51
+ inner_subject.map(&attr)
52
+ end
53
+ end
54
+ end
55
+
56
+ def is_expected
57
+ expect(__its_map_subject)
58
+ end
59
+
60
+ alias_method :are_expected, :is_expected
61
+
62
+ options << {} unless options.last.is_a?(Hash)
63
+
64
+ example(nil, *options, &block)
65
+ end
66
+ # rubocop:enable Lint/NestedMethodDefinition
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ RSpec.configure do |rspec|
73
+ rspec.extend Moarspec::Its::Map
74
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/its/map}
75
+ end
76
+
77
+ RSpec::SharedContext.include Moarspec::Its::Map
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Its
5
+ module With
6
+ # Creates a nested context + example with `let` values defined from the arguments.
7
+ #
8
+ # @example
9
+ #
10
+ # subject { x + y }
11
+ #
12
+ # it_with(x: 1, y: 2) { is_expected.to eq 3 }
13
+ #
14
+ # # is equivalent to
15
+ #
16
+ # context "with x=1, y=2" do
17
+ # let(:x) { 1 }
18
+ # let(:y) { 2 }
19
+ #
20
+ # it { is_expected.to eq 3 }
21
+ # end
22
+ #
23
+ # See also {Its::BlockWith#its_block_with #its_block_with} for a block form, and
24
+ # {ExampleGroups::InstantContext#instant_context #instant_context} for inline `context`+`let` definitions.
25
+ def it_with(**lets, &block)
26
+ context "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')}" do
27
+ lets.each do |name, val|
28
+ let(name) { val }
29
+ end
30
+ example(nil, &block)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ RSpec.configure do |rspec|
38
+ rspec.extend Moarspec::Its::With
39
+ rspec.backtrace_exclusion_patterns << %r{/lib/moarspec/its/with}
40
+ end
41
+
42
+ RSpec::SharedContext.include Moarspec::Its::With
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ # Wrapper module for all `its_*` RSpec additions.
5
+ #
6
+ # ## {Map#its_map #its_map}
7
+ #
8
+ # ```ruby
9
+ # subject { %w[1 2 3] }
10
+ # its_map(:to_s) { is_expected.to eq [1, 2, 3] }
11
+ # ```
12
+ #
13
+ # ## {Call#its_call #its_call}
14
+ #
15
+ # ```ruby
16
+ # subject { [1, 2, 3].method(:[]) }
17
+ # its_call(2) { is_expected.to ret 3 }
18
+ # its_call('foo') { is_expected.to raise_error }
19
+ # ```
20
+ #
21
+ # ## {Block#its_block #its_block}
22
+ #
23
+ # ```ruby
24
+ # subject { something_action }
25
+ # its_block { is_expected.not_to raise_error }
26
+ # its_block { is_expected.to change(some, :value).by(1) }
27
+ # ```
28
+ #
29
+ # ## {With#it_with #it_with}
30
+ #
31
+ # ```ruby
32
+ # subject { x + y }
33
+ # it_with(x: 1, y: 2) { is_expected.to eq 3 }
34
+ # ```
35
+ #
36
+ # ## {BlockWith#its_block_with #its_block_with}
37
+ #
38
+ # ```ruby
39
+ # subject { x + y }
40
+ # its_block_with(x: 1, y: nil) { is_expected.to raise_error }
41
+ # ```
42
+ #
43
+ module Its
44
+ end
45
+ end
46
+
47
+ require_relative 'its/map'
48
+ require_relative 'its/block'
49
+ require_relative 'its/call'
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Moarspec
6
+ module Matchers
7
+ # @private
8
+ class BeJson
9
+ include RSpec::Matchers::Composable
10
+ include RSpec::Matchers # to have #match
11
+
12
+ ANY = Object.new.freeze
13
+
14
+ attr_reader :actual, :expected
15
+
16
+ def initialize(expected, **parse_opts)
17
+ @expected_matcher = @expected = expected
18
+
19
+ # wrap to make be_json('foo' => matcher) work, too
20
+ unless expected == ANY || expected.respond_to?(:matches?)
21
+ @expected_matcher = match(expected)
22
+ end
23
+ @parse_opts = parse_opts
24
+ end
25
+
26
+ def matches?(json)
27
+ @actual = JSON.parse(json, **@parse_opts)
28
+ @expected_matcher == ANY || @expected_matcher === @actual
29
+ rescue JSON::ParserError => e
30
+ @parser_error = e
31
+ false
32
+ end
33
+
34
+ def does_not_match?(*args)
35
+ !matches?(*args)
36
+ end
37
+
38
+ def diffable?
39
+ true
40
+ end
41
+
42
+ def description
43
+ if @expected == ANY
44
+ 'be a valid JSON string'
45
+ else
46
+ expected = @expected.respond_to?(:description) ? @expected.description : @expected
47
+ "be a valid JSON matching (#{expected})"
48
+ end
49
+ end
50
+
51
+ def failure_message
52
+ failed =
53
+ case
54
+ when @parser_error
55
+ "failed: #{@parser_error}"
56
+ when @expected != ANY
57
+ "was #{@actual}"
58
+ end
59
+ "expected value to #{description} but #{failed}"
60
+ end
61
+
62
+ def failure_message_when_negated
63
+ 'expected value not to be parsed as JSON, but succeeded'
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ module RSpec
70
+ module Matchers
71
+ # `be_json` checks if provided value is JSON, and optionally checks it contents.
72
+ #
73
+ # If you need to check against some hashes, it is more convenient to use `be_json_sym`, which
74
+ # parses JSON with `symbolize_names: true`.
75
+ #
76
+ # @example
77
+ #
78
+ # expect('{}').to be_json # ok
79
+ # expect('garbage').to be_json
80
+ # # expected value to be a valid JSON string but failed: 765: unexpected token at 'garbage'
81
+ #
82
+ # expect('{"foo": "bar"}').to be_json('foo' => 'bar') # ok
83
+ # expect('{"foo": "bar"}').to be_json_sym(foo: 'bar') # more convenient
84
+ #
85
+ # expect('{"foo": [1, 2, 3]').to be_json_sym(foo: array_including(3)) # nested matchers work
86
+ # expect(something_large).to be_json_sym(include(meta: include(next_page: Integer)))
87
+ #
88
+ # @param expected Value or matcher to check JSON against. It should implement `#===` method,
89
+ # so all standard and custom RSpec matchers work.
90
+ def be_json(expected = Moarspec::Matchers::BeJson::ANY)
91
+ Moarspec::Matchers::BeJson.new(expected)
92
+ end
93
+
94
+ # `be_json_sym` checks if value is a valid JSON and parses it with `symbolize_names: true`. This
95
+ # way, it is convenient to check hashes content with Ruby's short symbolic keys syntax.
96
+ #
97
+ # See {#be_json_sym} for examples.
98
+ #
99
+ # @param expected Value or matcher to check JSON against. It should implement `#===` method,
100
+ # so all standard and custom RSpec matchers work.
101
+ def be_json_sym(expected = Moarspec::Matchers::BeJson::ANY)
102
+ Moarspec::Matchers::BeJson.new(expected, symbolize_names: true)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Matchers
5
+ # @private
6
+ class Not < RSpec::Matchers::BuiltIn::BaseMatcher
7
+ def initialize(*)
8
+ super
9
+ @delegator = Delegator.new
10
+ end
11
+
12
+ def description
13
+ "not #{@matcher.description}"
14
+ end
15
+
16
+ def match(_expected, actual)
17
+ @matcher or fail ArgumentError, '`dont` matcher used without any matcher to negate. ' \
18
+ 'Usage: dont.other_matcher(args)'
19
+
20
+ # https://www.rubydoc.info/github/rspec/rspec-expectations/RSpec%2FMatchers%2FMatcherProtocol:does_not_match%3F
21
+ # In a negative expectation such as `expect(x).not_to foo`, RSpec will call
22
+ # `foo.does_not_match?(x)` if this method is defined. If it's not defined it
23
+ # will fall back to using `!foo.matches?(x)`.
24
+ if @matcher.respond_to?(:does_not_match?)
25
+ @matcher.does_not_match?(actual)
26
+ else
27
+ !@matcher.matches?(actual)
28
+ end
29
+ end
30
+
31
+ def failure_message
32
+ @matcher.failure_message_when_negated
33
+ end
34
+
35
+ def supports_block_expectations?
36
+ @matcher.supports_block_expectations?
37
+ end
38
+
39
+ def method_missing(m, *a, &)
40
+ if @matcher
41
+ @matcher.send(m, *a, &)
42
+ else
43
+ @matcher = @delegator.send(m, *a, &)
44
+ end
45
+
46
+ self
47
+ end
48
+
49
+ def respond_to_missing?(method, include_private = false)
50
+ if @matcher
51
+ @matcher.respond_to?(method, include_private)
52
+ else
53
+ @delegator.respond_to_missing?(method, include_private)
54
+ end
55
+ end
56
+
57
+ # ActiveSupport 7.1+ defines Object#with, and thus it doesn't go to `method_missing`.
58
+ # So, matchers like `dont.send_message(...).with(...)` stop working correctly.
59
+ #
60
+ # This is dirty, but I don't see another way.
61
+ if Object.instance_methods.include?(:with)
62
+ def with(...)
63
+ @matcher.with(...)
64
+ end
65
+ end
66
+
67
+ class Delegator
68
+ include RSpec::Matchers
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ module RSpec
75
+ module Matchers
76
+ # Negates attached matcher, allowing creating negated matchers on the fly.
77
+ #
78
+ # While not being 100% grammatically correct, seems to be readable enough.
79
+ #
80
+ # @example
81
+ # # before
82
+ # RSpec.define_negated_matcher :not_change, :change
83
+ # it { expect { code }.to do_stuff.and not_change(obj, :attr) }
84
+ #
85
+ # # after: no `define_negated_matcher` needed
86
+ # it { expect { code }.to do_stuff.and dont.change(obj, :attr) }
87
+ #
88
+ def dont
89
+ Moarspec::Matchers::Not.new
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../util'
4
+
5
+ module Moarspec
6
+ module Matchers
7
+ # @private
8
+ class EqMultiline < RSpec::Matchers::BuiltIn::Eq
9
+ include Util
10
+ def initialize(expected)
11
+ super(multiline(expected))
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ module RSpec
18
+ module Matchers
19
+ # Allows to pretty test multiline strings with complex indentation (for example, results of
20
+ # code generation).
21
+ #
22
+ # In provided string, removes first and last empty line, trailing spaces and leading spaces up
23
+ # to `|` character.
24
+ #
25
+ # If you need to preserve trailing spaces, end them with another `|`.
26
+ #
27
+ # @example
28
+ # require 'moarspec/matchers/eq_multiline'
29
+ #
30
+ # expect(some_code_gen).to eq_multiline(%{
31
+ # |def something
32
+ # | a = 5
33
+ # | a**2
34
+ # |end
35
+ # })
36
+ #
37
+ # @param expected [String]
38
+ def eq_multiline(expected)
39
+ Moarspec::Matchers::EqMultiline.new(expected)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ # TODO: PR to webmock itself?..
2
+ #
3
+ # RSpec::Matchers.define :request_webmock do |url, method: :get|
4
+ # match do |block|
5
+ # WebMock.reset!
6
+ # stub_request(method, url)
7
+ # .tap { |req| req.with(@with_options) if @with_options && !@with_block }
8
+ # .tap { |req| req.with(@with_options, &@with_block) if @with_block }
9
+ # .tap { |req| req.to_return(@response) if @response }
10
+ # block.call
11
+ # matcher = have_requested(method, url)
12
+ # .tap { |matcher| matcher.with(@with_options) if @with_options && !@with_block }
13
+ # .tap { |matcher| matcher.with(@with_options, &@with_block) if @with_block }
14
+ # expect(WebMock).to matcher
15
+ # end
16
+ #
17
+ # chain :with do |options = {}, &block|
18
+ # @with_options = options
19
+ # @with_block = block
20
+ # end
21
+ #
22
+ # chain :once do
23
+ # times(1)
24
+ # end
25
+ #
26
+ # chain :twice do
27
+ # times(2)
28
+ # end
29
+ #
30
+ # chain :times do |n|
31
+ # @times = n
32
+ # end
33
+ #
34
+ # chain :at_least_once do
35
+ # at_least_times(1)
36
+ # end
37
+ #
38
+ # chain :at_least_twice do
39
+ # at_least_times(2)
40
+ # end
41
+ #
42
+ # chain :at_least_times do |n|
43
+ # @at_least_times = n
44
+ # end
45
+ #
46
+ # chain :returning do |response|
47
+ # @response =
48
+ # case response
49
+ # when String
50
+ # {body: response}
51
+ # when Hash
52
+ # response
53
+ # else
54
+ # fail "Expected string or Hash of params, got #{response.inspect}"
55
+ # end
56
+ # end
57
+ #
58
+ # supports_block_expectations
59
+ # end
60
+ #
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Matchers
5
+ # @private
6
+ class Ret
7
+ include RSpec::Matchers::Composable
8
+
9
+ attr_reader :actual, :expected
10
+
11
+ def initialize(expected)
12
+ @expected = expected
13
+ end
14
+
15
+ def matches?(subject)
16
+ @subject = subject
17
+ return false unless subject.respond_to?(:call)
18
+
19
+ @actual = subject.call
20
+ @expected === @actual
21
+ end
22
+
23
+ def supports_block_expectations?
24
+ true
25
+ end
26
+
27
+ def diffable?
28
+ true
29
+ end
30
+
31
+ def description
32
+ "return #{@expected.respond_to?(:description) ? @expected.description : @expected.inspect}"
33
+ end
34
+
35
+ def failure_message
36
+ case
37
+ when !@subject.respond_to?(:call)
38
+ "expected to #{description}, but was not callable"
39
+ when @expected.respond_to?(:failure_message)
40
+ "return value mismatch: #{@expected.failure_message}"
41
+ else
42
+ "expected to #{description}, but returned #{@actual.inspect}"
43
+ end
44
+ end
45
+
46
+ def failure_message_when_negated
47
+ case
48
+ when @expected.respond_to?(:failure_message_when_negated)
49
+ "return value mismatch: #{@expected.failure_message_when_negated}"
50
+ else
51
+ "expected not to #{description}, but returned it"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ module RSpec
59
+ module Matchers
60
+ # `ret` (short for `return`) checks if provided block **returns** value specified.
61
+ #
62
+ # It should be considered instead of simple value matchers (like `eq`) in the situations:
63
+ #
64
+ # 1. Several block behaviors tested in the same test, joined with `.and`, or in separate tests
65
+ # 2. You test what some block or method returns with arguments, using
66
+ # {Moarspec::Its::Call#its_call #its_call}
67
+ #
68
+ # Values are tested with `===`, which allows chaining other matchers and patterns to the check.
69
+ #
70
+ # @note
71
+ # There is a case when `ret` fails: when it is _not the first_ in a chain of matchers joined
72
+ # by `.and`. That's not exactly the matchers bug, that's how RSpec works (loses block's return
73
+ # value passing the block between matchers)
74
+ #
75
+ # @example
76
+ # # case 1: block is a subject
77
+ # subject { -> { do_something } }
78
+ #
79
+ # it { is_expected.not_to raise_error }
80
+ # it { is_expected.to change(some, :value).by(1) }
81
+ # it { is_expected.to ret 8 }
82
+ #
83
+ # # or, joined:
84
+ # specify {
85
+ # expect { do_something }.to ret(8).and change(some, :value).by(1)
86
+ # }
87
+ #
88
+ # # case 2: with arguments
89
+ # subject { %i[a b c].method(:[]) }
90
+ #
91
+ # its_call(1) { is_expected.to ret :b }
92
+ # its_call(1..-1) { is_expected.to ret %i[b c] }
93
+ # its_call('foo') { is_expected.to raise_error TypeError }
94
+ #
95
+ # # Note, that values are tested with ===, which means all other matchers could be chained:
96
+ # its_call(1) { is_expected.to ret instance_of(Symbol) }
97
+ # its_call(1..-1) { is_expected.to ret instance_of(Array).and have_attributes(length: 2) }
98
+ #
99
+ def ret(expected)
100
+ Moarspec::Matchers::Ret.new(expected)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Matchers
5
+ # @private
6
+ class SendMessage
7
+ include RSpec::Mocks::ExampleMethods
8
+ include RSpec::Matchers::Composable
9
+
10
+ def initialize(target, method)
11
+ @target = target
12
+ @method = method
13
+ end
14
+
15
+ # DSL
16
+ def with(*arguments)
17
+ @arguments = arguments
18
+ self
19
+ end
20
+
21
+ def returning(*res)
22
+ @res = [*res]
23
+ self
24
+ end
25
+
26
+ def calling_original
27
+ @call_original = true
28
+ self
29
+ end
30
+
31
+ def exactly(n)
32
+ @times = n
33
+ self
34
+ end
35
+
36
+ def at_least(n)
37
+ @at_least = n
38
+ self
39
+ end
40
+
41
+ def at_most(n)
42
+ @at_most = n
43
+ self
44
+ end
45
+
46
+ def times
47
+ fail NoMethodError unless @times || @at_least
48
+
49
+ self
50
+ end
51
+
52
+ def once
53
+ exactly(1)
54
+ end
55
+
56
+ def twice
57
+ exactly(2)
58
+ end
59
+
60
+ def thrice
61
+ exactly(3)
62
+ end
63
+
64
+ def ordered
65
+ @ordered = true
66
+ self
67
+ end
68
+
69
+ def yielding(*args, &block)
70
+ @yield_args = args
71
+ @yield_block = block
72
+ self
73
+ end
74
+
75
+ # Matching
76
+ def matches?(subject)
77
+ run(subject)
78
+ expect(@target).to expectation
79
+ true
80
+ end
81
+
82
+ def does_not_match?(subject)
83
+ run(subject)
84
+ expect(@target).not_to expectation
85
+ true
86
+ end
87
+
88
+ # Static properties
89
+ def supports_block_expectations?
90
+ true
91
+ end
92
+
93
+ def description
94
+ format('send %p.%s', @target, @method)
95
+ end
96
+
97
+ def failure_message
98
+ "expected #{description}, but sent nothing"
99
+ end
100
+
101
+ def failure_message_when_negated
102
+ "expected not #{description}, but sent it"
103
+ end
104
+
105
+ private
106
+
107
+ def run(subject)
108
+ @target.respond_to?(@method, true) or
109
+ fail NoMethodError,
110
+ "undefined method `#{@method}' for#{@target.inspect}:#{@target.class}"
111
+ allow(@target).to allower
112
+ subject.call
113
+ end
114
+
115
+ def allower
116
+ receive(@method).tap do |a|
117
+ a.and_return(*@res) if @res
118
+ a.and_call_original if @call_original
119
+ end
120
+ end
121
+
122
+ def expectation
123
+ have_received(@method).tap do |e|
124
+ e.with(*@arguments) if @arguments
125
+ e.exactly(@times).times if @times
126
+ e.at_least(@at_least).times if @at_least
127
+ e.at_most(@at_most).times if @at_most
128
+ e.ordered if @ordered
129
+ e.and_yield(*@yield_args, &@yield_block) if @yield_args
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ module RSpec
137
+ module Matchers
138
+ # Checks if the (block) subject sends specified message to specified object.
139
+ #
140
+ # @example
141
+ # # before:
142
+ # specify {
143
+ # allow(double).to receive(:fetch)
144
+ # code_being_tested
145
+ # expect(double).to have_received(:fetch).with(something)
146
+ # }
147
+ #
148
+ # # after:
149
+ # require 'moarspec/matchers/send_message'
150
+ #
151
+ # it { expect { code_being_tested }.to send_message(double, :fetch).with(something) }
152
+ #
153
+ # # after + its_block
154
+ # require 'moarspec/its/block'
155
+ #
156
+ # subject { code_being_tested }
157
+ # its_block { is_expected.to send_message(double, :fetch).with(something) }
158
+ #
159
+ # @param target Object which expects message, double or real object
160
+ # @param method [Symbol] Message being expected
161
+ #
162
+ # @return Instance of a matcher, allowing the following additional methods:
163
+ #
164
+ # * `once`, `twice`, `exactly(n).times`;
165
+ # * `with(arguments)`;
166
+ # * `calling_original`;
167
+ # * `returning(response)`.
168
+ #
169
+ def send_message(target, method)
170
+ Moarspec::Matchers::SendMessage.new(target, method)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ # All Moarspec matchers, when required, included into `RSpec::Matchers` namespace.
5
+ #
6
+ # See:
7
+ #
8
+ # * {RSpec::Matchers#dont #dont}: `expect { block }.to change(this).and dont.change(that)`
9
+ # * {RSpec::Matchers#send_message #send_message}: `expect { block }.to send_message(File, :write)`
10
+ # * {RSpec::Matchers#ret #ret}: `expect { block }.to ret value`
11
+ # * {RSpec::Matchers#be_json #be_json}: `expect(response.body).to be_json('foo' => 'bar')`
12
+ # * {RSpec::Matchers#eq_multiline #eq_multiline}: multiline equality akin to squiggly heredoc
13
+ #
14
+ module Matchers
15
+ end
16
+ end
17
+
18
+ require_relative 'matchers/eq_multiline'
19
+ require_relative 'matchers/send_message'
20
+ require_relative 'matchers/ret'
21
+ require_relative 'matchers/dont'
22
+ require_relative 'matchers/be_json'
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ module Util
5
+ def multiline(string)
6
+ # 1. for all lines looking like "<spaces>|" -- remove this.
7
+ # 2. remove trailing spaces
8
+ # 3. preserve trailing spaces ending with "|", but remove the pipe
9
+ # 4. remove one empty line before & after, allows prettier %Q{}
10
+ # TODO: check if all lines start with "|"?
11
+ string
12
+ .gsub(/^ *\|/, '')
13
+ .gsub(/ +$/, '')
14
+ .gsub(/\|$/, '')
15
+ .gsub(/(\A *\n|\n *\z)/, '')
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moarspec
4
+ MAJOR = 0
5
+ MINOR = 1
6
+ PATCH = 0
7
+ VERSION = [MAJOR, MINOR, PATCH].join('.')
8
+ end
data/lib/moarspec.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ defined?(RSpec) or
4
+ fail 'RSpec is not present in the current environment, check that `rspec` ' \
5
+ 'is present in your Gemfile and is in the same group as `moarspec`' \
6
+
7
+ # Umbrella module for all Moarspec RSpec DRY-ing features.
8
+ #
9
+ # See {file:README.md} or {Its}, {Matchers}, and {ExampleGroups} separately.
10
+ #
11
+ module Moarspec
12
+ end
13
+
14
+ require_relative 'moarspec/its'
15
+ require_relative 'moarspec/matchers'
16
+ require_relative 'moarspec/example_groups'
17
+ require_relative 'moarspec/util'
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: moarspec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Victor Shepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-06-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rubocop
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.76.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.76.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 3.7.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 3.7.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-its
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.9'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubygems-tasks
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email: zverok.offline@gmail.com
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - LICENSE.txt
118
+ - config/rubocop-rspec.yml
119
+ - lib/moarspec.rb
120
+ - lib/moarspec/example_groups.rb
121
+ - lib/moarspec/example_groups/instant_context.rb
122
+ - lib/moarspec/its.rb
123
+ - lib/moarspec/its/block.rb
124
+ - lib/moarspec/its/block_with.rb
125
+ - lib/moarspec/its/call.rb
126
+ - lib/moarspec/its/map.rb
127
+ - lib/moarspec/its/with.rb
128
+ - lib/moarspec/matchers.rb
129
+ - lib/moarspec/matchers/be_json.rb
130
+ - lib/moarspec/matchers/dont.rb
131
+ - lib/moarspec/matchers/eq_multiline.rb
132
+ - lib/moarspec/matchers/request_webmock.rb
133
+ - lib/moarspec/matchers/ret.rb
134
+ - lib/moarspec/matchers/send_message.rb
135
+ - lib/moarspec/util.rb
136
+ - lib/moarspec/version.rb
137
+ homepage: https://github.com/zverok/moarspec
138
+ licenses:
139
+ - MIT
140
+ metadata: {}
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: 3.1.0
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 3.5.22
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: Several additions for DRYer RSpec code
160
+ test_files: []