Your CI job needs a backend to test against. The usual menu:
db.json in the repo, a port mapping, and a health-check loop so tests don't start before the container does.npx json-server db.json & plus a sleep 5. Works until the day the install takes 6 seconds. Background-process management in YAML is nobody's favourite genre.Option five: create an ephemeral hosted mock API at the top of the workflow, delete it at the bottom. Two curl commands, no images, no ports, no waiting for ready. Each run gets its own isolated, seeded, stateful CRUD API โ parallel PRs can't touch each other's data, and there's nothing to clean up if the run is cancelled, because the delete step runs if: always() (and even a leaked project is just an idle mock).
# .github/workflows/test.yml
name: tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create ephemeral mock API
run: |
RESP=$(curl -sf -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' \
-d '{"name":"ci-'$GITHUB_RUN_ID'","preset":"ecommerce"}')
echo "::add-mask::$(echo "$RESP" | jq -r .adminKey)"
echo "MOCK_URL=$(echo "$RESP" | jq -r .baseUrl)" >> "$GITHUB_ENV"
echo "MOCK_ID=$(echo "$RESP" | jq -r .id)" >> "$GITHUB_ENV"
echo "MOCK_ADMIN_KEY=$(echo "$RESP" | jq -r .adminKey)" >> "$GITHUB_ENV"
- name: Run tests
run: |
npm ci
npm test
env:
API_URL: ${{ env.MOCK_URL }}
- name: Delete mock API
if: always()
run: |
curl -sf -X DELETE "https://mockbird.mockbird.workers.dev/api/projects/$MOCK_ID" \
-H "x-admin-key: $MOCK_ADMIN_KEY"
That's the entire integration. No signup, no API token in your repo secrets โ the project is created anonymously and the only credential that exists (its admin key) is born inside the run, masked from the logs on the next line, and dies with the delete step.
What the create step returns (this exact response shape, one flat JSON object):
{"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}
The ecommerce preset seeds products, orders, customers and reviews (115 records) with filtering, sorting, pagination, relations and CORS already on. Other presets: blog, saas โ or define your own schema in the same request. Naming the project ci-$GITHUB_RUN_ID costs nothing and pays off the day you're staring at a failed run and want to poke at the exact data it saw (open $MOCK_URL in a browser โ the project root lists every endpoint).
jq is preinstalled on ubuntu-latest runners, no setup step needed. curl -sf makes the step fail loudly on any non-2xx (without -f, a rate-limit response would sail through and your tests would fail confusingly later). The ::add-mask:: line must come before anything might echo the key.Because the create step runs per job, a matrix gets one independent mock API per leg with no extra work โ Node 20 and Node 22 can write to "the database" simultaneously without racing each other:
test:
strategy:
matrix:
node: [20, 22]
runs-on: ubuntu-latest
steps:
# identical steps โ each leg creates and deletes its own project
If you'd rather share one project across jobs (say, a seed job populates bespoke data first), pass it through job outputs โ and give each parallel consumer its own data scenario by pinning reads to a named snapshot instead of mutating shared state:
# any GET, answered read-only from the "baseline" snapshot โ live data untouched
curl "$MOCK_URL/products?mock_snapshot=baseline"
# or as a header your test framework sets once: X-Mockbird-Snapshot: baseline
Save the snapshot right after seeding (POST /api/projects/$MOCK_ID/snapshots {"name":"baseline"} with the admin key). We verified the sequence this guide ships: snapshot at 30 products โ POST an extra product โ live list grows, snapshot-pinned list still serves the original 30. The deterministic test data guide covers the pattern in depth, including per-worker pinning in Playwright.
CI is exactly where retry/backoff code should be exercised, and almost never is โ because making a real backend fail twice then succeed is awkward. Hosted, it's a query param. mock_seq serves an exact status sequence, one per request:
$ for i in 1 2 3; do curl -s -o /dev/null -w "%{http_code} " \
"$MOCK_URL/products?mock_seq=429,429,200&mock_seq_key=ci-retry"; done
429 429 200
So "our client survives two 500s" becomes a deterministic assertion โ here's the shape of it with curl itself standing in for your client:
$ curl -s --retry 3 --retry-all-errors -o /dev/null -w "final: %{http_code}\n" \
"$MOCK_URL/products?mock_seq=500,500,200&mock_seq_key=ci-retry2"
final: 200
The mock_seq_key scopes the counter so parallel tests don't consume each other's sequence. For a scheduled resilience run, ?mock_chaos=0.3 fails a random 30% of requests with realistic statuses โ a nightly on: schedule job running your normal suite against a chaos-flagged base URL finds the missing catch blocks your green PR runs never hit. Both flags are covered in testing loading and error states.
Nothing above is GitHub-specific โ it's three curl commands and an env var. GitLab flavour:
# .gitlab-ci.yml
test:
image: node:22
script:
- apt-get update -qq && apt-get install -y -qq jq curl
- RESP=$(curl -sf -X POST https://mockbird.mockbird.workers.dev/api/projects
-H 'content-type: application/json'
-d '{"name":"ci-'$CI_PIPELINE_ID'","preset":"ecommerce"}')
- export API_URL=$(echo "$RESP" | jq -r .baseUrl)
- export MOCK_ID=$(echo "$RESP" | jq -r .id)
- export MOCK_ADMIN_KEY=$(echo "$RESP" | jq -r .adminKey)
- npm ci && npm test
after_script:
- 'curl -sf -X DELETE "https://mockbird.mockbird.workers.dev/api/projects/$MOCK_ID" -H "x-admin-key: $MOCK_ADMIN_KEY" || true'
after_script runs even when script fails โ same job as if: always(). (The node image doesn't ship jq, hence the install line; on GitHub's ubuntu-latest you skip it.)
| Service container | npx json-server & | MSW / in-process | Staging env | Mockbird (ephemeral) | |
|---|---|---|---|---|---|
| Setup in the workflow | image + ports + health-check | bg process + sleep/wait loop | test-code-level | none (it exists) | 2 curl steps |
| Works on offline / airgapped runners | โ (if image cached) | โ | โ | โ | โ needs egress |
| Isolated per run / per matrix leg | โ | โ | โ | โ shared state | โ project per job |
| Visible to browser E2E, SSR, non-JS services | โ | โ | โ one process only | โ | โ it's a URL |
| Seeded realistic data, stateful CRUD | your db.json | your db.json | hand-written handlers | real (mutable!) data | โ presets or your schema |
| Failure/latency injection | DIY | DIY middleware | โ in handlers | โ please don't | โ query params |
| Tests real integration (auth, infra, data shape drift) | โ | โ | โ | โ its actual job | โ it's a mock |
| Response latency | ~1 ms | ~1 ms | ~0 ms | network | network (tens of ms) |
Where the others win, they really win: airgapped or egress-restricted runners rule us out entirely; MSW is unbeatable for pure unit layers; and a staging smoke test checks things no mock can (real auth, real schema drift). Many pipelines sensibly run MSW-backed unit tests and a hosted-mock E2E job. One more honest line: projects have a 10,000 requests/day cap โ a full test suite barely dents it, but don't point k6 or a load test at us.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"ci-demo","preset":"ecommerce"}'
โฆrun that locally first if you like โ the response is the same one your workflow will parse. Or create a project in one click and poke at it in the dashboard before wiring the YAML. Free, no signup required. Full docs ยท machine-readable API index.
Framework-specific test wiring lives in the Playwright, Cypress, Jest and Vitest guides โ all of them compose with the $MOCK_URL pattern above via an env-var base URL. Running Postman collections instead of test code? The same ephemeral-project pattern with npx newman run (zero flags โ the generated collection carries its own baseUrl) is in the Newman guide.
Verification: every shell command on this page โ the create/parse/GITHUB_ENV block (run with GITHUB_RUN_ID and GITHUB_ENV set exactly as Actions sets them), the snapshot sequence, both mock_seq demos, and the delete โ was run verbatim against production on 26 Aug 2026 and the outputs shown are real. The YAML scaffolding around them is standard Actions/GitLab syntax we cannot run outside those platforms, so we kept it boring on purpose. If a snippet here doesn't work, that's a bug: tell us.