matchi 3.3.2 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5831db6ac7cfec32997d0683fb756c4442fa516702cdb410556fc9c790de1f03
4
- data.tar.gz: 679942f7da92507e0cc4e944a3d9761cbb7d72083cb71ab7e5302a154fe889c0
3
+ metadata.gz: 730c0baf16a64842af123077150d70aee64e664ef6d5626dadf4730c1ee3425d
4
+ data.tar.gz: a934654b282119e28d4fb5c4079298770f86a834d58621240737f2473f067500
5
5
  SHA512:
6
- metadata.gz: f75dea44631fe6a02dc2909e8d7ba1e1964e953a34268b021135436d45d49e9de25d439a7c4436c449c5366c57193bbf0d1e652fe8bf6439be1b17218c04ce82
7
- data.tar.gz: 03acf53bf37c0f1397b8366dd2463faa174c2be364a234bb86b7a4c49cd540abf3e1749790822992a5420367260807a3de611840ccee74165e1bf204fbc4f571
6
+ metadata.gz: 75884ba2533b155d3f1e4490f9b8f6071a95aad0fca637dcdb0eee2bf60e1a3dc6bd7cab35ab93a3a7c18b680b9d789ee71bfbd7d524e6ca5ea8573520a3deec
7
+ data.tar.gz: d421e42619fc45d1f1aab95ccde2a24d6a1226f4163a4afbde02fc8e693343997fa5a9a0081d72734955b2b6a25b3f9f735766702860d2184c44f403e169f442
data/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
  [![RuboCop](https://github.com/fixrb/matchi/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Arubocop+branch%3Amain)
7
7
  [![License](https://img.shields.io/github/license/fixrb/matchi?label=License&logo=github)](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
8
8
 
9
- > Collection of expectation matchers for Rubyists 🤹
9
+ This library provides a comprehensive set of matchers for testing different aspects of your code.
10
+ Each matcher is designed to handle specific verification needs while maintaining a clear and expressive syntax.
10
11
 
11
12
  ![A Rubyist juggling between Matchi letters](https://github.com/fixrb/matchi/raw/main/img/matchi.png)
12
13
 
@@ -52,148 +53,221 @@ All examples here assume that this has been done.
52
53
 
53
54
  ### Anatomy of a matcher
54
55
 
55
- A __Matchi__ matcher is an object that must respond to the `matches?` method with a block as argument, and return a boolean.
56
+ A **Matchi** matcher is a simple Ruby object that follows these requirements:
56
57
 
57
- To facilitate the integration of the matchers in other tools, __Matchi__ matchers may expose expected values via the `expected` method.
58
+ 1. It must implement a `match?` method that:
59
+ - Accepts a block as its only parameter
60
+ - Executes that block to get the actual value
61
+ - Returns a boolean indicating if the actual value matches the expected criteria
58
62
 
59
- ### Built-in matchers
63
+ 2. Optionally, it may implement:
64
+ - `to_s`: Returns a human-readable description of the match criteria
65
+
66
+ ### Using Matchers
67
+
68
+ There are two main ways to use Matchi matchers:
60
69
 
61
- Here is the collection of useful generic matchers.
70
+ #### 1. Direct Class Instantiation
62
71
 
63
- **Equivalence** matcher:
72
+ You can create matchers directly from their classes:
64
73
 
65
74
  ```ruby
66
75
  matcher = Matchi::Eq.new("foo")
76
+ matcher.match? { "foo" } # => true
67
77
 
68
- matcher.expected # => "foo"
69
- matcher.matches? { "foo" } # => true
78
+ matcher = Matchi::BeWithin.new(0.5).of(3.0)
79
+ matcher.match? { 3.2 } # => true
70
80
  ```
71
81
 
72
- **Identity** matcher:
82
+ #### 2. Helper Methods via Module Inclusion
83
+
84
+ For a more expressive and readable syntax, you can include or extend the `Matchi` module to get access to helper methods:
73
85
 
74
86
  ```ruby
75
- matcher = Matchi::Be.new(:foo)
87
+ # Including in a class for instance methods
88
+ class MyTestFramework
89
+ include Matchi
76
90
 
77
- matcher.expected # => :foo
78
- matcher.matches? { :foo } # => true
79
- ```
91
+ def test_equality
92
+ # Helper methods available as instance methods
93
+ matcher = eq("foo")
94
+ assert(matcher.match? { "foo" })
80
95
 
81
- **Comparisons** matcher:
96
+ # Method chaining works too
97
+ assert(change(@array, :length).by(1).match? { @array << 1 })
98
+ end
99
+ end
82
100
 
83
- ```ruby
84
- matcher = Matchi::BeWithin.new(8).of(37)
101
+ # Extending a class for class methods
102
+ class MyAssertions
103
+ extend Matchi
104
+
105
+ def self.assert_equals(expected, actual)
106
+ eq(expected).match? { actual }
107
+ end
85
108
 
86
- matcher.expected # => 37
87
- matcher.matches? { 42 } # => true
109
+ def self.assert_within_range(expected, delta, actual)
110
+ be_within(delta).of(expected).match? { actual }
111
+ end
112
+ end
88
113
  ```
89
114
 
90
- **Regular expressions** matcher:
115
+ Available helper methods correspond to the built-in matchers:
116
+ - `eq` / `eql` - For equivalence matching
117
+ - `be` / `equal` - For identity matching
118
+ - `be_within` - For delta comparisons
119
+ - `match` - For regular expression matching
120
+ - `change` - For state changes
121
+ - `be_true`, `be_false`, `be_nil` - For state verification
122
+ - `be_an_instance_of` - For exact type matching
123
+ - `be_a_kind_of` - For type hierarchy matching
124
+ - `satisfy` - For custom block-based matching
125
+ - Dynamic predicate matchers (`be_*` and `have_*`)
91
126
 
92
- ```ruby
93
- matcher = Matchi::Match.new(/^foo$/)
127
+ ### Built-in matchers
94
128
 
95
- matcher.expected # => /^foo$/
96
- matcher.matches? { "foo" } # => true
97
- ```
129
+ Here is the collection of generic matchers.
98
130
 
99
- **Expecting errors** matcher:
131
+ #### Basic Comparison Matchers
100
132
 
133
+ ##### `Be`
134
+ Checks for object identity using Ruby's `equal?` method.
101
135
  ```ruby
102
- matcher = Matchi::RaiseException.new(:NameError)
136
+ Matchi::Be.new(:foo).match? { :foo } # => true (same object)
137
+ Matchi::Be.new("test").match? { "test" } # => false (different objects)
138
+ ```
103
139
 
104
- matcher.expected # => "NameError"
105
- matcher.matches? { Boom } # => true
140
+ ##### `Eq`
141
+ Verifies object equivalence using Ruby's `eql?` method.
142
+ ```ruby
143
+ Matchi::Eq.new("foo").match? { "foo" } # => true (equivalent content)
144
+ Matchi::Eq.new([1, 2]).match? { [1, 2] } # => true (equivalent arrays)
106
145
  ```
107
146
 
108
- **Type/class** matcher:
147
+ #### Type and Class Matchers
109
148
 
149
+ ##### `BeAnInstanceOf`
150
+ Verifies exact class matching (no inheritance).
110
151
  ```ruby
111
- matcher = Matchi::BeAnInstanceOf.new(:String)
112
-
113
- matcher.expected # => "String"
114
- matcher.matches? { "foo" } # => true
152
+ Matchi::BeAnInstanceOf.new(String).match? { "test" } # => true
153
+ Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
154
+ Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false (Integer, not Numeric)
115
155
  ```
116
156
 
117
- **Predicate** matcher:
118
-
157
+ ##### `BeAKindOf`
158
+ Verifies class inheritance and module inclusion.
119
159
  ```ruby
120
- matcher = Matchi::Predicate.new(:be_empty)
160
+ Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true (Integer inherits from Numeric)
161
+ Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true (Float inherits from Numeric)
162
+ ```
121
163
 
122
- matcher.expected # => [:empty?, [], {}, nil]
123
- matcher.matches? { [] } # => true
164
+ #### Pattern Matchers
124
165
 
125
- matcher = Matchi::Predicate.new(:have_key, :foo)
166
+ ##### `Match`
167
+ Tests string patterns against regular expressions.
168
+ ```ruby
169
+ Matchi::Match.new(/^foo/).match? { "foobar" } # => true
170
+ Matchi::Match.new(/\d+/).match? { "abc123" } # => true
171
+ Matchi::Match.new(/^foo/).match? { "barfoo" } # => false
172
+ ```
126
173
 
127
- matcher.expected # => [:has_key?, [:foo], {}, nil]
128
- matcher.matches? { { foo: 42 } } # => true
174
+ ##### `Satisfy`
175
+ Provides custom matching through a block.
176
+ ```ruby
177
+ Matchi::Satisfy.new { |x| x.positive? && x < 10 }.match? { 5 } # => true
178
+ Matchi::Satisfy.new { |x| x.start_with?("test") }.match? { "test_file" } # => true
129
179
  ```
130
180
 
131
- **Change** matcher:
181
+ #### State Change Matchers
132
182
 
133
- ```ruby
134
- object = []
135
- matcher = Matchi::Change.new(object, :length).by(1)
183
+ ##### `Change`
184
+ Verifies state changes in objects with multiple variation methods:
136
185
 
137
- matcher.expected # => 1
138
- matcher.matches? { object << 1 } # => true
186
+ ###### Basic Change
187
+ ```ruby
188
+ array = []
189
+ Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
190
+ ```
139
191
 
140
- object = []
141
- matcher = Matchi::Change.new(object, :length).by_at_least(1)
192
+ ###### Minimum Change
193
+ ```ruby
194
+ counter = 0
195
+ Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
196
+ ```
142
197
 
143
- matcher.expected # => 1
144
- matcher.matches? { object << 1 } # => true
198
+ ###### Maximum Change
199
+ ```ruby
200
+ value = 10
201
+ Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 } # => true
202
+ ```
145
203
 
146
- object = []
147
- matcher = Matchi::Change.new(object, :length).by_at_most(1)
204
+ ###### From-To Change
205
+ ```ruby
206
+ string = "hello"
207
+ Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! } # => true
208
+ ```
148
209
 
149
- matcher.expected # => 1
150
- matcher.matches? { object << 1 } # => true
210
+ ###### To-Only Change
211
+ ```ruby
212
+ number = 1
213
+ Matchi::Change.new(number, :to_i).to(5).match? { number = 5 } # => true
214
+ ```
151
215
 
152
- object = "foo"
153
- matcher = Matchi::Change.new(object, :to_s).from("foo").to("FOO")
216
+ #### Numeric Matchers
154
217
 
155
- matcher.expected # => "FOO"
156
- matcher.matches? { object.upcase! } # => true
218
+ ##### `BeWithin`
219
+ Checks if a number is within a specified range of an expected value.
220
+ ```ruby
221
+ Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
222
+ Matchi::BeWithin.new(5).of(100).match? { 98 } # => true
223
+ ```
157
224
 
158
- object = "foo"
159
- matcher = Matchi::Change.new(object, :to_s).to("FOO")
225
+ #### Behavior Matchers
160
226
 
161
- matcher.expected # => "FOO"
162
- matcher.matches? { object.upcase! } # => true
227
+ ##### `RaiseException`
228
+ Verifies that code raises specific exceptions.
229
+ ```ruby
230
+ Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
231
+ Matchi::RaiseException.new(NameError).match? { undefined_variable } # => true
163
232
  ```
164
233
 
165
- **Satisfy** matcher:
234
+ ##### `Predicate`
235
+ Creates matchers for methods ending in `?`.
166
236
 
237
+ ###### Using `be_` prefix
167
238
  ```ruby
168
- matcher = Matchi::Satisfy.new { |value| value == 42 }
239
+ Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
240
+ Matchi::Predicate.new(:be_nil).match? { nil } # => true (calls nil?)
241
+ ```
169
242
 
170
- matcher.expected # => #<Proc:0x00007fbaafc65540>
171
- matcher.matches? { 42 } # => true
243
+ ###### Using `have_` prefix
244
+ ```ruby
245
+ Matchi::Predicate.new(:have_key, :foo).match? { { foo: 42 } } # => true (calls has_key?)
172
246
  ```
173
247
 
174
248
  ### Custom matchers
175
249
 
176
- Custom matchers can easily be added to express more specific expectations.
250
+ Custom matchers could easily be added to `Matchi` module to express more specific expectations.
177
251
 
178
252
  A **Be the answer** matcher:
179
253
 
180
254
  ```ruby
181
255
  module Matchi
182
256
  class BeTheAnswer
183
- def expected
184
- 42
257
+ def match?
258
+ expected.equal?(yield)
185
259
  end
186
260
 
187
- def matches?
188
- expected.equal?(yield)
261
+ private
262
+
263
+ def expected
264
+ 42
189
265
  end
190
266
  end
191
267
  end
192
268
 
193
269
  matcher = Matchi::BeTheAnswer.new
194
-
195
- matcher.expected # => 42
196
- matcher.matches? { 42 } # => true
270
+ matcher.match? { 42 } # => true
197
271
  ```
198
272
 
199
273
  A **Be prime** matcher:
@@ -203,7 +277,7 @@ require "prime"
203
277
 
204
278
  module Matchi
205
279
  class BePrime
206
- def matches?
280
+ def match?
207
281
  Prime.prime?(yield)
208
282
  end
209
283
  end
@@ -211,7 +285,7 @@ end
211
285
 
212
286
  matcher = Matchi::BePrime.new
213
287
 
214
- matcher.matches? { 42 } # => false
288
+ matcher.match? { 42 } # => false
215
289
  ```
216
290
 
217
291
  A **Start with** matcher:
@@ -219,22 +293,64 @@ A **Start with** matcher:
219
293
  ```ruby
220
294
  module Matchi
221
295
  class StartWith
222
- attr_reader :expected
223
-
224
296
  def initialize(expected)
225
297
  @expected = expected
226
298
  end
227
299
 
228
- def matches?
229
- /\A#{expected}/.match?(yield)
300
+ def match?
301
+ /\A#{@expected}/.match?(yield)
230
302
  end
231
303
  end
232
304
  end
233
305
 
234
306
  matcher = Matchi::StartWith.new("foo")
307
+ matcher.match? { "foobar" } # => true
308
+ ```
235
309
 
236
- matcher.expected # => "foo"
237
- matcher.matches? { "foobar" } # => true
310
+ ## Best Practices
311
+
312
+ ### Proper Value Comparison Order
313
+
314
+ One of the most critical aspects when implementing matchers is the order of comparison between expected and actual values. Always compare values in this order:
315
+
316
+ ```ruby
317
+ # GOOD: Expected value controls the comparison
318
+ expected_value.eql?(actual_value)
319
+
320
+ # BAD: Actual value controls the comparison
321
+ actual_value.eql?(expected_value)
322
+ ```
323
+
324
+ #### Why This Matters
325
+
326
+ The order is crucial because the object receiving the comparison method controls how the comparison is performed. When testing, the actual value might come from untrusted or malicious code that could override comparison methods:
327
+
328
+ ```ruby
329
+ # Example of how comparison can be compromised
330
+ class MaliciousString
331
+ def eql?(other)
332
+ true # Always returns true regardless of actual equality
333
+ end
334
+
335
+ def ==(other)
336
+ true # Always returns true regardless of actual equality
337
+ end
338
+ end
339
+
340
+ actual = MaliciousString.new
341
+ expected = "expected string"
342
+
343
+ actual.eql?(expected) # => true (incorrect result!)
344
+ expected.eql?(actual) # => false (correct result)
345
+ ```
346
+
347
+ This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the `Eq` matcher:
348
+
349
+ ```ruby
350
+ # Implementation in Matchi::Eq
351
+ def match?
352
+ @expected.eql?(yield) # Expected value controls the comparison
353
+ end
238
354
  ```
239
355
 
240
356
  ## Contact
@@ -250,11 +366,6 @@ __Matchi__ follows [Semantic Versioning 2.0](https://semver.org/).
250
366
 
251
367
  The [gem](https://rubygems.org/gems/matchi) is available as open source under the terms of the [MIT License](https://github.com/fixrb/matchi/raw/main/LICENSE.md).
252
368
 
253
- ***
369
+ ## Sponsors
254
370
 
255
- <p>
256
- This project is sponsored by:<br />
257
- <a href="https://sashite.com/"><img
258
- src="https://github.com/fixrb/matchi/raw/main/img/sashite.png"
259
- alt="Sashité" /></a>
260
- </p>
371
+ This project is sponsored by [Sashité](https://sashite.com/)
data/lib/matchi/be.rb CHANGED
@@ -3,9 +3,6 @@
3
3
  module Matchi
4
4
  # *Identity* matcher.
5
5
  class Be
6
- # @return [#equal?] The expected identical object.
7
- attr_reader :expected
8
-
9
6
  # Initialize the matcher with an object.
10
7
  #
11
8
  # @example
@@ -24,26 +21,23 @@ module Matchi
24
21
  # require "matchi/be"
25
22
  #
26
23
  # matcher = Matchi::Be.new(:foo)
27
- #
28
- # matcher.expected # => :foo
29
- # matcher.matches? { :foo } # => true
24
+ # matcher.match? { :foo } # => true
30
25
  #
31
26
  # @yieldreturn [#object_id] The actual value to compare to the expected
32
27
  # one.
33
28
  #
34
29
  # @return [Boolean] Comparison between actual and expected values.
35
- def matches?
36
- expected.equal?(yield)
37
- end
30
+ def match?
31
+ raise ::ArgumentError, "a block must be provided" unless block_given?
38
32
 
39
- # A string containing a human-readable representation of the matcher.
40
- def inspect
41
- "#{self.class}(#{expected.inspect})"
33
+ @expected.equal?(yield)
42
34
  end
43
35
 
44
36
  # Returns a string representing the matcher.
37
+ #
38
+ # @return [String] a human-readable description of the matcher
45
39
  def to_s
46
- "be #{expected.inspect}"
40
+ "be #{@expected.inspect}"
47
41
  end
48
42
  end
49
43
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matchi
4
+ # *Type/class* matcher for inheritance-aware type checking.
5
+ #
6
+ # This matcher provides a clear way to check if an object is an instance of a
7
+ # specific class or one of its subclasses. It leverages Ruby's native === operator
8
+ # which reliably handles class hierarchy relationships.
9
+ #
10
+ # @example Basic usage
11
+ # require "matchi/be_a_kind_of"
12
+ #
13
+ # matcher = Matchi::BeAKindOf.new(Numeric)
14
+ # matcher.match? { 42 } # => true
15
+ # matcher.match? { 42.0 } # => true
16
+ # matcher.match? { "42" } # => false
17
+ class BeAKindOf
18
+ # Initialize the matcher with (the name of) a class or module.
19
+ #
20
+ # @example
21
+ # require "matchi/be_a_kind_of"
22
+ #
23
+ # Matchi::BeAKindOf.new(String)
24
+ # Matchi::BeAKindOf.new("String")
25
+ # Matchi::BeAKindOf.new(:String)
26
+ #
27
+ # @param expected [Class, #to_s] The expected class name
28
+ # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
29
+ def initialize(expected)
30
+ @expected = String(expected)
31
+ return if /\A[A-Z]/.match?(@expected)
32
+
33
+ raise ::ArgumentError,
34
+ "expected must start with an uppercase letter (got: #{@expected})"
35
+ end
36
+
37
+ # Checks if the yielded object is an instance of the expected class
38
+ # or one of its subclasses.
39
+ #
40
+ # This method uses the case equality operator (===) which provides a reliable
41
+ # way to check class hierarchy relationships in Ruby. When a class is the
42
+ # receiver of ===, it returns true if the argument is an instance of that
43
+ # class or one of its subclasses.
44
+ #
45
+ # @example Class hierarchy check
46
+ # class Animal; end
47
+ # class Dog < Animal; end
48
+ #
49
+ # matcher = Matchi::BeAKindOf.new(Animal)
50
+ # matcher.match? { Dog.new } # => true
51
+ # matcher.match? { Animal.new } # => true
52
+ # matcher.match? { Object.new } # => false
53
+ #
54
+ # @yieldreturn [Object] the actual value to check
55
+ # @return [Boolean] true if the object is an instance of the expected class or one of its subclasses
56
+ # @raise [ArgumentError] if no block is provided
57
+ def match?
58
+ raise ::ArgumentError, "a block must be provided" unless block_given?
59
+
60
+ expected_class === yield # rubocop:disable Style/CaseEquality
61
+ end
62
+
63
+ # Returns a string representing the matcher.
64
+ #
65
+ # @return [String] a human-readable description of the matcher
66
+ def to_s
67
+ "be a kind of #{@expected}"
68
+ end
69
+
70
+ private
71
+
72
+ # Resolves the expected class name to an actual Class object.
73
+ # This method handles both string and symbol class names through constant resolution.
74
+ #
75
+ # @return [Class] the resolved class
76
+ # @raise [NameError] if the class doesn't exist
77
+ def expected_class
78
+ ::Object.const_get(@expected)
79
+ end
80
+ end
81
+ end