โ† All guides

A mock API in GitHub Actions โ€” two curls, no service containers

Your CI job needs a backend to test against. The usual menu:

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).

1. The whole workflow

# .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).

Notes that save you a debug cycle: 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.

2. Matrix jobs: isolation is automatic

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.

3. Test your retry logic in the same pipeline

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.

4. The same idea in GitLab CI

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.)

5. Honest comparison

Service containernpx json-server &MSW / in-processStaging envMockbird (ephemeral)
Setup in the workflowimage + ports + health-checkbg process + sleep/wait looptest-code-levelnone (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 CRUDyour db.jsonyour db.jsonhand-written handlersreal (mutable!) dataโœ” presets or your schema
Failure/latency injectionDIYDIY 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 msnetworknetwork (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.

6. Two security notes

Try it now

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.