Scenarios & state

A scenario is a small finite-state machine expressed as a chain of stubs: each state gates on a request matcher, responds, and (optionally) transitions to the next state. Under the hood it’s still just stubs — scenarioName/required_scenario_state/new_scenario_state on the wire — the scenario() builder just spares you writing that out by hand.

Building a scenario

import { imposter, scenario, onPost, onGet, created, ok, status } from '@rift-vs/rift';

const checkout = scenario('checkout')
  .startingAt('empty')
  .when('empty', onPost('/cart')).respond(created()).goTo('has-items')
  .when('has-items', onPost('/checkout')).respond(ok('done')).goTo('done')
  .when('done', onGet('/receipt')).respond(status(200)); // terminal: no goTo

const users = await engine.create(imposter('checkout-flow').record().scenario(checkout));
  • .startingAt(state) documents the FSM’s initial state; .build() checks it against the first .when()’s state and throws if they disagree.
  • .when(state, stub) snapshots (build()s) the passed stub’s predicates immediately — mutating the builder you passed in afterward never rewrites the already-committed step.
  • .respond(...) sets the response cycle for the currently open step; multiple responses cycle within that state, same as .willReturn() on a plain stub.
  • .goTo(next) sets the transition; omitting it (as in the terminal 'done' step above) gates the step without ever transitioning further.
  • .build() flattens the whole chain to a wire.Stub[]. Calling .respond()/.goTo() with no open .when() throws rather than silently dropping the call.

Attaching a scenario to an imposter

imposter().scenario(builder) appends builder.build()’s stubs onto the same ordered stub list that .stub() pushes onto — at the time .scenario() is called, not at imposter().build() time. Interleaved .stub()/.scenario() calls preserve call order rather than batching all scenario stubs after all plain stubs:

imposter('mixed')
  .stub(onGet('/health').willReturn(ok()))   // stub #1
  .scenario(checkout)                         // scenario steps land next, in order
  .stub(onGet('/version').willReturn(ok('1.0'))); // then this

Grouping without an FSM

A bare scenarioName tag with no state machine — just for grouping/filtering stubs — doesn’t need the scenario() builder at all:

onGet('/x').inScenario('smoke-tests')

Use scenario() only when you actually need required_scenario_state/new_scenario_state transitions.

Runtime scenario controls

Once an imposter is running, you can inspect and drive scenario state directly through the handle — useful for setting up a test at a specific state without replaying every transition:

await users.scenarios();                             // [{ name: 'checkout', state: 'has-items' }, ...]
await users.setScenarioState('checkout', 'has-items'); // jump straight to a state
await users.resetScenarios();                          // back to each scenario's initial state

All three take an optional trailing flowIdscenarios(flowId?), setScenarioState(name, state, flowId?), resetScenarios(flowId?) — to scope the call to one flow when the imposter uses per-flow state (below). Equivalent Mountebank admin routes: GET .../scenarios, PUT .../scenarios/{name}, DELETE .../scenarios.

Flow state

By default, scenario state (and response cycling, and the recorded-request journal) is scoped to the whole imposter. flowState(...) lets you scope it per logical flow instead — one test run, one session, one tenant — sharing a single imposter across many independent callers:

imposter('users')
  .flowState({ backend: 'inmemory', ttlSeconds: 600, flowIdSource: 'header:X-Flow-Id' })
  // sugar for the flowIdSource line above:
  .flowIdFromHeader('X-Flow-Id')

flowState(cfg) shallow-merges into a single _rift.flowState block across repeated calls (later calls add/override individual keys, earlier keys survive), so flowState(...) and flowIdFromHeader(...) compose freely regardless of call order. flowIdSource is either 'imposter_port' — one flow for the whole imposter — or `header:${name}` — deriving the flow id from a request header, so each caller that sends a distinct X-Flow-Id gets its own scenario state, verification journal, and response-cycle position on the same running imposter. redis: { url, poolSize?, keyPrefix? } moves the backend to Redis for state shared across multiple Rift processes (see Migrating from Mountebank §Persistence for when you’d reach for that over a sticky load balancer).

See the API reference §5.9 for the exact flowState config shape, and §Scenarios in the migration guide for the Mountebank-JSON equivalents of each concept here.