Wanderland

Wanderland Core: RSpec Integration

The third lens on the scenarios: block. The verify_route boundary runs the probe live in HTTP/CLI mode; --type test iterates the batch in CI; rspec exercises the same scenarios in the dev inner loop. One config, one shape comparison, three ways to surface the result.

One spec file per site

A site's entire scenarios suite collapses to one rspec file:

# spec/scenarios_spec.rb
require "spec_helper"

runtime = Wanderland.boot(File.join(__dir__, "..", "config.yml"))
Wanderland::Scenario.rspec_describe!(runtime)

rspec_describe! emits one describe block per route and one it block per scenario. Each it calls Scenario#verify(runtime: runtime) — the same primitive the --type test runner uses — and fails the example with the ShapeMatcher output when the live response drifts from the declared expected shape.

Explicit form

The helper is literal sugar over this loop; sites that want to wrap the scenarios with their own before / around blocks can inline it:

# spec/scenarios_spec.rb
require "spec_helper"

runtime   = Wanderland.boot(File.join(__dir__, "..", "config.yml"))
scenarios = Wanderland::Scenario.from_config(runtime)

RSpec.describe "config scenarios" do
  scenarios.each do |route_name, by_name|
    describe route_name do
      by_name.each do |scenario_name, scenario|
        it scenario_name do
          failures = scenario.verify(runtime: runtime)
          expect(failures).to be_empty, -> { failures.join("\n") }
        end
      end
    end
  end
end

The helper and the explicit loop produce the same rspec tree. Pick whichever suits the site's test conventions.

Scenario shape (recap)

Scenarios live under config.yml's scenarios: block, keyed by route name:

scenarios:
  greet:
    alice:
      description: "Canonical greeting format"
      input:
        params:
          name: alice
      expected:
        greeting: "Hello, alice!"

For rspec and --type test, both input: and expected: must be present — the synthetic request needs the input, and the probe needs the shape to match against. Probe-only scenarios (no input:) are valid for live X-Wanderland-Verify use but are skipped/defaulted in the test lens since there's no request to synthesise.

What a failure looks like

The failure message carries the ShapeMatcher output verbatim — the path into the response, the expected shape, and what was actually there:

Failures:

  1) config scenarios greet alice
     Failure/Error: expect(failures).to be_empty, -> { failures.join("\n") }
       greeting: expected "Hello, alice!", got "Howdy alice!"
     # ./spec/scenarios_spec.rb:11:in 'block'

Finished in 0.02 seconds (files took 0.3 seconds to load)
3 examples, 1 failure

Same output shape as the --type test runner's report, same output shape as the 422 body the live probe returns. The three lenses differ only in how they surface the result — not in what they measure.

The three lenses

                       scenarios: block in config.yml
                                    │
                 ┌──────────────────┼──────────────────┐
                 ▼                  ▼                  ▼
              rspec            --type test       X-Wanderland-Verify
           (dev loop)         (CI / batch)       (live / probe)
                 │                  │                  │
                 └──────────────────┼──────────────────┘
                                    ▼
                       Scenario#verify(runtime:)
                                    │
                                    ▼
                       Dispatch.invoke with
                       X-Wanderland-Verify header
                                    │
                                    ▼
                       verify_route boundary
                       runs ShapeMatcher
                                    │
                                    ▼
                       pass / halt 422 /
                       failures array

Every lens funnels through Scenario#verify(runtime:), which funnels through the normal request chain, which funnels through the verify_route boundary's ShapeMatcher call. One comparison, one source of truth for what "scenario X passes" means.

API

Wanderland::Scenario.from_config(runtime)

Walks runtime.config.domain["scenarios"] and returns a nested hash:

scenarios = Wanderland::Scenario.from_config(runtime)
# =>
# {
#   "greet" => {
#     "alice"          => #<Wanderland::Scenario mode=:route route_name="greet" scenario_name="alice">,
#     "default_output" => #<Wanderland::Scenario ...>,
#     "polite_tone"    => #<Wanderland::Scenario ...>
#   },
#   "hello" => {
#     "default" => #<Wanderland::Scenario ...>
#   }
# }

Each value is a Scenario in :route mode — the same class that powers --type test.

Scenario#verify(runtime:)

Dispatches a synthetic request through Dispatch.invoke with X-Wanderland-Verify: <scenario_name> set. Returns a failures array; empty means pass.

failures = scenario.verify(runtime: runtime)
# => []                                           (pass)
# => ["greeting: expected \"Hello, alice!\", ..."] (failure)

The verify_route boundary inside the chain does the shape comparison and halts with 422 on mismatch; Scenario#verify reads the crossing's type_addr, extracts the scenario failures from the halt payload, and returns them.

Scenario.rspec_describe!(runtime)

Sugar over the explicit loop. Emits an RSpec.describe block with one it per scenario.

Wiring rspec inside an HTTP/CLI service

Most wanderland-core sites already have a config.yml at the root and an spec/spec_helper.rb from the Ruby conventions. Add the scenarios spec:

# spec/spec_helper.rb
require "wanderland-core"

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end
end
# spec/scenarios_spec.rb
require "spec_helper"

runtime = Wanderland.boot(File.join(__dir__, "..", "config.yml"))
Wanderland::Scenario.rspec_describe!(runtime)

bundle exec rspec spec/scenarios_spec.rb and every configured scenario runs as an rspec example. CI wires to the same command; the --type test runner is the thin CLI path to the same verification.

Best practices

Write scenarios, not unit tests, for contract behaviour. A scenario with input: + expected: covers the route's contract and lives as a probe fixture, a CI test case, a game level, and documentation. Per-class rspec files for boundary internals stay inline; anything about the route's shape goes to a scenario.

Use rspec_describe! unless you need the explicit loop. Fewer moving parts, harder to drift out of sync with the runner.

Keep the scenarios spec as the only file that boots the full runtime. Per-boundary specs that stub dependencies stay fast; the scenarios spec is where the real engine comes up. If the scenarios suite is slow to boot, that's diagnostic for the boot sequence, not a reason to split scenarios.

Match the runner and the probe on the same scenarios. Don't have scenarios that only run in test mode or only run live — the three lenses work best when they see identical inputs. If a scenario can't run synthetically, question whether it should be a scenario at all.

Fail loud, fail specific. Scenario names should describe the contract they enforce (alice, default_output, polite_tone, regression_ticket_742). A failing test that says greet.regression_ticket_742 failed routes to the right fix faster than greet.basic failed.

Source