Deep comparison engine for verifying actual output against expected shapes. The single comparison primitive every scenario lens funnels through — --type test, the X-Wanderland-Verify HTTP probe, the --wanderland-verify CLI probe, and Scenario.rspec_describe! all reach it through the verify_route boundary. Also used directly by the file-based Scenario infrastructure for boundary-level YAML tests in wanderland-core's own suite.
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.
Every route-level verify path funnels through the same ShapeMatcher call:
--type test batch runner — wanderland --type test config.yml walks the scenarios: block, dispatches each scenario synthetically through the normal chain, and the verify_route boundary calls ShapeMatcher against expected:. Exit 0 on pass, non-zero on any failure. See --type test.X-Wanderland-Verify: <scenario-name> on a live request. verify_route reads the prior crossing's result, runs ShapeMatcher, halts 422 on mismatch with the failure list in the response body. See Verify Route.--wanderland-verify=<scenario-name> on a Commander dispatch. Same boundary, same matcher, non-zero exit on mismatch. See Verify Route.Wanderland::Scenario.rspec_describe!(runtime) emits one example per configured scenario; each calls the same Scenario#verify(runtime:) the three lenses above use. See RSpec Integration.One matcher instance per comparison. Failure strings are identical across every lens — the ShapeMatcher output that --type test prints is the same string rspec surfaces and the same string the 422 body carries.
| 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) |
Matchers register into the global Wanderland::ShapeMatcher registry. Every lens builds ShapeMatcher instances against the same registry — register the matcher before the first dispatch and all four lenses pick it up automatically.
Register at boot. Put the registration in a file the service loads before the runtime starts serving — typically alongside lib/<app>.rb:
# lib/my_app/matchers.rb
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(: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
# lib/my_app.rb
require "wanderland-core"
require_relative "my_app/matchers"
module MyApp
end
Use in the scenarios: block like any built-in:
scenarios:
sensor_read:
healthy_range:
description: "Reading within operational bounds"
input:
params: { reading: 36.7 }
expected:
temperature:
between:
min: 35.0
max: 42.0
status:
starts_with: "OK"
Every lens sees the matcher:
# Batch — custom matchers land in every scenario comparison
bundle exec wanderland --type test config.yml
# Live HTTP probe
curl -H 'X-Wanderland-Verify: healthy_range' http://localhost:9294/sensor_read
# CLI probe
bundle exec wanderland --type cli config.yml sensor_read reading=36.7 --wanderland-verify=healthy_range
# RSpec via the shared helper
bundle exec rspec
Because Scenario#verify(runtime:) dispatches through the normal chain, the registered matcher fires inside verify_route regardless of which lens initiated the request.
A matcher that's only needed at test time — a fixture-specific helper, a comparison that has no production use — registers in spec/spec_helper.rb so it exists during bundle exec rspec but not when the HTTP server is running:
# spec/spec_helper.rb
require "wanderland-core"
Wanderland::ShapeMatcher.register(:fixture_uuid) do |actual, operand, path, failures, _|
unless actual.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
failures << "#{path}: expected UUID, got #{actual.inspect}"
end
end
The rspec lens picks it up; the other three don't.
The same register call works anywhere. Matchers registered after boot are available to all subsequent ShapeMatcher instances — useful for integration tests that exercise registration itself.
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
wanderland.dev