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.
# 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
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\"}"}'
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).
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.
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.
| Real api.github.com | nock / MSW | @octokit/fixtures | Mockbird | |
|---|---|---|---|---|
| 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 | โ | โ | โ |
| Setup | none | code per test | fixture recording | 3 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.
status + headers, not message text./users/:u, /orgs/:o/repos, /search/โฆ); or model repos/issues as resources for full CRUD with persistence.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.