matchi 4.1.1 → 5.0.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 +4 -4
- data/LICENSE.md +1 -1
- data/README.md +87 -294
- data/lib/matchi/be.rb +11 -24
- data/lib/matchi/be_a_kind_of.rb +21 -63
- data/lib/matchi/be_an_instance_of.rb +24 -101
- data/lib/matchi/be_within/of.rb +39 -33
- data/lib/matchi/be_within.rb +19 -25
- data/lib/matchi/change/delta.rb +56 -0
- data/lib/matchi/change/from.rb +22 -32
- data/lib/matchi/change/from_to.rb +46 -0
- data/lib/matchi/change/to.rb +19 -34
- data/lib/matchi/change.rb +85 -86
- data/lib/matchi/constant.rb +55 -0
- data/lib/matchi/eq.rb +26 -25
- data/lib/matchi/match.rb +30 -26
- data/lib/matchi/number.rb +37 -0
- data/lib/matchi/predicate.rb +46 -56
- data/lib/matchi/raise_exception.rb +75 -40
- data/lib/matchi/satisfy.rb +22 -26
- data/lib/matchi.rb +22 -306
- metadata +18 -11
- data/lib/matchi/change/by.rb +0 -60
- data/lib/matchi/change/by_at_least.rb +0 -61
- data/lib/matchi/change/by_at_most.rb +0 -61
- data/lib/matchi/change/from/to.rb +0 -65
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66ca7f8e6eb1fb3e7fa80907957f37af41fd04b678258199c0f1abfe784aa839
|
|
4
|
+
data.tar.gz: b25bc8c4da4bb7db33478cf7fda77e7119bf0fce94fdfd8310ab8d7c79f8638c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c4b77eb03979138e162a7040380b9b74c31b16e1995e67e537eebd3d83074913fa39d377f29759e085f35a143a15f19407f77dcd6b2788fc5e2874b28f3591f
|
|
7
|
+
data.tar.gz: e570be50f7b9338a24489b791b0b118b0c892fcc6c8fdc7f375b6187cf63a430642d6a87c7b7016f28b9b6742e7a8c4c07cbcf94bf340a9d590dfbeb49ef0e87
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
|
@@ -2,365 +2,158 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/fixrb/matchi/tags)
|
|
4
4
|
[](https://rubydoc.info/github/fixrb/matchi/main)
|
|
5
|
-
[](https://github.com/fixrb/matchi/actions?query=workflow%3Aruby+branch%3Amain)
|
|
6
|
-
[](https://github.com/fixrb/matchi/actions?query=workflow%3Arubocop+branch%3Amain)
|
|
7
5
|
[](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
Each matcher is designed to handle specific verification needs while maintaining a clear and expressive syntax.
|
|
7
|
+
Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
|
|
11
8
|
|
|
12
9
|

|
|
13
10
|
|
|
14
|
-
## Project goals
|
|
15
|
-
|
|
16
|
-
* Adding matchers should be as simple as possible.
|
|
17
|
-
* Being framework agnostic and easy to integrate.
|
|
18
|
-
* Avoid false positives/negatives due to malicious actual values.
|
|
19
|
-
|
|
20
11
|
## Installation
|
|
21
12
|
|
|
22
|
-
Add this line to your application's Gemfile:
|
|
23
|
-
|
|
24
13
|
```ruby
|
|
14
|
+
# Gemfile
|
|
25
15
|
gem "matchi"
|
|
26
16
|
```
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```sh
|
|
31
|
-
bundle install
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Or install it yourself as:
|
|
35
|
-
|
|
36
|
-
```sh
|
|
37
|
-
gem install matchi
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Overview
|
|
41
|
-
|
|
42
|
-
__Matchi__ provides a collection of damn simple expectation matchers.
|
|
18
|
+
Matchi requires Ruby 3.3 or later and has no runtime dependency.
|
|
43
19
|
|
|
44
20
|
## Usage
|
|
45
21
|
|
|
46
|
-
|
|
22
|
+
A matcher is built with its expected value, then asked whether an actual value matches. The block yields the actual value once; `match?` answers `true` or `false`.
|
|
47
23
|
|
|
48
24
|
```ruby
|
|
49
25
|
require "matchi"
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
All examples here assume that this has been done.
|
|
53
|
-
|
|
54
|
-
### Anatomy of a matcher
|
|
55
|
-
|
|
56
|
-
A **Matchi** matcher is a simple Ruby object that follows these requirements:
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
69
|
-
|
|
70
|
-
#### 1. Direct Class Instantiation
|
|
71
|
-
|
|
72
|
-
You can create matchers directly from their classes:
|
|
73
|
-
|
|
74
|
-
```ruby
|
|
75
|
-
matcher = Matchi::Eq.new("foo")
|
|
76
|
-
matcher.match? { "foo" } # => true
|
|
77
|
-
|
|
78
|
-
matcher = Matchi::BeWithin.new(0.5).of(3.0)
|
|
79
|
-
matcher.match? { 3.2 } # => true
|
|
27
|
+
matcher = Matchi::Eq.new(42)
|
|
28
|
+
matcher.match? { 42 } # => true
|
|
29
|
+
matcher.match? { 41 } # => false
|
|
30
|
+
matcher.to_s # => "eq 42"
|
|
80
31
|
```
|
|
81
32
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
For a more expressive and readable syntax, you can include or extend the `Matchi` module to get access to helper methods:
|
|
33
|
+
Every `to_s` completes the sentence "expected ACTUAL to …", which is all a test framework needs:
|
|
85
34
|
|
|
86
35
|
```ruby
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def test_equality
|
|
92
|
-
# Helper methods available as instance methods
|
|
93
|
-
matcher = eq("foo")
|
|
94
|
-
assert(matcher.match? { "foo" })
|
|
95
|
-
|
|
96
|
-
# Method chaining works too
|
|
97
|
-
assert(change(@array, :length).by(1).match? { @array << 1 })
|
|
98
|
-
end
|
|
36
|
+
# Minitest
|
|
37
|
+
def assert_matches(matcher, actual)
|
|
38
|
+
assert matcher.match? { actual }, "expected #{actual.inspect} to #{matcher}"
|
|
99
39
|
end
|
|
100
40
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
extend Matchi
|
|
104
|
-
|
|
105
|
-
def self.assert_equals(expected, actual)
|
|
106
|
-
eq(expected).match? { actual }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def self.assert_within_range(expected, delta, actual)
|
|
110
|
-
be_within(delta).of(expected).match? { actual }
|
|
111
|
-
end
|
|
41
|
+
def refute_matches(matcher, actual)
|
|
42
|
+
refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
|
|
112
43
|
end
|
|
113
|
-
```
|
|
114
|
-
|
|
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_*`)
|
|
126
|
-
|
|
127
|
-
### Built-in matchers
|
|
128
|
-
|
|
129
|
-
Here is the collection of generic matchers.
|
|
130
|
-
|
|
131
|
-
#### Basic Comparison Matchers
|
|
132
|
-
|
|
133
|
-
##### `Be`
|
|
134
|
-
Checks for object identity using Ruby's `equal?` method.
|
|
135
|
-
```ruby
|
|
136
|
-
Matchi::Be.new(:foo).match? { :foo } # => true (same object)
|
|
137
|
-
Matchi::Be.new("test").match? { "test" } # => false (different objects)
|
|
138
|
-
```
|
|
139
|
-
|
|
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)
|
|
145
|
-
```
|
|
146
44
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
##### `BeAnInstanceOf`
|
|
150
|
-
Verifies exact class matching (no inheritance).
|
|
151
|
-
```ruby
|
|
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)
|
|
45
|
+
assert_matches Matchi::BeAKindOf.new(Numeric), 42
|
|
46
|
+
refute_matches Matchi::Match.new(/^foo/), "bar"
|
|
155
47
|
```
|
|
156
48
|
|
|
157
|
-
|
|
158
|
-
Verifies class inheritance and module inclusion.
|
|
159
|
-
```ruby
|
|
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
|
-
```
|
|
49
|
+
Negation is the framework's job: Matchi has no `not_to`, it answers a boolean.
|
|
163
50
|
|
|
164
|
-
|
|
51
|
+
## Matchers
|
|
165
52
|
|
|
166
|
-
##### `Match`
|
|
167
|
-
Tests string patterns against regular expressions.
|
|
168
53
|
```ruby
|
|
169
|
-
|
|
170
|
-
Matchi::Match.new(/\d+/).match? { "abc123" } # => true
|
|
171
|
-
Matchi::Match.new(/^foo/).match? { "barfoo" } # => false
|
|
172
|
-
```
|
|
173
|
-
|
|
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
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
#### State Change Matchers
|
|
182
|
-
|
|
183
|
-
##### `Change`
|
|
184
|
-
Verifies state changes in objects with multiple variation methods:
|
|
185
|
-
|
|
186
|
-
###### Basic Change
|
|
187
|
-
```ruby
|
|
188
|
-
array = []
|
|
189
|
-
Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
###### Minimum Change
|
|
193
|
-
```ruby
|
|
194
|
-
counter = 0
|
|
195
|
-
Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
###### Maximum Change
|
|
199
|
-
```ruby
|
|
200
|
-
value = 10
|
|
201
|
-
Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 } # => true
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
###### From-To Change
|
|
205
|
-
```ruby
|
|
206
|
-
string = "hello"
|
|
207
|
-
Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! } # => true
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
###### To-Only Change
|
|
211
|
-
```ruby
|
|
212
|
-
number = 1
|
|
213
|
-
Matchi::Change.new(number, :to_i).to(5).match? { number = 5 } # => true
|
|
214
|
-
```
|
|
54
|
+
require "matchi"
|
|
215
55
|
|
|
216
|
-
|
|
56
|
+
# Identity (equal?) and equivalence (eql?)
|
|
57
|
+
Matchi::Be.new(:foo).match? { :foo } # => true
|
|
58
|
+
Matchi::Eq.new("foo").match? { "foo" } # => true
|
|
59
|
+
Matchi::Eq.new(1).match? { 1.0 } # => false
|
|
217
60
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
Matchi::
|
|
222
|
-
Matchi::BeWithin.new(5).of(100).match? { 98 } # => true
|
|
223
|
-
```
|
|
61
|
+
# Types: a module, or its name resolved when the matcher runs
|
|
62
|
+
Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
|
|
63
|
+
Matchi::BeAnInstanceOf.new(:Integer).match? { 42 } # => true
|
|
64
|
+
Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false
|
|
224
65
|
|
|
225
|
-
|
|
66
|
+
# Numeric proximity; a non-Numeric actual value fails
|
|
67
|
+
Matchi::BeWithin.new(0.01).of(0.3).match? { 0.1 + 0.2 } # => true
|
|
226
68
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Matchi::
|
|
231
|
-
Matchi::
|
|
232
|
-
```
|
|
69
|
+
# Patterns and predicates
|
|
70
|
+
Matchi::Match.new(/^foo/).match? { "foobar" } # => true
|
|
71
|
+
Matchi::Satisfy.new { |v| v > 41 }.match? { 42 } # => true
|
|
72
|
+
Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
|
|
73
|
+
Matchi::Predicate.new(:have_key, :a).match? { { a: 1 } } # => true (calls has_key?(:a))
|
|
233
74
|
|
|
234
|
-
|
|
235
|
-
|
|
75
|
+
# Exceptions, by class or by name, optionally with a message (String or Regexp)
|
|
76
|
+
Matchi::RaiseException.new(ZeroDivisionError).match? { 1 / 0 } # => true
|
|
77
|
+
Matchi::RaiseException.new("Errno::ENOENT").match? { File.read("/missing") } # => true
|
|
78
|
+
Matchi::RaiseException.new(ZeroDivisionError, /by 0/).match? { 1 / 0 } # => true
|
|
236
79
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
Matchi::
|
|
240
|
-
Matchi::
|
|
241
|
-
|
|
80
|
+
# State changes, observed through a public method
|
|
81
|
+
list = []
|
|
82
|
+
Matchi::Change.new(list, :length).match? { list << 1 } # => true
|
|
83
|
+
Matchi::Change.new(list, :length).by(2).match? { list.push(1, 2) } # => true
|
|
84
|
+
Matchi::Change.new(list, :length).by_at_least(1).match? { list << 1 } # => true
|
|
85
|
+
Matchi::Change.new(list, :length).by_at_most(1).match? { list << 1 } # => true
|
|
242
86
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
Matchi::
|
|
87
|
+
list = []
|
|
88
|
+
Matchi::Change.new(list, :length).to(1).match? { list << 1 } # => true
|
|
89
|
+
Matchi::Change.new(list, :length).from(1).to(2).match? { list << 1 } # => true
|
|
246
90
|
```
|
|
247
91
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
Custom matchers could easily be added to `Matchi` module to express more specific expectations.
|
|
92
|
+
`BeWithin.new(delta)` and `Change#from(value)` are builders, not matchers: they do not respond to `match?` until `of` or `to` completes them.
|
|
251
93
|
|
|
252
|
-
A
|
|
94
|
+
Arguments are validated when the matcher is built (`ArgumentError`); a name is resolved when the matcher runs (`NameError`, or `TypeError` when the constant is not a module). A block is always required by `match?`. Whatever the actual value or an observed method raises propagates; `RaiseException` catches what its block raises, not what its message filter or the resolution of its name raises.
|
|
253
95
|
|
|
254
|
-
|
|
255
|
-
module Matchi
|
|
256
|
-
class BeTheAnswer
|
|
257
|
-
def match?
|
|
258
|
-
expected.equal?(yield)
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
private
|
|
96
|
+
`RaiseException` catches every `Exception`, including `SystemExit` and `Interrupt`, so that a specification can assert on them; an unexpected `exit` inside the block is a failure, not the end of the process.
|
|
262
97
|
|
|
263
|
-
|
|
264
|
-
42
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
end
|
|
98
|
+
`Change` observes a value: when the observed method returns the same unfrozen object twice (an `attr_reader` on a mutated array, say), `match?` raises instead of reporting no change. Observe `items.length`, or use `to`.
|
|
268
99
|
|
|
269
|
-
|
|
270
|
-
matcher.match? { 42 } # => true
|
|
271
|
-
```
|
|
100
|
+
## Writing a matcher
|
|
272
101
|
|
|
273
|
-
|
|
102
|
+
Any object that responds to `match?` with a block and to `to_s` is a matcher:
|
|
274
103
|
|
|
275
104
|
```ruby
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
class BePrime
|
|
280
|
-
def match?
|
|
281
|
-
Prime.prime?(yield)
|
|
282
|
-
end
|
|
105
|
+
class BePositive
|
|
106
|
+
def match?
|
|
107
|
+
yield.positive?
|
|
283
108
|
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
matcher = Matchi::BePrime.new
|
|
287
109
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
A **Start with** matcher:
|
|
292
|
-
|
|
293
|
-
```ruby
|
|
294
|
-
module Matchi
|
|
295
|
-
class StartWith
|
|
296
|
-
def initialize(expected)
|
|
297
|
-
@expected = expected
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
def match?
|
|
301
|
-
/\A#{@expected}/.match?(yield)
|
|
302
|
-
end
|
|
110
|
+
def to_s
|
|
111
|
+
"be positive"
|
|
303
112
|
end
|
|
304
113
|
end
|
|
305
114
|
|
|
306
|
-
|
|
307
|
-
matcher.match? { "foobar" } # => true
|
|
308
|
-
```
|
|
309
|
-
|
|
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)
|
|
115
|
+
BePositive.new.match? { 42 } # => true
|
|
322
116
|
```
|
|
323
117
|
|
|
324
|
-
|
|
118
|
+
Every matcher in Matchi follows two rules, which are worth keeping in your own:
|
|
325
119
|
|
|
326
|
-
The
|
|
120
|
+
- **The expected value receives the comparison.** `Matchi::Eq.new(a).match? { b }` evaluates `a.eql?(b)`, never `b.eql?(a)`, so an actual value that redefines `eql?` cannot pass; since `Hash#eql?` hands the comparison back to an operand of another class, `Eq` also requires the two values to be of the same class. Classes are compared with `Module#===`, the class of the actual value is read with `Kernel#class` bound to it, and `BeWithin` only subtracts core numbers, for the same reason.
|
|
121
|
+
- **A predicate answers `true` or `false`.** `Satisfy` and `Predicate` raise a `TypeError` on any other value instead of treating it as truthy.
|
|
327
122
|
|
|
328
|
-
|
|
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
|
|
123
|
+
Two matchers depend on the honesty of the object under test, because they use values it produces: `Predicate` calls one of its methods, and `Change` (with `by`, `by_at_least`, `by_at_most`) compares two values returned by the observed method, including the subtraction between them. So does the message filter of `RaiseException`, which reads the exception's own message. `Change#to` and `from … to` compare with the expected value and do not.
|
|
334
124
|
|
|
335
|
-
|
|
336
|
-
true # Always returns true regardless of actual equality
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
actual = MaliciousString.new
|
|
341
|
-
expected = "expected string"
|
|
125
|
+
A class or an exception given by name (`:Duck`, `"Errno::ENOENT"`) is resolved from `Object` when the matcher runs, so a specification can be written before the class it describes is loaded. An `autoload` or an `Object.const_missing` defined by the code under test takes part in that resolution, so a name does not carry the guarantee a module does. Names are ASCII constant paths; `"::Integer"` is not accepted.
|
|
342
126
|
|
|
343
|
-
|
|
344
|
-
expected.eql?(actual) # => false (correct result)
|
|
345
|
-
```
|
|
127
|
+
## Differences from RSpec
|
|
346
128
|
|
|
347
|
-
|
|
129
|
+
If you come from RSpec or Minitest, these will surprise you:
|
|
348
130
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
131
|
+
- `Eq` is RSpec's `eql`, not its `eq`: it compares with `eql?`, so `1` is not `1.0`, and a value object that only defines `==` never matches. Define `eql?` and `hash` on it, or use `Satisfy`. `Integer#==` hands the comparison to the other operand, which is why Matchi does not use it.
|
|
132
|
+
- `Match` takes a `Regexp` only. For a literal substring, `Matchi::Match.new(Regexp.new(Regexp.escape("a.b")))`.
|
|
133
|
+
- `RaiseException` is `raise_error`; it needs an exception class, or its name, and takes an optional message.
|
|
134
|
+
- `Change#to` demands a change: a value already equal to the target does not pass. `Change` without a refinement needs the observed method to return a value, not the mutated object.
|
|
135
|
+
- `Predicate` only calls a method of the actual value: `be_nil` calls `nil?`, but `be_truthy`, `be_falsey` and `be_a` are not predicates and do not exist. Use `Be.new(nil)`, `Satisfy`, `BeAKindOf`.
|
|
136
|
+
- There is no `and`, `or`, `not_to` or `all`: compose in your framework, with plain booleans.
|
|
355
137
|
|
|
356
|
-
##
|
|
138
|
+
## Changes in 5.0
|
|
357
139
|
|
|
358
|
-
|
|
359
|
-
|
|
140
|
+
- `BeAKindOf`, `BeAnInstanceOf` and `RaiseException` accept a module directly, so anonymous classes work. A name is still accepted; it must be a constant path (`"Foo::bar"` is rejected at construction) and resolve to a module, or a class for `BeAnInstanceOf` and an exception class for `RaiseException`. `BeAnInstanceOf` rejects a module that is not a class.
|
|
141
|
+
- `RaiseException` compares with `Module#===` and always answers `true` or `false`; `Module#<=` could answer `nil` and let the exception control the comparison. A non-exception class is rejected at construction. An expected message, `String` or `Regexp`, can be given.
|
|
142
|
+
- `Match` accepts a `Regexp` only, and fails on an actual value that is neither a `String` nor a `Symbol`, or whose encoding is invalid or incompatible. With a `String` pattern, the actual value would have become the regular expression.
|
|
143
|
+
- `Eq`, `Change#to` and `from … to` require the actual value to be of the class of the expected value, so that `Hash#eql?` cannot hand the comparison to the object under test.
|
|
144
|
+
- `Satisfy` requires a `true` or `false` answer (`TypeError`), as `Predicate` already did. The error names the class of the answer, never its `inspect`.
|
|
145
|
+
- `Predicate` and `Change` reach the object under test through `Kernel#public_send`: a private or protected predicate is now a `NoMethodError`. `Change` accepts a `String` method name and no longer checks `respond_to?` at construction; a missing method is a `NoMethodError` when the matcher runs.
|
|
146
|
+
- `BeWithin::Of` only subtracts an `Integer`, a `Float`, a `Rational` or a `BigDecimal`; any other actual value fails, including a user-defined `Numeric` that could control the subtraction through `coerce`. Deltas and expected values, there and in `by`, `by_at_least`, `by_at_most`, must be finite real numbers (`NaN`, infinities and `Complex` are rejected).
|
|
147
|
+
- `BeWithin` without `of` and `Change#from` without `to` no longer respond to `match?`.
|
|
148
|
+
- `from … to` compares with `eql?`, like `to` and `Eq` already did; `to` requires a change; both always evaluate the block. `Change` raises a `TypeError` when the observed method returns the same unfrozen object twice: in particular, asserting that an attribute holding an unfrozen object (a `Time`, an `Array`) did not change now raises instead of answering `false`. The three `by` refinements are one class, `Change::Delta`; `Change::From::To` is `Change::FromTo`.
|
|
149
|
+
- Descriptions are stable: `Change#to_s` names the observed class and method (`change Array#length`), an anonymous class reads `an anonymous class`, and no `to_s` calls `inspect` on an object under test.
|
|
150
|
+
- Error messages are rewritten and all read `X must be … (got: …)`; `Predicate::PREFIXES` is gone.
|
|
151
|
+
- The `Matchi` helper module removed in 4.2 is not back: `Matchi` is a namespace. Frameworks define their own helpers.
|
|
152
|
+
- Ruby 3.3 or later.
|
|
360
153
|
|
|
361
154
|
## Versioning
|
|
362
155
|
|
|
363
|
-
|
|
156
|
+
Matchi follows [Semantic Versioning 2.0](https://semver.org/).
|
|
364
157
|
|
|
365
158
|
## License
|
|
366
159
|
|
data/lib/matchi/be.rb
CHANGED
|
@@ -1,41 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Matchi
|
|
4
|
-
#
|
|
4
|
+
# Identity matcher: passes when the actual value is the very same object as
|
|
5
|
+
# the expected one (`equal?`).
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# Matchi::Be.new(:foo).match? { :foo } # => true
|
|
9
|
+
# Matchi::Be.new("a").match? { "a".dup } # => false (another object)
|
|
5
10
|
class Be
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# @example
|
|
9
|
-
# require "matchi/be"
|
|
10
|
-
#
|
|
11
|
-
# Matchi::Be.new(:foo)
|
|
12
|
-
#
|
|
13
|
-
# @param expected [#equal?] The expected identical object.
|
|
11
|
+
# @param expected [Object] the expected object
|
|
14
12
|
def initialize(expected)
|
|
15
13
|
@expected = expected
|
|
16
14
|
end
|
|
17
15
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# @
|
|
21
|
-
# require "matchi/be"
|
|
22
|
-
#
|
|
23
|
-
# matcher = Matchi::Be.new(:foo)
|
|
24
|
-
# matcher.match? { :foo } # => true
|
|
25
|
-
#
|
|
26
|
-
# @yieldreturn [#object_id] The actual value to compare to the expected
|
|
27
|
-
# one.
|
|
28
|
-
#
|
|
29
|
-
# @return [Boolean] Comparison between actual and expected values.
|
|
16
|
+
# @yieldreturn [Object] the actual value
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
# @raise [ArgumentError] when no block is given
|
|
30
19
|
def match?
|
|
31
20
|
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
32
21
|
|
|
33
22
|
@expected.equal?(yield)
|
|
34
23
|
end
|
|
35
24
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# @return [String] a human-readable description of the matcher
|
|
25
|
+
# @return [String]
|
|
39
26
|
def to_s
|
|
40
27
|
"be #{@expected.inspect}"
|
|
41
28
|
end
|
data/lib/matchi/be_a_kind_of.rb
CHANGED
|
@@ -1,81 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "constant"
|
|
4
|
+
|
|
3
5
|
module Matchi
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# specific class or one of its subclasses. It leverages Ruby's native === operator
|
|
8
|
-
# which reliably handles class hierarchy relationships.
|
|
6
|
+
# Type matcher: passes when the actual value is an instance of the expected
|
|
7
|
+
# module or of one of its descendants (`Module#===`, so a `BasicObject` is
|
|
8
|
+
# handled and an object redefining `is_a?` cannot lie).
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# The expected module can be given directly, or by name; see
|
|
11
|
+
# {Matchi::Constant}.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# matcher.match? { "42" } # => false
|
|
13
|
+
# @example
|
|
14
|
+
# Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
|
|
15
|
+
# Matchi::BeAKindOf.new(:Numeric).match? { "42" } # => false
|
|
17
16
|
class BeAKindOf
|
|
18
|
-
#
|
|
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
|
|
17
|
+
# @param expected [Module, String, Symbol] the expected module, or its name
|
|
18
|
+
# @raise [ArgumentError] when expected is neither a Module nor a constant name
|
|
29
19
|
def initialize(expected)
|
|
30
|
-
@expected =
|
|
31
|
-
return if /\A[A-Z]/.match?(@expected)
|
|
32
|
-
|
|
33
|
-
raise ::ArgumentError,
|
|
34
|
-
"expected must start with an uppercase letter (got: #{@expected})"
|
|
20
|
+
@expected = Constant.normalize(expected, ::Module)
|
|
35
21
|
end
|
|
36
22
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
|
23
|
+
# @yieldreturn [Object] the actual value
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
# @raise [ArgumentError] when no block is given
|
|
26
|
+
# @raise [NameError] when the name is not defined
|
|
27
|
+
# @raise [TypeError] when the name does not resolve to a Module
|
|
57
28
|
def match?
|
|
58
29
|
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
59
30
|
|
|
60
|
-
|
|
31
|
+
Constant.resolve(@expected, ::Module) === yield
|
|
61
32
|
end
|
|
62
33
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# @return [String] a human-readable description of the matcher
|
|
34
|
+
# @return [String]
|
|
66
35
|
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)
|
|
36
|
+
"be a kind of #{Constant.label(@expected)}"
|
|
79
37
|
end
|
|
80
38
|
end
|
|
81
39
|
end
|