Matchi

Version Yard documentation Ruby RuboCop License

This library provides a comprehensive set of matchers for testing different aspects of your code. Each matcher is designed to handle specific verification needs while maintaining a clear and expressive syntax.

A Rubyist juggling between Matchi letters

Project goals

  • Adding matchers should be as simple as possible.
  • Being framework agnostic and easy to integrate.
  • Avoid false positives/negatives due to malicious actual values.

Installation

Add this line to your application's Gemfile:

gem "matchi"

And then execute:

bundle install

Or install it yourself as:

gem install matchi

Overview

Matchi provides a collection of damn simple expectation matchers.

Usage

To make Matchi available:

require "matchi"

All examples here assume that this has been done.

Anatomy of a matcher

A Matchi matcher is a simple Ruby object that follows these requirements:

  1. It must implement a match? method that:

    • Accepts a block as its only parameter
    • Executes that block to get the actual value
    • Returns a boolean indicating if the actual value matches the expected criteria
  2. Optionally, it may implement:

    • to_s: Returns a human-readable description of the match criteria

Built-in matchers

Here is the collection of generic matchers.

Basic Comparison Matchers

Be

Checks for object identity using Ruby's equal? method.

Matchi::Be.new(:foo).match? { :foo } # => true (same object)
Matchi::Be.new("test").match? { "test" } # => false (different objects)
Eq

Verifies object equivalence using Ruby's eql? method.

Matchi::Eq.new("foo").match? { "foo" } # => true (equivalent content)
Matchi::Eq.new([1, 2]).match? { [1, 2] } # => true (equivalent arrays)

Type and Class Matchers

BeAnInstanceOf

Verifies exact class matching (no inheritance).

Matchi::BeAnInstanceOf.new(String).match? { "test" }  # => true
Matchi::BeAnInstanceOf.new(Integer).match? { 42 }     # => true
Matchi::BeAnInstanceOf.new(Numeric).match? { 42 }     # => false (Integer, not Numeric)
BeAKindOf

Verifies class inheritance and module inclusion.

Matchi::BeAKindOf.new(Numeric).match? { 42 }    # => true (Integer inherits from Numeric)
Matchi::BeAKindOf.new(Numeric).match? { 42.0 }  # => true (Float inherits from Numeric)

Pattern Matchers

Match

Tests string patterns against regular expressions.

Matchi::Match.new(/^foo/).match? { "foobar" }  # => true
Matchi::Match.new(/\d+/).match? { "abc123" }   # => true
Matchi::Match.new(/^foo/).match? { "barfoo" }  # => false
Satisfy

Provides custom matching through a block.

Matchi::Satisfy.new { |x| x.positive? && x < 10 }.match? { 5 } # => true
Matchi::Satisfy.new { |x| x.start_with?("test") }.match? { "test_file" } # => true

State Change Matchers

Change

Verifies state changes in objects with multiple variation methods:

Basic Change
array = []
Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
Minimum Change
counter = 0
Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
Maximum Change
value = 10
Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 } # => true
From-To Change
string = "hello"
Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! } # => true
To-Only Change
number = 1
Matchi::Change.new(number, :to_i).to(5).match? { number = 5 } # => true

Numeric Matchers

BeWithin

Checks if a number is within a specified range of an expected value.

Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 }  # => true
Matchi::BeWithin.new(5).of(100).match? { 98 }     # => true

Behavior Matchers

RaiseException

Verifies that code raises specific exceptions.

Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
Matchi::RaiseException.new(NameError).match? { undefined_variable } # => true
Predicate

Creates matchers for methods ending in ?.

Using be_ prefix
Matchi::Predicate.new(:be_empty).match? { [] }  # => true (calls empty?)
Matchi::Predicate.new(:be_nil).match? { nil }   # => true (calls nil?)
Using have_ prefix
Matchi::Predicate.new(:have_key, :foo).match? { { foo: 42 } } # => true (calls has_key?)

Custom matchers

Custom matchers could easily be added to Matchi module to express more specific expectations.

A Be the answer matcher:

module Matchi
  class BeTheAnswer
    def match?
      expected.equal?(yield)
    end

    private

    def expected
      42
    end
  end
end

matcher = Matchi::BeTheAnswer.new
matcher.match? { 42 } # => true

A Be prime matcher:

require "prime"

module Matchi
  class BePrime
    def match?
      Prime.prime?(yield)
    end
  end
end

matcher = Matchi::BePrime.new

matcher.match? { 42 } # => false

A Start with matcher:

module Matchi
  class StartWith
    def initialize(expected)
      @expected = expected
    end

    def match?
      /\A#{@expected}/.match?(yield)
    end
  end
end

matcher = Matchi::StartWith.new("foo")
matcher.match? { "foobar" } # => true

Best Practices

Proper Value Comparison Order

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:

# GOOD: Expected value controls the comparison
expected_value.eql?(actual_value)

# BAD: Actual value controls the comparison
actual_value.eql?(expected_value)

Why This Matters

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:

# Example of how comparison can be compromised
class MaliciousString
  def eql?(other)
    true  # Always returns true regardless of actual equality
  end

  def ==(other)
    true  # Always returns true regardless of actual equality
  end
end

actual = MaliciousString.new
expected = "expected string"

actual.eql?(expected)      # => true (incorrect result!)
expected.eql?(actual)      # => false (correct result)

This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the Eq matcher:

# Implementation in Matchi::Eq
def match?
  @expected.eql?(yield) # Expected value controls the comparison
end

Contact

Versioning

Matchi follows Semantic Versioning 2.0.

License

The gem is available as open source under the terms of the MIT License.

Sponsors

This project is sponsored by Sashité