Matchi is a lightweight, framework-agnostic Ruby library that provides a comprehensive set of expectation matchers for elegant and secure testing. Its design focuses on simplicity, security, and extensibility.
- Framework Agnostic: Easily integrate with any Ruby testing framework
- Security-Focused Design: Built with robust type checking for most matchers
- Simple Integration: Minimal setup required to get started
- Extensible: Create custom matchers with just a few lines of code
- Comprehensive: Rich set of built-in matchers for common testing scenarios
- Well Documented: Extensive documentation with clear examples and implementation details
- Thread Safe: Immutable matchers design ensures thread safety in concurrent environments
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:
# Example of predicate matcher vulnerability:
matcher = Matchi::Predicate.new(:be_empty)
array = []
# Method overriding can defeat the matcher
def array.empty?
false
end
matcher.match? { array } # => false (Even though array is empty!)
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.
A Matchi matcher is a simple Ruby object that follows a specific contract:
-
Core Interface: Every matcher 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
-
Optional Description: Matchers can implement a
to_s
method that returns a human-readable description of the match criteria
Here's the simplest possible matcher:
module Matchi
class SimpleEqual
def initialize(expected)
@expected = expected
end
def match?
raise ArgumentError, "a block must be provided" unless block_given?
@expected == yield
end
def to_s
"equal #{@expected.inspect}"
end
end
end
# Usage:
matcher = Matchi::SimpleEqual.new(42)
matcher.match? { 42 } # => true
matcher.match? { "42" } # => false
matcher.to_s # => "equal 42"
This design provides several benefits:
- Lazy Evaluation: The actual value is only computed when needed via the block
- Encapsulation: Each matcher is a self-contained object with clear responsibilities
- Composability: Matchers can be easily combined and reused
- Testability: The contract is simple and easy to verify
Add to your Gemfile:
gem "matchi"
Or install directly:
gem install matchi
require "matchi"
# Basic equality matching
Matchi::Eq.new("hello").match? { "hello" } # => true
# Type checking
Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
Matchi::BeAKindOf.new(String).match? { 42 } # => false
# State change verification
array = []
Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
# Exact equality (eql?)
Matchi::Eq.new("test").match? { "test" } # => true
Matchi::Eq.new([1, 2, 3]).match? { [1, 2, 3] } # => true
# Object identity (equal?)
symbol = :test
Matchi::Be.new(symbol).match? { symbol } # => true
string = "test"
Matchi::Be.new(string).match? { string.dup } # => false
# Inheritance-aware type checking
Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true
Matchi::BeAKindOf.new(Integer).match? { 42.0 } # => false
# Exact type matching
Matchi::BeAnInstanceOf.new(Float).match? { 42.0 } # => true
Matchi::BeAnInstanceOf.new(Numeric).match? { 42.0 } # => false
# Using class names as strings
Matchi::BeAKindOf.new("Numeric").match? { 42.0 } # => true
Matchi::BeAnInstanceOf.new("Float").match? { 42.0 } # => true
# Verify exact changes
counter = 0
Matchi::Change.new(counter, :to_i).by(5).match? { counter += 5 } # => true
# Verify minimum changes
Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
# Verify maximum changes
Matchi::Change.new(counter, :to_i).by_at_most(5).match? { counter += 3 } # => true
# Track value transitions
string = "hello"
Matchi::Change.new(string, :to_s).from("hello").to("HELLO").match? { string.upcase! } # => true
# Simple change detection
array = []
Matchi::Change.new(array, :length).match? { array << 1 } # => true
# Check final state only
counter = 0
Matchi::Change.new(counter, :to_i).to(5).match? { counter = 5 } # => true
# Regular expressions
Matchi::Match.new(/^test/).match? { "test_string" } # => true
Matchi::Match.new(/^\d{3}-\d{2}$/).match? { "123-45" } # => true
# Custom predicates with Satisfy
Matchi::Satisfy.new { |x| x.positive? && x < 10 }.match? { 5 } # => true
Matchi::Satisfy.new { |arr| arr.all?(&:even?) }.match? { [2, 4, 6] } # => true
# Built-in predicates
Matchi::Predicate.new(:be_empty).match? { [] } # => true
Matchi::Predicate.new(:have_key, :name).match? { { name: "Alice" } } # => true
# Verify raised exceptions
Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
# Works with inheritance
Matchi::RaiseException.new(StandardError).match? { raise ArgumentError } # => true
# Using exception class names
Matchi::RaiseException.new("ArgumentError").match? { raise ArgumentError } # => true
# Delta comparisons
Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
Matchi::BeWithin.new(2).of(10).match? { 9 } # => true
Creating custom matchers is straightforward:
module Matchi
class BePositive
def match?
yield.positive?
end
def to_s
"be positive"
end
end
end
matcher = Matchi::BePositive.new
matcher.match? { 42 } # => true
matcher.match? { -1 } # => false
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)
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
The matchi-fix gem extends Matchi with support for testing against Fix specifications. It provides a seamless integration between Matchi's matcher interface and Fix's powerful specification system.
# Add to your Gemfile
gem "matchi-fix"
This extension adds a Fix
matcher that allows you to verify implementation conformance to Fix test specifications across different testing frameworks like Minitest and RSpec.
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é