Scenario files

Work with scenario files

Your client has to cope with the API being down, a token being expired, and an order that is not there. Those are the paths that break in production, and they are the hardest ones to reach from a mock you would otherwise be editing halfway through a test run.

A scenario is a name for a set of responses. Turning one on swaps them in, so an endpoint that normally answers 200 answers 500, or 401, or whatever state you are testing. No second workspace, and no edit to a mock file.

State machines go one step further: returning a response can move the machine, so a login call actually logs you in and the next request sees that.

Start from Getting started if you have no workspace yet.

Define one

A scenario definition is a name and a description, in any file under scenarios/:

# scenarios/scenarios.yaml
- name: error-mode
  description: "All endpoints return errors"

Tag the responses it owns

A response carrying a scenario field is only reachable while that scenario is active. Leave one response untagged and it answers the rest of the time:

# mocks/health.yaml
- path: /health
  method: GET
  responses:
    - scenario: error-mode
      status: 500
      body: { error: "Service unavailable" }
    - status: 200
      body: { status: "ok" }

Turn it on

Either at startup:

$ apifae up --scenario error-mode

or against a server that is already running:

$ apifae scenario set error-mode

Either way GET /health now answers 500 Service unavailable instead of 200 ok.

Driving it from the command line

scenario list prints what the workspace defines, and the current state of any state machine in it:

$ apifae scenario list
Scenarios:
  error-mode
    All endpoints return errors

State machines:
  auth-flow
    states: logged-out → logged-in → session-expired

scenario set activates one against a running server, and scenario reset deactivates it and returns every state machine to its initial state:

$ apifae scenario set error-mode

# Override the port. Without this, the port is resolved exactly as `apifae up`
# resolves it: `server.port` from apifae.yaml, else the built-in default (4000).
$ apifae scenario set error-mode --port 9000
$ apifae scenario reset

# Same port resolution as `scenario set`.
$ apifae scenario reset --port 9000

scenario create writes a new definition file, from a template or from a prompt:

# Create with template
$ apifae scenario create maintenance

# Interactive wizard
$ apifae scenario create auth-flow --interactive

Driving it from a test

The running server answers a small control API under /_apifae/, which is what a test suite should reach for rather than shelling out to the CLI between cases.

Read the current state:

GET /_apifae/scenarios
{
  "scenarios": ["error-mode", "auth-flow"],
  "active": null,
  "state_machines": {
    "auth-flow": "logged-out"
  }
}

Activate a scenario, and the reply tells you what was active before — which is what a teardown needs to put it back:

POST /_apifae/scenarios/active
Content-Type: application/json

{"scenario": "error-mode"}
{
  "active": "error-mode",
  "previous": null
}

Deactivate it with a DELETE to the same path. This is the endpoint scenario reset calls, so it does the same two things: clears the active scenario, and returns every state machine to its initial state.

DELETE /_apifae/scenarios/active
{
  "active": null,
  "previous": "error-mode"
}

State machines have their own two endpoints. One reports where every machine currently is, and what states it can be in:

GET /_apifae/state-machines
{
  "auth-flow": {
    "current": "logged-out",
    "states": ["logged-out", "logged-in", "session-expired"]
  }
}

The other sends one machine back to its initial state without touching the active scenario:

POST /_apifae/state-machines/auth-flow/reset
{
  "current": "logged-out"
}

State machines

A plain scenario is a switch you throw. A state machine remembers: the response your mock returns depends on which state the machine is in, and returning that response can move it to another one. That is how you mock a login, a checkout, or anything else where the second request is supposed to behave differently from the first.

Declare the states and where it starts:

# scenarios/auth-flow.yaml
- name: auth-flow
  type: state-machine
  initial: logged-out
  states:
    - logged-out
    - logged-in
    - session-expired

Then tag responses with machine:state, and give the ones that advance the machine a transition_to:

# mocks/auth.yaml
- path: /login
  method: POST
  responses:
    # Only matches when auth-flow is in "logged-out" state
    - scenario: auth-flow:logged-out
      status: 200
      body: { token: "abc123" }
      transition_to: logged-in      # Transitions to "logged-in" after response

    # Only matches when auth-flow is in "logged-in" state
    - scenario: auth-flow:logged-in
      status: 400
      body: { error: "Already logged in" }

- path: /logout
  method: POST
  responses:
    - scenario: auth-flow:logged-in
      status: 200
      body: { message: "Logged out" }
      transition_to: logged-out

    - scenario: auth-flow:logged-out
      status: 400
      body: { error: "Not logged in" }

- path: /profile
  method: GET
  responses:
    - scenario: auth-flow:logged-in
      status: 200
      body:
        id: "{{uuid()}}"
        name: "{{fake_name()}}"
        email: "{{fake_email()}}"

    - scenario: auth-flow:logged-out
      status: 401
      body: { error: "Unauthorized" }

    - scenario: auth-flow:session-expired
      status: 401
      body: { error: "Session expired", code: "SESSION_EXPIRED" }

A transition_to needs a scenario: machine:state tag on the same response. The transition is keyed off the machine named before the colon, so a transition_to on an untagged response — or on one tagged with a plain scenario name and no colon — is silently ignored. Nothing warns you; the machine simply never advances.

- scenario: auth-flow:logged-out
  status: 200
  body: { token: "abc" }
  transition_to: logged-in    # After this response, state becomes "logged-in"

Walked through, the workspace above behaves like this:

# Initial state: logged-out
$ curl http://localhost:4000/profile
# -> 401 Unauthorized

# Login transitions to logged-in
$ curl -X POST http://localhost:4000/login
# -> 200 { "token": "abc123" }

# Now profile works
$ curl http://localhost:4000/profile
# -> 200 { "id": "...", "name": "..." }

# Already logged in
$ curl -X POST http://localhost:4000/login
# -> 400 Already logged in

# Logout transitions back
$ curl -X POST http://localhost:4000/logout
# -> 200 Logged out

# Profile requires auth again
$ curl http://localhost:4000/profile
# -> 401 Unauthorized

Which response wins

Candidates are scored, not ranked in the order you wrote them. Every response that survives scenario filtering and whose when matches is scored, and the highest score wins:

Contribution Points
each matcher in the response's when 5
carrying a scenario tag at all 10

Declaration order only breaks a tie between equal scores.

A specific untagged response can beat a tagged one. Three when matchers score 15; a scenario tag with no when scores 10, so the untagged response wins. If you need a scenario to take precedence, give its response the matchers too rather than relying on the tag.

An untagged response is always a candidate, whatever is active — which is what makes it the fallback:

- path: /api/version
  method: GET
  responses:
    - scenario: error-mode
      status: 500
      body: { error: "Down for maintenance" }

    - status: 200                    # No scenario - always available
      body: { version: "1.0.0" }

Tags and when matchers compose, so one scenario can answer differently depending on the request:

- path: /users/{id}
  method: GET
  responses:
    # Error mode + specific ID
    - scenario: error-mode
      when:
        path.id: "1"
      status: 500
      body: { error: "Database error for user 1" }

    # Error mode + any ID
    - scenario: error-mode
      status: 503
      body: { error: "Service unavailable" }

    # Normal mode + not found
    - when:
        path.id: "999"
      status: 404
      body: { error: "User not found" }

    # Normal mode + success
    - status: 200
      body: { id: "{{path.id}}" }

Where the files go

Anywhere under scenarios/. Every .yaml and .yml below it is loaded, nested directories included, so one file per scenario and one file holding all of them are equally valid:

scenarios/
├── scenarios.yaml       # Multiple scenarios in one file
├── auth-flow.yaml       # One scenario per file
└── test-modes/
    ├── error.yaml       # Nested directories work too
    └── maintenance.yaml

A workspace with all of it

my-project/
├── apifae.yaml
├── mocks/
│   ├── health.yaml
│   ├── auth.yaml
│   └── users.yaml
└── scenarios/
    └── scenarios.yaml

Two plain scenarios and one machine:

# scenarios/scenarios.yaml
- name: error-mode
  description: "Simulate service errors"

- name: maintenance
  description: "Maintenance mode responses"

- name: auth-flow
  type: state-machine
  initial: logged-out
  states:
    - logged-out
    - logged-in
    - session-expired

/health answers three ways, one per scenario, with the untagged 200 for the rest of the time:

# mocks/health.yaml
- path: /health
  method: GET
  responses:
    - scenario: error-mode
      status: 500
      body: { status: "error", message: "Internal server error" }

    - scenario: maintenance
      status: 503
      body: { status: "maintenance", message: "Back soon" }

    - status: 200
      body: { status: "healthy" }
# mocks/auth.yaml
- path: /login
  method: POST
  responses:
    - scenario: auth-flow:logged-out
      status: 200
      body: { token: "{{uuid()}}" }
      transition_to: logged-in

    - scenario: auth-flow:logged-in
      status: 400
      body: { error: "Already authenticated" }

    - status: 200
      body: { token: "{{uuid()}}" }

- path: /me
  method: GET
  responses:
    - scenario: auth-flow:logged-in
      status: 200
      body:
        id: "user-1"
        name: "{{fake_name()}}"

    - scenario: auth-flow:logged-out
      status: 401
      body: { error: "Not authenticated" }

    - scenario: auth-flow:session-expired
      status: 401
      body: { error: "Session expired" }

    - status: 200
      body: { id: "anonymous" }
# Start server
$ apifae up

# Default behavior
$ curl http://localhost:4000/health
# -> 200 { "status": "healthy" }

# Activate error mode
$ apifae scenario set error-mode
$ curl http://localhost:4000/health
# -> 500 { "status": "error", "message": "Internal server error" }

# Reset and test auth flow
$ apifae scenario reset
$ curl http://localhost:4000/me
# -> 401 { "error": "Not authenticated" }

$ curl -X POST http://localhost:4000/login
# -> 200 { "token": "..." }

$ curl http://localhost:4000/me
# -> 200 { "id": "user-1", "name": "..." }

In a test suite

Reset in beforeEach and activate inside the test. The DELETE above puts the state machines back too, so one call is enough to give each case a clean server:

// Jest example
beforeEach(async () => {
  // Reset to clean state
  await fetch('http://localhost:4000/_apifae/scenarios/active', {
    method: 'DELETE'
  });
});

test('handles server errors gracefully', async () => {
  // Activate error scenario
  await fetch('http://localhost:4000/_apifae/scenarios/active', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scenario: 'error-mode' })
  });

  // Test error handling
  const response = await myApiClient.getHealth();
  expect(response.status).toBe(500);
  expect(myApp.showsErrorMessage()).toBe(true);
});

test('auth flow works correctly', async () => {
  // State machine starts at initial state (logged-out)
  let response = await myApiClient.getProfile();
  expect(response.status).toBe(401);

  // Login transitions to logged-in
  await myApiClient.login({ email: 'test@example.com', password: 'secret' });

  // Now profile works
  response = await myApiClient.getProfile();
  expect(response.status).toBe(200);
});

Every field a scenario file accepts

A scenarios/*.yaml file is a list of these. Only name is required.

Field Type Required Default What it does
name string Yes - The name a response's scenario: tag refers to, and the one apifae scenario set takes.
description string No unset Free text, shown by apifae scenario list.
type string No unset state-machine makes this a state machine. Anything else, including leaving it out, is a simple scenario.
initial string No unset State machine only: the state it starts in, and the one apifae scenario reset returns it to.
states list of strings No unset State machine only: every state it may be in. A transition_to naming a state that is not here is a load error.

A response opts into a scenario with its own scenario: field, and moves a machine with transition_to. Both are documented in the Mock file reference.

This manual is generated from the repository. Every command and flag in it is checked against the binary; the marked transcripts are executed.