matchi 4.2.0 → 6.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 +96 -227
- data/lib/matchi/be.rb +10 -65
- data/lib/matchi/be_a_kind_of.rb +21 -116
- data/lib/matchi/be_an_instance_of.rb +24 -113
- data/lib/matchi/be_within/of.rb +57 -0
- data/lib/matchi/be_within.rb +17 -134
- data/lib/matchi/change/delta.rb +56 -0
- data/lib/matchi/change/from.rb +21 -61
- data/lib/matchi/change/from_to.rb +46 -0
- data/lib/matchi/change/to.rb +17 -81
- data/lib/matchi/change.rb +73 -148
- data/lib/matchi/constant.rb +55 -0
- data/lib/matchi/eq.rb +24 -65
- data/lib/matchi/match.rb +29 -64
- data/lib/matchi/number.rb +37 -0
- data/lib/matchi/predicate.rb +40 -139
- data/lib/matchi/raise_exception.rb +83 -105
- data/lib/matchi/satisfy.rb +21 -96
- data/lib/matchi.rb +22 -60
- metadata +18 -20
- data/lib/matchi/change/by.rb +0 -137
- data/lib/matchi/change/by_at_least.rb +0 -158
- data/lib/matchi/change/by_at_most.rb +0 -171
- data/lib/matchi/change/from/to.rb +0 -132
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f6e92545e779fe4ef4a899d77213b30b26480bb4ee4daef969b1e1ea60ee7e5
|
|
4
|
+
data.tar.gz: a407ea85374dd3242d28da5609fe37320200879edb46c877a13dba1f3e377d66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b48397862d2c8d6c8582e23445cf8c73f03f714db6bd7a73cd950f6ccc8149f8488440a827c7e31c220927e2dd7c294cc8726cd2afa1c5fb99dad2f2d8bec930
|
|
7
|
+
data.tar.gz: 5160fa06299005d522a20e8f805bd16ae5bddec4341728b92f15bbfe64c8582d756328326c29c6868636c99a9f87af74d4ee81f93e9b61f2d20a6b692f0db0cb
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
|
@@ -4,288 +4,157 @@
|
|
|
4
4
|
[](https://rubydoc.info/github/fixrb/matchi/main)
|
|
5
5
|
[](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
|
|
8
8
|
|
|
9
|
-
![A
|
|
10
|
-
|
|
11
|
-
## Key Features
|
|
12
|
-
|
|
13
|
-
- **Framework Agnostic**: Easily integrate with any Ruby testing framework
|
|
14
|
-
- **Security-Focused Design**: Built with robust type checking for most matchers
|
|
15
|
-
- **Simple Integration**: Minimal setup required to get started
|
|
16
|
-
- **Extensible**: Create custom matchers with just a few lines of code
|
|
17
|
-
- **Comprehensive**: Rich set of built-in matchers for common testing scenarios
|
|
18
|
-
- **Well Documented**: Extensive documentation with clear examples and implementation details
|
|
19
|
-
- **Thread Safe**: Immutable matchers design ensures thread safety in concurrent environments
|
|
20
|
-
|
|
21
|
-
### Security Considerations for Predicate Matchers
|
|
22
|
-
|
|
23
|
-
While most Matchi matchers are designed to resist type spoofing, predicate matchers (`Matchi::Predicate`) rely on Ruby's dynamic method dispatch system and can be vulnerable to method overriding:
|
|
24
|
-
|
|
25
|
-
```ruby
|
|
26
|
-
# Example of predicate matcher vulnerability:
|
|
27
|
-
matcher = Matchi::Predicate.new(:be_empty)
|
|
28
|
-
array = []
|
|
29
|
-
|
|
30
|
-
# Method overriding can defeat the matcher
|
|
31
|
-
def array.empty?
|
|
32
|
-
false
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
matcher.match? { array } # => false (Even though array is empty!)
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
This limitation is inherent to Ruby's dynamic nature when working with predicate methods. If your tests require strict security guarantees, consider using direct state verification matchers instead of predicate matchers.
|
|
39
|
-
|
|
40
|
-
## What is a Matchi Matcher?
|
|
41
|
-
|
|
42
|
-
A Matchi matcher is a simple Ruby object that follows a specific contract:
|
|
43
|
-
|
|
44
|
-
1. **Core Interface**: Every matcher must implement a `match?` method that:
|
|
45
|
-
- Accepts a block as its only parameter
|
|
46
|
-
- Executes that block to get the actual value
|
|
47
|
-
- Returns a boolean indicating if the actual value matches the expected criteria
|
|
48
|
-
|
|
49
|
-
2. **Optional Description**: Matchers can implement a `to_s` method that returns a human-readable description of the match criteria
|
|
50
|
-
|
|
51
|
-
Here's the simplest possible matcher:
|
|
52
|
-
|
|
53
|
-
```ruby
|
|
54
|
-
module Matchi
|
|
55
|
-
class SimpleEqual
|
|
56
|
-
def initialize(expected)
|
|
57
|
-
@expected = expected
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def match?
|
|
61
|
-
raise ArgumentError, "a block must be provided" unless block_given?
|
|
62
|
-
|
|
63
|
-
@expected == yield
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def to_s
|
|
67
|
-
"equal #{@expected.inspect}"
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Usage:
|
|
73
|
-
matcher = Matchi::SimpleEqual.new(42)
|
|
74
|
-
matcher.match? { 42 } # => true
|
|
75
|
-
matcher.match? { "42" } # => false
|
|
76
|
-
matcher.to_s # => "equal 42"
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
This design provides several benefits:
|
|
80
|
-
- **Lazy Evaluation**: The actual value is only computed when needed via the block
|
|
81
|
-
- **Encapsulation**: Each matcher is a self-contained object with clear responsibilities
|
|
82
|
-
- **Composability**: Matchers can be easily combined and reused
|
|
83
|
-
- **Testability**: The contract is simple and easy to verify
|
|
9
|
+

|
|
84
10
|
|
|
85
11
|
## Installation
|
|
86
12
|
|
|
87
|
-
Add to your Gemfile:
|
|
88
|
-
|
|
89
13
|
```ruby
|
|
14
|
+
# Gemfile
|
|
90
15
|
gem "matchi"
|
|
91
16
|
```
|
|
92
17
|
|
|
93
|
-
|
|
18
|
+
Matchi requires Ruby 3.3 or later and has no runtime dependency.
|
|
94
19
|
|
|
95
|
-
|
|
96
|
-
gem install matchi
|
|
97
|
-
```
|
|
20
|
+
## Usage
|
|
98
21
|
|
|
99
|
-
|
|
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`.
|
|
100
23
|
|
|
101
24
|
```ruby
|
|
102
25
|
require "matchi"
|
|
103
26
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
|
|
109
|
-
Matchi::BeAKindOf.new(String).match? { 42 } # => false
|
|
110
|
-
|
|
111
|
-
# State change verification
|
|
112
|
-
array = []
|
|
113
|
-
Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
|
|
27
|
+
matcher = Matchi::Eq.new(42)
|
|
28
|
+
matcher.match? { 42 } # => true
|
|
29
|
+
matcher.match? { 41 } # => false
|
|
30
|
+
matcher.to_s # => "eq 42"
|
|
114
31
|
```
|
|
115
32
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
### Value Comparison
|
|
33
|
+
Every `to_s` completes the sentence "expected ACTUAL to …", which is all a test framework needs:
|
|
119
34
|
|
|
120
35
|
```ruby
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# Object identity (equal?)
|
|
126
|
-
symbol = :test
|
|
127
|
-
Matchi::Be.new(symbol).match? { symbol } # => true
|
|
128
|
-
string = "test"
|
|
129
|
-
Matchi::Be.new(string).match? { string.dup } # => false
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### Type Checking
|
|
133
|
-
|
|
134
|
-
```ruby
|
|
135
|
-
# Inheritance-aware type checking
|
|
136
|
-
Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true
|
|
137
|
-
Matchi::BeAKindOf.new(Integer).match? { 42.0 } # => false
|
|
36
|
+
# Minitest
|
|
37
|
+
def assert_matches(matcher, actual)
|
|
38
|
+
assert matcher.match? { actual }, "expected #{actual.inspect} to #{matcher}"
|
|
39
|
+
end
|
|
138
40
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
41
|
+
def refute_matches(matcher, actual)
|
|
42
|
+
refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
|
|
43
|
+
end
|
|
142
44
|
|
|
143
|
-
|
|
144
|
-
Matchi::
|
|
145
|
-
Matchi::BeAnInstanceOf.new("Float").match? { 42.0 } # => true
|
|
45
|
+
assert_matches Matchi::BeAKindOf.new(Numeric), 42
|
|
46
|
+
refute_matches Matchi::Match.new(/^foo/), "bar"
|
|
146
47
|
```
|
|
147
48
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
```ruby
|
|
151
|
-
# Verify exact changes
|
|
152
|
-
counter = 0
|
|
153
|
-
Matchi::Change.new(counter, :to_i).by(5).match? { counter += 5 } # => true
|
|
49
|
+
Negation is the framework's job: Matchi has no `not_to`, it answers a boolean.
|
|
154
50
|
|
|
155
|
-
|
|
156
|
-
Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
|
|
51
|
+
## Matchers
|
|
157
52
|
|
|
158
|
-
|
|
159
|
-
|
|
53
|
+
```ruby
|
|
54
|
+
require "matchi"
|
|
160
55
|
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
Matchi::
|
|
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
|
|
164
60
|
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
Matchi::
|
|
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
|
|
168
65
|
|
|
169
|
-
#
|
|
170
|
-
|
|
171
|
-
Matchi::Change.new(counter, :to_i).to(5).match? { counter = 5 } # => true
|
|
172
|
-
```
|
|
66
|
+
# Numeric proximity; a non-Numeric actual value fails
|
|
67
|
+
Matchi::BeWithin.new(0.01).of(0.3).match? { 0.1 + 0.2 } # => true
|
|
173
68
|
|
|
174
|
-
|
|
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))
|
|
175
74
|
|
|
176
|
-
|
|
177
|
-
#
|
|
178
|
-
Matchi::
|
|
179
|
-
Matchi::
|
|
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
|
|
180
79
|
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
Matchi::
|
|
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
|
|
184
86
|
|
|
185
|
-
|
|
186
|
-
Matchi::
|
|
187
|
-
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
|
|
188
90
|
```
|
|
189
91
|
|
|
190
|
-
|
|
92
|
+
`BeWithin.new(delta)` and `Change#from(value)` are builders, not matchers: they do not respond to `match?` until `of` or `to` completes them.
|
|
191
93
|
|
|
192
|
-
|
|
193
|
-
# Verify raised exceptions
|
|
194
|
-
Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
|
|
195
|
-
|
|
196
|
-
# Works with inheritance
|
|
197
|
-
Matchi::RaiseException.new(StandardError).match? { raise ArgumentError } # => true
|
|
198
|
-
|
|
199
|
-
# Using exception class names
|
|
200
|
-
Matchi::RaiseException.new("ArgumentError").match? { raise ArgumentError } # => true
|
|
201
|
-
```
|
|
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, or not an exception class for `RaiseException`). A block is always required by `match?`. Whatever the actual value or an observed method raises propagates; `RaiseException` rescues what its block raises, within the limits below, not what its message filter or the resolution of its name raises.
|
|
202
95
|
|
|
203
|
-
|
|
96
|
+
`RaiseException` rescues an exception of the expected class, and any `StandardError`, `ScriptError`, `SystemStackError` or `SystemExit` (the exceptions [Defi](https://fixrb.dev/gems/defi/) keeps too), which it then compares with the expected class. Anything else, an `Interrupt`, a signal, a `NoMemoryError`, the exception with which an enclosing `Timeout` ends its block, goes through as it would with any other matcher, unless that is the class expected: `RaiseException.new(Interrupt)` rescues an `Interrupt`. An unexpected `exit` inside the block is the wrong exception, so `false`, not the end of the process.
|
|
204
97
|
|
|
205
|
-
|
|
206
|
-
# Delta comparisons
|
|
207
|
-
Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
|
|
208
|
-
Matchi::BeWithin.new(2).of(10).match? { 9 } # => true
|
|
209
|
-
```
|
|
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`.
|
|
210
99
|
|
|
211
|
-
##
|
|
100
|
+
## Writing a matcher
|
|
212
101
|
|
|
213
|
-
|
|
102
|
+
Any object that responds to `match?` with a block and to `to_s` is a matcher:
|
|
214
103
|
|
|
215
104
|
```ruby
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
yield.positive?
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def to_s
|
|
223
|
-
"be positive"
|
|
224
|
-
end
|
|
105
|
+
class BePositive
|
|
106
|
+
def match?
|
|
107
|
+
yield.positive?
|
|
225
108
|
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
matcher = Matchi::BePositive.new
|
|
229
|
-
matcher.match? { 42 } # => true
|
|
230
|
-
matcher.match? { -1 } # => false
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
## Security Best Practices
|
|
234
|
-
|
|
235
|
-
### Proper Value Comparison Order
|
|
236
109
|
|
|
237
|
-
|
|
110
|
+
def to_s
|
|
111
|
+
"be positive"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
238
114
|
|
|
239
|
-
|
|
240
|
-
# GOOD: Expected value controls the comparison
|
|
241
|
-
expected_value.eql?(actual_value)
|
|
242
|
-
# BAD: Actual value controls the comparison
|
|
243
|
-
actual_value.eql?(expected_value)
|
|
115
|
+
BePositive.new.match? { 42 } # => true
|
|
244
116
|
```
|
|
245
117
|
|
|
246
|
-
|
|
118
|
+
Every matcher in Matchi follows two rules, which are worth keeping in your own:
|
|
247
119
|
|
|
248
|
-
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.
|
|
249
122
|
|
|
250
|
-
|
|
251
|
-
# Example of how comparison can be compromised
|
|
252
|
-
class MaliciousString
|
|
253
|
-
def eql?(other)
|
|
254
|
-
true # Always returns true regardless of actual equality
|
|
255
|
-
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.
|
|
256
124
|
|
|
257
|
-
|
|
258
|
-
true # Always returns true regardless of actual equality
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
actual = MaliciousString.new
|
|
263
|
-
expected = "expected string"
|
|
264
|
-
actual.eql?(expected) # => true (incorrect result!)
|
|
265
|
-
expected.eql?(actual) # => false (correct result)
|
|
266
|
-
```
|
|
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.
|
|
267
126
|
|
|
268
|
-
|
|
127
|
+
## Differences from RSpec
|
|
269
128
|
|
|
270
|
-
|
|
271
|
-
# Implementation in Matchi::Eq
|
|
272
|
-
def match?
|
|
273
|
-
@expected.eql?(yield) # Expected value controls the comparison
|
|
274
|
-
end
|
|
275
|
-
```
|
|
129
|
+
If you come from RSpec or Minitest, these will surprise you:
|
|
276
130
|
|
|
277
|
-
|
|
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. It does not rescue an `Interrupt`, a signal, a `NoMemoryError` or the exception with which an enclosing `Timeout` ends its block, unless that is the class expected, where `raise_error` rescues every `Exception`.
|
|
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.
|
|
278
137
|
|
|
279
|
-
|
|
138
|
+
## Changes in 6.0
|
|
280
139
|
|
|
281
|
-
|
|
140
|
+
- `RaiseException` no longer rescues every `Exception`: it rescues the expected class, and any `StandardError`, `ScriptError`, `SystemStackError` or `SystemExit`, which it compares with the expected class. An `Interrupt`, a signal, a `NoMemoryError` or the exception with which an enclosing `Timeout` ends its block goes through unless that is the class expected, where 5.0 reported it as a failed match. `RaiseException.new(Exception)` still rescues everything, as asked.
|
|
141
|
+
- The message filter of `RaiseException` and `Match` compare a plain copy of the actual `String`: a subclass could answer `valid_encoding?` for it and turn the comparison into an error.
|
|
282
142
|
|
|
283
|
-
|
|
284
|
-
# Add to your Gemfile
|
|
285
|
-
gem "matchi-fix"
|
|
286
|
-
```
|
|
143
|
+
## Changes in 5.0
|
|
287
144
|
|
|
288
|
-
|
|
145
|
+
- `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.
|
|
146
|
+
- `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.
|
|
147
|
+
- `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.
|
|
148
|
+
- `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.
|
|
149
|
+
- `Satisfy` requires a `true` or `false` answer (`TypeError`), as `Predicate` already did. The error names the class of the answer, never its `inspect`.
|
|
150
|
+
- `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.
|
|
151
|
+
- `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).
|
|
152
|
+
- `BeWithin` without `of` and `Change#from` without `to` no longer respond to `match?`.
|
|
153
|
+
- `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`.
|
|
154
|
+
- 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.
|
|
155
|
+
- Error messages are rewritten and all read `X must be … (got: …)`; `Predicate::PREFIXES` is gone.
|
|
156
|
+
- The `Matchi` helper module removed in 4.2 is not back: `Matchi` is a namespace. Frameworks define their own helpers.
|
|
157
|
+
- Ruby 3.3 or later.
|
|
289
158
|
|
|
290
159
|
## Versioning
|
|
291
160
|
|
data/lib/matchi/be.rb
CHANGED
|
@@ -1,83 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Matchi
|
|
4
|
-
# Identity matcher
|
|
4
|
+
# Identity matcher: passes when the actual value is the very same object as
|
|
5
|
+
# the expected one (`equal?`).
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# @example Basic usage with symbols
|
|
11
|
-
# matcher = Matchi::Be.new(:foo)
|
|
12
|
-
# matcher.match? { :foo } # => true
|
|
13
|
-
# matcher.match? { :bar } # => false
|
|
14
|
-
#
|
|
15
|
-
# @example Identity comparison with strings
|
|
16
|
-
# string = "test"
|
|
17
|
-
# matcher = Matchi::Be.new(string)
|
|
18
|
-
# matcher.match? { string } # => true
|
|
19
|
-
# matcher.match? { string.dup } # => false
|
|
20
|
-
# matcher.match? { "test" } # => false
|
|
21
|
-
#
|
|
22
|
-
# @example With mutable objects
|
|
23
|
-
# array = [1, 2, 3]
|
|
24
|
-
# matcher = Matchi::Be.new(array)
|
|
25
|
-
# matcher.match? { array } # => true
|
|
26
|
-
# matcher.match? { array.dup } # => false
|
|
27
|
-
# matcher.match? { [1, 2, 3] } # => false
|
|
28
|
-
#
|
|
29
|
-
# @see https://ruby-doc.org/core/Object.html#method-i-equal-3F
|
|
30
|
-
# @see Matchi::Eq
|
|
7
|
+
# @example
|
|
8
|
+
# Matchi::Be.new(:foo).match? { :foo } # => true
|
|
9
|
+
# Matchi::Be.new("a").match? { "a".dup } # => false (another object)
|
|
31
10
|
class Be
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# @api public
|
|
35
|
-
#
|
|
36
|
-
# @param expected [#equal?] The expected identical object
|
|
37
|
-
#
|
|
38
|
-
# @return [Be] a new instance of the matcher
|
|
39
|
-
#
|
|
40
|
-
# @example
|
|
41
|
-
# string = "test"
|
|
42
|
-
# Be.new(string) # Match only the same string instance
|
|
11
|
+
# @param expected [Object] the expected object
|
|
43
12
|
def initialize(expected)
|
|
44
13
|
@expected = expected
|
|
45
14
|
end
|
|
46
15
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# by comparing object IDs. Two objects are considered identical only if they are
|
|
51
|
-
# the exact same instance in memory.
|
|
52
|
-
#
|
|
53
|
-
# @api public
|
|
54
|
-
#
|
|
55
|
-
# @yield [] Block that returns the object to check
|
|
56
|
-
# @yieldreturn [Object] The object to verify identity with
|
|
57
|
-
#
|
|
58
|
-
# @return [Boolean] true if both objects are the same instance
|
|
59
|
-
#
|
|
60
|
-
# @raise [ArgumentError] if no block is provided
|
|
61
|
-
#
|
|
62
|
-
# @example
|
|
63
|
-
# obj = "test"
|
|
64
|
-
# matcher = Be.new(obj)
|
|
65
|
-
# matcher.match? { obj } # => true
|
|
66
|
-
# matcher.match? { obj.dup } # => false
|
|
16
|
+
# @yieldreturn [Object] the actual value
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
# @raise [ArgumentError] when no block is given
|
|
67
19
|
def match?
|
|
68
20
|
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
69
21
|
|
|
70
22
|
@expected.equal?(yield)
|
|
71
23
|
end
|
|
72
24
|
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
# @api public
|
|
76
|
-
#
|
|
77
|
-
# @return [String] A string describing what this matcher verifies
|
|
78
|
-
#
|
|
79
|
-
# @example
|
|
80
|
-
# Be.new("test").to_s # => 'be "test"'
|
|
25
|
+
# @return [String]
|
|
81
26
|
def to_s
|
|
82
27
|
"be #{@expected.inspect}"
|
|
83
28
|
end
|