Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
# Gemfile
gem "matchi"Matchi requires Ruby 3.3 or later and has no runtime dependency.
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.
require "matchi"
matcher = Matchi::Eq.new(42)
matcher.match? { 42 } # => true
matcher.match? { 41 } # => false
matcher.to_s # => "eq 42"Every to_s completes the sentence "expected ACTUAL to …", which is all a test framework needs:
# Minitest
def assert_matches(matcher, actual)
assert matcher.match? { actual }, "expected #{actual.inspect} to #{matcher}"
end
def refute_matches(matcher, actual)
refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
end
assert_matches Matchi::BeAKindOf.new(Numeric), 42
refute_matches Matchi::Match.new(/^foo/), "bar"Negation is the framework's job: Matchi has no not_to, it answers a boolean.
require "matchi"
# Identity (equal?) and equivalence (eql?)
Matchi::Be.new(:foo).match? { :foo } # => true
Matchi::Eq.new("foo").match? { "foo" } # => true
Matchi::Eq.new(1).match? { 1.0 } # => false
# Types: a module, or its name resolved when the matcher runs
Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
Matchi::BeAnInstanceOf.new(:Integer).match? { 42 } # => true
Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false
# Numeric proximity; a non-Numeric actual value fails
Matchi::BeWithin.new(0.01).of(0.3).match? { 0.1 + 0.2 } # => true
# Patterns and predicates
Matchi::Match.new(/^foo/).match? { "foobar" } # => true
Matchi::Satisfy.new { |v| v > 41 }.match? { 42 } # => true
Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
Matchi::Predicate.new(:have_key, :a).match? { { a: 1 } } # => true (calls has_key?(:a))
# Exceptions, by class or by name, optionally with a message (String or Regexp)
Matchi::RaiseException.new(ZeroDivisionError).match? { 1 / 0 } # => true
Matchi::RaiseException.new("Errno::ENOENT").match? { File.read("/missing") } # => true
Matchi::RaiseException.new(ZeroDivisionError, /by 0/).match? { 1 / 0 } # => true
# State changes, observed through a public method
list = []
Matchi::Change.new(list, :length).match? { list << 1 } # => true
Matchi::Change.new(list, :length).by(2).match? { list.push(1, 2) } # => true
Matchi::Change.new(list, :length).by_at_least(1).match? { list << 1 } # => true
Matchi::Change.new(list, :length).by_at_most(1).match? { list << 1 } # => true
list = []
Matchi::Change.new(list, :length).to(1).match? { list << 1 } # => true
Matchi::Change.new(list, :length).from(1).to(2).match? { list << 1 } # => trueBeWithin.new(delta) and Change#from(value) are builders, not matchers: they do not respond to match? until of or to completes them.
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.
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.
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.
Any object that responds to match? with a block and to to_s is a matcher:
class BePositive
def match?
yield.positive?
end
def to_s
"be positive"
end
end
BePositive.new.match? { 42 } # => trueEvery matcher in Matchi follows two rules, which are worth keeping in your own:
- The expected value receives the comparison.
Matchi::Eq.new(a).match? { b }evaluatesa.eql?(b), neverb.eql?(a), so an actual value that redefineseql?cannot pass; sinceHash#eql?hands the comparison back to an operand of another class,Eqalso requires the two values to be of the same class. Classes are compared withModule#===, the class of the actual value is read withKernel#classbound to it, andBeWithinonly subtracts core numbers, for the same reason. - A predicate answers
trueorfalse.SatisfyandPredicateraise aTypeErroron any other value instead of treating it as truthy.
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.
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.
If you come from RSpec or Minitest, these will surprise you:
Eqis RSpec'seql, not itseq: it compares witheql?, so1is not1.0, and a value object that only defines==never matches. Defineeql?andhashon it, or useSatisfy.Integer#==hands the comparison to the other operand, which is why Matchi does not use it.Matchtakes aRegexponly. For a literal substring,Matchi::Match.new(Regexp.new(Regexp.escape("a.b"))).RaiseExceptionisraise_error; it needs an exception class, or its name, and takes an optional message.Change#todemands a change: a value already equal to the target does not pass.Changewithout a refinement needs the observed method to return a value, not the mutated object.Predicateonly calls a method of the actual value:be_nilcallsnil?, butbe_truthy,be_falseyandbe_aare not predicates and do not exist. UseBe.new(nil),Satisfy,BeAKindOf.- There is no
and,or,not_toorall: compose in your framework, with plain booleans.
BeAKindOf,BeAnInstanceOfandRaiseExceptionaccept 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 forBeAnInstanceOfand an exception class forRaiseException.BeAnInstanceOfrejects a module that is not a class.RaiseExceptioncompares withModule#===and always answerstrueorfalse;Module#<=could answerniland let the exception control the comparison. A non-exception class is rejected at construction. An expected message,StringorRegexp, can be given.Matchaccepts aRegexponly, and fails on an actual value that is neither aStringnor aSymbol, or whose encoding is invalid or incompatible. With aStringpattern, the actual value would have become the regular expression.Eq,Change#toandfrom … torequire the actual value to be of the class of the expected value, so thatHash#eql?cannot hand the comparison to the object under test.Satisfyrequires atrueorfalseanswer (TypeError), asPredicatealready did. The error names the class of the answer, never itsinspect.PredicateandChangereach the object under test throughKernel#public_send: a private or protected predicate is now aNoMethodError.Changeaccepts aStringmethod name and no longer checksrespond_to?at construction; a missing method is aNoMethodErrorwhen the matcher runs.BeWithin::Ofonly subtracts anInteger, aFloat, aRationalor aBigDecimal; any other actual value fails, including a user-definedNumericthat could control the subtraction throughcoerce. Deltas and expected values, there and inby,by_at_least,by_at_most, must be finite real numbers (NaN, infinities andComplexare rejected).BeWithinwithoutofandChange#fromwithouttono longer respond tomatch?.from … tocompares witheql?, liketoandEqalready did;torequires a change; both always evaluate the block.Changeraises aTypeErrorwhen the observed method returns the same unfrozen object twice: in particular, asserting that an attribute holding an unfrozen object (aTime, anArray) did not change now raises instead of answeringfalse. The threebyrefinements are one class,Change::Delta;Change::From::ToisChange::FromTo.- Descriptions are stable:
Change#to_snames the observed class and method (change Array#length), an anonymous class readsan anonymous class, and noto_scallsinspecton an object under test. - Error messages are rewritten and all read
X must be … (got: …);Predicate::PREFIXESis gone. - The
Matchihelper module removed in 4.2 is not back:Matchiis a namespace. Frameworks define their own helpers. - Ruby 3.3 or later.
Matchi follows Semantic Versioning 2.0.
The gem is available as open source under the terms of the MIT License.
This project is sponsored by Sashité
