Mock the GitHub API โ€” test Octokit integrations without rate limits or PATs

Integration tests that hit api.github.com have three problems. Unauthenticated requests get 60 per hour per IP (verified live: x-ratelimit-limit: 60) โ€” one busy CI runner exhausts that before lunch. Authenticating fixes the quota but means a personal access token in every CI job โ€” a real credential, with real permissions, in your least-protected environment. And either way, your tests now depend on live GitHub data that changes under you: octocat/Hello-World's stargazers_count moved while this guide was being written.

The part everyone skips: you can't make the real API fail on purpose. The 403 rate-limit response is the single most important GitHub error your integration must survive, and there's no way to order one up โ€” you meet it for the first time in production. This guide builds a GitHub-shaped mock in about a minute, points Octokit's baseUrl at it, and stages the failures on cue. Every claim below was verified with a real 5-test Octokit suite against the live mock before publishing. No signup, no PAT.

1. What the real API returns (verified live)

# the error shapes your client must parse โ€” curled from api.github.com while writing this:
# 404:  {"message":"Not Found","documentation_url":"https://docs.github.com/rest/repos/repos#get-a-repository","status":"404"}
# 401:  {"message":"Bad credentials","documentation_url":"https://docs.github.com/rest","status":"401"}
# rate limit exceeded (403): {"message":"API rate limit exceeded for <ip>. (But here's
#   the good news: Authenticated requests get a higher rate limit. ...)","documentation_url":"..."}
# every response carries: x-ratelimit-limit / -remaining / -used / -reset headers

2. Build the mock (one paste)

Custom routes with :params reproduce GitHub's URL structure, and per-route headers reproduce the x-ratelimit-* family. Three routes cover a typical read integration:

BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
     -d '{"name":"github-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')

# GET /repos/:owner/:repo โ€” GitHub-shaped repo, owner/name templated from the URL
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"GET","path":"/repos/:owner/:repo","status":200,
  "headers":{"x-ratelimit-limit":"5000","x-ratelimit-remaining":"4999","x-github-media-type":"github.v3; format=json"},
  "body":"{\"id\":1296269,\"name\":\"{{params.repo}}\",\"full_name\":\"{{params.owner}}/{{params.repo}}\",\"private\":false,\"owner\":{\"login\":\"{{params.owner}}\",\"id\":583231,\"type\":\"User\"},\"html_url\":\"https://github.com/{{params.owner}}/{{params.repo}}\",\"description\":\"Mocked by Mockbird\",\"fork\":false,\"stargazers_count\":3745,\"forks_count\":12,\"open_issues_count\":42,\"default_branch\":\"main\",\"visibility\":\"public\",\"updated_at\":\"{{now}}\"}"}'

# GET /repos/:owner/:repo/issues โ€” an issues array w/ number, labels, user
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"GET","path":"/repos/:owner/:repo/issues","status":200,
  "body":"[{\"id\":101,\"number\":1347,\"title\":\"Found a bug\",\"state\":\"open\",\"user\":{\"login\":\"octocat\",\"id\":1},\"labels\":[{\"name\":\"bug\",\"color\":\"d73a4a\"}],\"comments\":3,\"created_at\":\"2026-01-01T00:00:00Z\",\"body\":\"I have a problem with this.\"}]"}'

# GET /limited/:owner/:repo โ€” GitHub's exact rate-limit refusal, always
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"GET","path":"/limited/:owner/:repo","status":403,
  "headers":{"x-ratelimit-limit":"60","x-ratelimit-remaining":"0","retry-after":"60"},
  "body":"{\"message\":\"API rate limit exceeded for 203.0.113.7. (But here'"'"'s the good news: Authenticated requests get a higher rate limit.)\",\"documentation_url\":\"https://docs.github.com/rest/overview/rate-limits-for-the-rest-api\"}"}'

3. Point Octokit at it

Octokit has first-class support for this โ€” baseUrl is a constructor option. This is the actual test file run against the mock above (5/5 passed, Node 22, octokit latest):

import { Octokit } from "octokit";

const octokit = new Octokit({
  baseUrl: "https://mockbird.mockbird.workers.dev/m/<PID>",
  throttle: {                      // surface rate limits to the test instead of auto-retrying
    onRateLimit: () => false,
    onSecondaryRateLimit: () => false,
  },
});

const repo = await octokit.rest.repos.get({ owner: "octocat", repo: "hello-world" });
// repo.data.full_name === "octocat/hello-world"   (templated from the URL params)
// repo.headers["x-ratelimit-limit"] === "5000"

const issues = await octokit.rest.issues.listForRepo({ owner: "octocat", repo: "hello-world" });
// issues.data[0].number === 1347, labels[0].name === "bug"

Your production code doesn't change โ€” read the base URL from an env var and every octokit.rest.* call, pagination helper, and plugin works against the mock. The same override exists in most GitHub clients (github.enterprise-url patterns, go-github's WithEnterpriseURLs, PyGithub's base_url).

4. The rate-limit drills

The refusal itself. Calling the /limited/โ€ฆ route makes Octokit throw a RequestError with status 403, GitHub's real refusal message in the body, and x-ratelimit-remaining: 0 + retry-after readable on err.response.headers โ€” verified. A detail worth the price of admission: Octokit's built-in throttle plugin recognized the mock's headers and offered to retry (that's why the test config returns false) โ€” the mock speaks the dialect well enough to trigger the SDK's real rate-limit machinery.

Fail once, then recover. Octokit passes unknown request options through as query params, so Mockbird's simulation params ride along without touching your URL strings:

// first call throws 403, the retry succeeds โ€” deterministic, not flaky
await octokit.request("GET /repos/{owner}/{repo}", {
  owner: "octocat", repo: "hello-world",
  mock_seq: "403,200", mock_seq_key: "worker-1",
});

A live quota that runs out. ?mock_ratelimit=2 allows 2 requests per fixed 60-second window per client, then 429s with Retry-After and x-ratelimit-limit/remaining/reset โ€” the same header family GitHub uses, so the backoff code you're testing reads real values (verified: a rapid Octokit burst threw 429 with retry-after: 10 readable on the error; note the window is clock-aligned, so use mock_seq when you need exact per-request determinism). Add ?mock_delay=2000 (measured 2.15s) for slow-API timeout tests.

5. Webhook handlers

GitHub signs webhook deliveries with X-Hub-Signature-256: sha256=<HMAC-SHA256 of the raw body>. Mockbird's outbound webhooks use the identical scheme โ€” sha256=-prefixed HMAC-SHA256 of the raw body, shared secret โ€” under the header name X-Mockbird-Signature. So you can exercise your HMAC verification path with real signed deliveries (change one header name in test config), and use a project's request bin as the receiver to inspect exactly what your own service sends. Honest scope note: we don't emit GitHub's event payload shapes โ€” for replaying real event JSON to a local handler, GitHub's own gh webhook forward / smee.io are the right tools.

Honest comparison

Real api.github.comnock / MSW@octokit/fixturesMockbird
Real data, real validationโœ” the source of truthโœ— your stubs~ recorded real responsesโœ— your shapes
Works without a PAT in CI~ 60 req/h then 403โœ”โœ”โœ”
Reachable outside one JS process (curl, other services, staging config)โœ”โœ— in-process onlyโœ— Node onlyโœ” hosted URL
Fail on cue (403/429/sequences/latency)โœ—โœ” hand-writtenโœ— replay onlyโœ” query params
Deterministic across runsโœ— live data driftsโœ”โœ”โœ”
Setupnonecode per testfixture recording3 curl routes, hosted

Straight answer: for unit tests inside one Node process, nock or MSW is lighter โ€” no network at all. Use Mockbird when the mock needs to be a URL: several services or languages sharing one fake GitHub, a staging environment that shouldn't hold a PAT, curl-able repro cases, or rate-limit/retry drills you want identical on every run. And keep one small smoke test against the real API โ€” a mock can't tell you GitHub changed a field.

Notes & limits

Create yours

The paste block in section 2 is the whole setup โ€” or open the dashboard and click the routes together. Every project also gets a request inspector (assert what your code actually called), GraphQL, and snapshot fixtures.