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_toneeds ascenario: machine:statetag on the same response. The transition is keyed off the machine named before the colon, so atransition_toon 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.