Deep comparison engine for verifying actual output against expected shapes. Used by the Scenario infrastructure to verify boundary outputs in YAML-driven tests.
Matchers are registered strategies. The built-in set covers common assertions. Custom matchers can be registered for domain-specific checks.
ShapeMatcher walks two trees — actual and expected — in parallel. Plain values are compared directly. Hashes are compared key by key. Arrays are compared element by element.
When a key in the expected hash matches a registered matcher name, it's dispatched to that matcher's strategy instead of being treated as a field comparison. This means matchers compose naturally with field checks in the same expected block:
expected:
repos:
count: 3
first:
name: kremis
status: ok
Here count and first are matchers applied to the repos array. status is a plain field comparison. name inside first is a plain comparison on the first element. The matcher registry decides which keys are matchers and which are fields.
| Matcher | Operand | Checks |
|---|---|---|
count |
integer | actual.size == operand |
items:
count: 3
Work on numeric values directly, or on .size for collections.
| Matcher | Checks |
|---|---|
gte |
actual >= operand |
lte |
actual <= operand |
gt |
actual > operand |
lt |
actual < operand |
score:
gte: 50
lte: 100
| Matcher | Operand | Checks |
|---|---|---|
matches |
regex string | actual.to_s.match?(Regexp.new(operand)) |
version:
matches: "^v\\d+\\.\\d+"
| Matcher | Operand | Checks |
|---|---|---|
contains |
single value | actual.include?(operand) |
includes |
array of values | all values present in actual |
excludes |
array of values | no values present in actual |
colors:
contains: red
includes: [red, blue]
excludes: [yellow]
| Matcher | Operand | Checks |
|---|---|---|
first |
expected shape | actual.first matches the shape |
last |
expected shape | actual.last matches the shape |
The operand is itself a shape — it recurses. You can nest matchers inside first/last.
stages:
first: build
last:
has_key: status
| Matcher | Operand | Checks |
|---|---|---|
any |
expected shape | at least one element in actual matches the shape |
users:
any:
role: admin
| Matcher | Operand | Checks |
|---|---|---|
keys |
array of strings | actual.keys matches exactly (order-independent) |
has_key |
single key | actual contains this key |
config:
has_key: host
keys: [host, port]
| Matcher | Operand | Checks |
|---|---|---|
not |
expected shape | passes when the inner match fails |
status:
not: stopped
| Matcher | Operand | Checks |
|---|---|---|
empty |
true/false | true = must be empty, false = must be non-empty |
errors:
empty: true
results:
empty: false
A matcher is a callable registered by name. It receives five arguments:
| Argument | What it is |
|---|---|
actual |
The value being tested |
operand |
The value from the expected YAML |
path |
Dot-separated path for error messages |
failures |
Array to append failure strings to |
matcher |
The ShapeMatcher instance (for recursive checks) |
Create a file that registers your matchers and require it from spec_helper.rb:
# spec/support/custom_matchers.rb
require "wanderland/shape_matcher"
Wanderland::ShapeMatcher.register(:between) do |actual, operand, path, failures, _|
min = operand["min"] || operand[:min]
max = operand["max"] || operand[:max]
val = actual.is_a?(Numeric) ? actual : actual.to_f
unless val >= min && val <= max
failures << "#{path}: expected between #{min}..#{max}, got #{val}"
end
end
Wanderland::ShapeMatcher.register(:type) do |actual, operand, path, failures, _|
expected_type = operand.to_s
actual_type = actual.class.name.downcase
unless actual_type == expected_type || actual.is_a?(Object.const_get(operand))
failures << "#{path}: expected type #{expected_type}, got #{actual_type}"
end
end
# spec/spec_helper.rb
require "wanderland-core"
require_relative "support/custom_matchers"
Then use them in YAML scenarios like any built-in:
name: "custom matcher test"
operation: passthrough
input:
temperature: 36.7
expected:
temperature:
between:
min: 35.0
max: 42.0
Same register call works anywhere. Matchers registered after boot are available to all subsequent ShapeMatcher instances.
Wanderland::ShapeMatcher.register(:starts_with) do |actual, operand, path, failures, _|
unless actual.to_s.start_with?(operand.to_s)
failures << "#{path}: expected to start with #{operand.inspect}, got #{actual.inspect}"
end
end