Mock an API before it exists
You are building against an endpoint that does not exist yet, or exists and is unreliable. You want it to answer now, and you want it to answer badly on demand, because the interesting bugs are in what your client does when things go wrong.
Start from Getting started if you have no workspace yet.
The one rule to read first
Almost everything people get wrong here comes from not knowing this:
A template renders to text. That text is the value — as a string — unless it starts with
[or{and parses as JSON, in which case the parsed structure is the value.
Learn the rule and a whole class of mistake disappears. Here is the same intention written both ways.
This works. The template renders to something that parses as JSON, so the response contains a real array:
- path: /list
method: GET
responses:
- status: 200
headers:
content-type: application/json
body:
data: '[{% for i in range(3) %}{"id":"ord_{{i}}","note":"row {{i}}"}{% if not loop.last %},{% endif %}{% endfor %}]'
$ curl -s http://127.0.0.1:4123/list
{"data":[{"id":"ord_0","note":"row 0"},{"id":"ord_1","note":"row 1"},{"id":"ord_2","note":"row 2"}]}
This does not. A YAML block scalar emitting - item lines renders to a
string of YAML source, which is not YAML the client will parse — it is text:
body:
data: |
{% for i in range(3) %}
- id: ord_{{i}}
{% endfor %}
$ curl -s http://127.0.0.1:4123/broken
{"data":"\n- id: ord_0\n\n- id: ord_1\n\n- id: ord_2\n"}
A client calling .map() on that gets nothing, and the failure appears in your
code rather than in the mock. Swap in the single-quoted JSON form.
The states worth building
An empty list
body:
data: []
total: 0
hasMore: false
The state most clients get wrong, and the cheapest to add.
A large list
The loop above with a bigger range. Ten thousand elements is fine and stays readable in the file, because the file holds the loop rather than the data.
An error status
Give the response a scenario: tag and activate it when you want it:
- status: 403
scenario: forbidden
headers:
content-type: application/json
body:
error: forbidden
message: You do not have access to this order
apifae scenario set forbidden
A scenario: tag declares nothing on its own — the scenario has to exist in
scenarios/*.yaml too. apifae scenario create writes one, and
Scenario files has the format.
A slow response
- status: 200
delay_ms: 30000
body: { }
Thirty seconds is past most default client timeouts, which is the point: it is how you find out whether yours has one.
A malformed payload
Sometimes you need to hand the client bytes that are not valid JSON at all — to
prove your error handling survives a truncated response. Templating cannot do
this, because a template that renders valid JSON gives you valid JSON. Use
raw_body, which is written to the socket verbatim and is never templated:
- status: 200
headers:
content-type: application/json
raw_body: '{"data":[{"id":"ord_1","status":'
$ curl -s http://127.0.0.1:4123/malformed
{"data":[{"id":"ord_1","status":
Your client should throw. If it does not, you have found something.
raw_body_base64 covers bytes that are not valid UTF-8. A response may set at
most one body field.
Making tests deterministic
This is the section that decides whether the tool is usable with Playwright, Cypress or a snapshot suite.
By default uuid(), now() and the fake_* helpers are random, which is
the right default and the wrong thing for a test that snapshots a response:
$ curl -s http://127.0.0.1:4123/ids
{"at":"2026-08-27T11:45:00.732423+00:00","id":"67090475-2f41-4ce0-a78a-f9317f82728b"}
$ curl -s http://127.0.0.1:4123/ids
{"at":"2026-08-27T11:45:00.737830+00:00","id":"e529face-212f-41f1-9d1c-8435cf45733b"}
Pass a seed and they are reproducible — now() freezes, and the generated values
repeat:
$ apifae up --seed 42
$ curl -s http://127.0.0.1:4123/ids
{"at":"2026-01-01T00:00:00+00:00","id":"f0f87ecf-961b-4347-ab24-7c3dbf679019"}
$ curl -s http://127.0.0.1:4123/ids
{"at":"2026-01-01T00:00:00+00:00","id":"f0f87ecf-961b-4347-ab24-7c3dbf679019"}
Identical across processes, not merely within one run, so a CI run matches
what you saw locally. Set it once in apifae.yaml if you would rather not pass
the flag:
server:
seed: 42
A reader who snapshots an unseeded response gets a suite that fails on Tuesday for no reason. Seed it.
When a template is wrong
A mock whose template fails to render is served as 500, with the error in the
body. That is deliberate: a test asserting status === 200 should fail on a
broken mock rather than pass on an error envelope.
In CI, refuse to start at all:
apifae up --strict
--strict makes validation errors fatal. Without it, up prints the errors
and starts serving anyway; with it, the server refuses to start, so a broken
workspace stops the pipeline instead of serving 500s to a suite that then reports
thirty failures with no obvious cause.
Two things it does not do:
- Warnings are not errors under
--strict. A mock the spec does not describe prints a warning and the server still starts. Only the errors are fatal. - It refuses when there is no spec at all.
--strictmeans "check the mocks against the contract", and a workspace where neither theschemas:globs nor the root filenames turn one up has no contract to check against — so it fails for that reason alone, whatever state its mocks are in.
See Troubleshooting for the specific errors and what they mean.