Mock an API

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. --strict means "check the mocks against the contract", and a workspace where neither the schemas: 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.

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