Testing a Slack integration honestly requires a surprising amount of ceremony: a workspace, an app config, a bot token with the right scopes, and โ for anything Slack calls you about โ a public URL. Point CI at a real channel and every test run posts to actual humans. And the API has a famous trap baked in: Web API errors come back as HTTP 200 with "ok":false in the body. A naive if (res.ok) HTTP check โ or a generic retry-on-non-200 wrapper โ sails right past a failed call.
This guide builds a Slack-shaped mock in about a minute, points the official @slack/web-api SDK at it with its built-in slackApiUrl option, and stages the failures on cue โ including a 429 that triggers the SDK's real rate-limit machinery. It also covers the receiving side: a stand-in for incoming webhooks, an Events API endpoint that passes Slack's URL verification, and a slash-command receiver. Every claim was verified against the live mock with the official SDK before publishing. No tokens, no signup.
# curled from slack.com/api while writing this โ the dialect your client must parse:
# no token: HTTP 200 {"ok":false,"error":"not_authed"}
# bad token: HTTP 200 {"ok":false,"error":"invalid_auth","warning":"missing_charset",...}
# dead webhook: HTTP 404 no_team (hooks.slack.com โ plain text, not JSON)
# rate limited: HTTP 429 + Retry-After header (the one place Slack uses HTTP status)
# success: HTTP 200 {"ok":true,"channel":"Cโฆ","ts":"1503435956.000247","message":{โฆ}}
Note the asymmetry: almost everything is HTTP 200 โ ok in the JSON is the only truth โ except rate limiting, which is a real 429. Your error handling needs both paths, and the mock below produces both.
Custom routes with response templating reproduce Slack's shapes; {{body.x}} works for both JSON and form-encoded posts (Slack's SDK sends forms). Four routes cover a typical bot:
BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"slack-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')
# chat.postMessage โ echoes channel + text back, Slack-shaped
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/api/chat.postMessage","status":200,
"body":"{\"ok\":true,\"channel\":\"{{body.channel}}\",\"ts\":\"{{ts}}\",\"message\":{\"user\":\"U0MOCKBIRD\",\"type\":\"message\",\"text\":\"{{body.text}}\",\"ts\":\"{{ts}}\"}}"}'
# incoming-webhook stand-in โ returns Slack's literal plain-text "ok"
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' \
-d '{"method":"POST","path":"/services/*","status":200,"contentType":"text/plain","body":"ok"}'
# Events API endpoint โ answers Slack's url_verification challenge
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' \
-d '{"method":"POST","path":"/slack/events","status":200,"body":"{\"challenge\":\"{{body.challenge}}\"}"}'
# slash-command receiver โ reads Slack's form-encoded fields
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/slack/command","status":200,
"body":"{\"response_type\":\"in_channel\",\"text\":\"You ran /deploy with: {{body.text}} (from @{{body.user_name}})\"}"}'
@slack/web-api has first-class support for this โ slackApiUrl is a constructor option. This is the actual test run against the mock above (Node 22, @slack/web-api 8.0.0):
import { WebClient } from "@slack/web-api";
const web = new WebClient("xoxb-fake-token-for-tests", {
slackApiUrl: "https://mockbird.mockbird.workers.dev/m/<PID>/api/",
});
const res = await web.chat.postMessage({ channel: "C0123456789", text: "deploy finished" });
// res.ok === true, res.channel === "C0123456789", res.message.text === "deploy finished"
Production code doesn't change โ read the URL from an env var. Bolt apps take the same option (clientOptions: { slackApiUrl }), and the Python SDK's WebClient(base_url=โฆ) is the same idea.
The HTTP-200 failure. Re-POST the chat.postMessage route with an error body to flip the mock into failure mode (same path replaces the route):
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/api/chat.postMessage","status":200,
"body":"{\"ok\":false,\"error\":\"channel_not_found\"}"}'
Verified: the SDK now throws slack_webapi_platform_error with e.data.error === "channel_not_found" โ while the HTTP status stays 200, exactly like real Slack. If your own client code checks res.status instead of body.ok, this drill is where you find out.
A rate limit the SDK actually recognizes. ?mock_ratelimit=2 allows 2 requests per fixed 60-second window, then 429s with Retry-After โ and because the header family is real, the official SDK's rate-limit handling kicks in. The SDK appends the method name to slackApiUrl, so simulation params can ride along on an apiCall string (verified โ third call threw slack_webapi_rate_limited_error with e.retryAfter read from the mock's header):
const web = new WebClient("xoxb-fake", {
slackApiUrl: "https://mockbird.mockbird.workers.dev/m/<PID>/api/",
rejectRateLimitedCalls: true, // throw instead of silently waiting out Retry-After
});
await web.apiCall("chat.postMessage?mock_ratelimit=2", { channel: "C1", text: "burst" });
By default (without rejectRateLimitedCalls) the SDK pauses for the full Retry-After and retries invisibly โ worth experiencing in a test once, because it's also what your production bot will do. With plain fetch/curl you can also script exact fail-then-recover sequences: ?mock_seq=500,200 serves a 500 to the first request and success after โ deterministic, not flaky. (Use mock_ratelimit rather than mock_seq=429 for SDK rate-limit drills: the SDK requires a Retry-After header on 429s, which mock_ratelimit sends.) Add ?mock_delay=3000 for timeout tests.
Half of a Slack integration is Slack calling you. The routes from section 2 make the mock a stand-in on that side too โ and every hit lands in the project's request inspector with headers and body, so you can see exactly what was sent:
# your alerting code posts to a webhook URL? Point it here instead of a real channel:
curl -s -X POST $BASE/m/$PID/services/T0001/B0001/faketoken \
-H 'content-type: application/json' -d '{"text":"Alert: build failed on main"}'
# โ 200 "ok" (Slack's literal webhook reply), payload visible in the inspector
# Slack's Events API URL verification โ the mock echoes the challenge:
curl -s -X POST $BASE/m/$PID/slack/events -H 'content-type: application/json' \
-d '{"type":"url_verification","challenge":"3eZbrw1aBm2rZgRN..."}'
# โ {"challenge":"3eZbrw1aBm2rZgRN..."}
# slash commands arrive form-encoded โ the template reads the fields:
curl -s -X POST $BASE/m/$PID/slack/command \
-d 'command=%2Fdeploy&text=api-server+to+staging&user_name=pilar'
# โ {"response_type":"in_channel","text":"You ran /deploy with: api-server to staging (from @pilar)"}
The /slack/events route even passes Slack's real URL verification โ you can temporarily set it as an app's Event Subscriptions URL to capture genuine event payloads in the inspector, then copy them into your test fixtures. For asserting what your own service sends to Slack, point its webhook URL at a catch-all bin (request-bin guide).
| Real workspace / dev sandbox | steno (Slack's mock tool) | nock / MSW | Mockbird | |
|---|---|---|---|---|
| Real Block Kit rendering, real scopes | โ the source of truth | ~ recorded traffic | โ | โ your shapes |
| Maintained | โ | โ archived June 2022 | โ | โ |
| Works in CI without tokens | โ real token in CI | โ | โ | โ |
| Reachable outside one process (curl, other services, staging) | โ | ~ local proxy | โ in-process only | โ hosted URL |
| Fail on cue (ok:false, 429, latency) | โ | โ replay only | โ hand-written | โ query params + route flips |
| Receiver side (webhooks/events/commands) without a public URL of your own | โ needs ngrok etc. | ~ | โ | โ hosted + inspector |
Straight answer: Slack's free developer program sandboxes are genuinely good, and before shipping a real bot you should absolutely click through it in one โ a mock can't render Block Kit or exercise OAuth scopes. steno, Slack's own record-replay testing companion, was archived in June 2022. Use nock/MSW for pure in-process unit tests. Use Mockbird when the mock needs to be a URL โ CI without tokens, several services sharing one fake Slack, deterministic failure drills, or capturing what Slack (or your own code) actually sends.
no_team, 429 + Retry-After) verified against the live API, August 2026. SDK behaviors verified with @slack/web-api 8.0.0.ts values are millisecond epochs, not Slack's seconds.micros strings โ fine for code that treats ts as opaque (most does); adjust the template if yours parses it.conversations.list, users.info, โฆ are just more routes).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, GraphQL, and snapshot fixtures.