@erox/mock-gateway
v0.2.0
Published
Fake Discord gateway and REST server for testing bots without hitting the real API
Downloads
18
Maintainers
Readme
@discord-toolkit/mock-gateway
A fake Discord gateway + REST server you can run locally so you can actually unit test bot logic without hitting the real API, burning rate limits, or needing a live token in CI.
Models gateway v10 connection behavior: HELLO/IDENTIFY/READY handshake, heartbeat acks, RESUME/RESUMED, and the 4002 close code for oversized payloads.
Why
Testing Discord bots normally means either mocking discord.js internals
by hand (brittle, breaks every update) or just not testing the bot
logic at all and hoping for the best. This spins up a real WebSocket
server that speaks the gateway protocol closely enough to fool a client,
plus an HTTP server that stands in for discord.com/api, so you point
your bot at localhost during tests and everything just works.
Install
npm install --save-dev @discord-toolkit/mock-gatewayQuick start
const { MockDiscordServer } = require('@discord-toolkit/mock-gateway');
const server = new MockDiscordServer();
const { gatewayUrl, restUrl } = await server.start();
// point your bot / client at these instead of the real Discord URLs
// register a canned REST response
server.rest.on('POST', '/api/v10/channels/123/messages', (req) => ({
status: 200,
body: { id: '999', content: req.body.content },
}));
// simulate an incoming message
server.emitMessage({ content: '!ping', channelId: '123' });
// later, assert your bot actually replied
server.hasSentMessage('123'); // true
server.sentMessages('123'); // [{ content: '...' }, ...]
await server.stop();Beginner guide: what this is actually doing
If you've never tested a Discord bot before, here's why this exists and how it fits into a test:
A Discord bot normally works like this: it opens a WebSocket connection to Discord's real gateway, gets sent events (someone posted a message, someone joined a voice channel, etc), and reacts by making REST calls back to Discord's real API (send a reply, add a role, whatever).
Testing that against the real Discord API is a bad idea — you'd need a live bot token, a real server to test in, and every test run would be slow and could get you rate limited or banned for spam.
This package gives you two fake servers that run locally:
- a fake gateway (WebSocket server) that your bot connects to instead of Discord's real one — it plays along with the connection handshake, and you can make it emit fake events (messages, interactions, whatever) whenever you want
- a fake REST API (HTTP server) that your bot's outgoing calls hit
instead of
discord.com/api— you decide what it responds with, and it records everything that was called so you can check your bot's behavior afterward
So a typical test looks like: start the fake servers → point your bot at them → simulate an incoming event → check that your bot made the REST call you expected. No real Discord connection anywhere in the test.
API reference
new MockDiscordServer(options)
Creates both the fake gateway and fake REST server together.
const server = new MockDiscordServer({
gateway: { port: 0 }, // options passed to MockGateway
rest: { port: 0 }, // options passed to MockRest
});Ports default to 0, meaning "pick a free port" — good for running
tests in parallel without collisions. You get the actual assigned ports
back from server.start().
server.start()
Starts both servers. Returns:
{
gatewayUrl: 'ws://localhost:PORT',
restUrl: 'http://localhost:PORT',
}Point your bot's client at these instead of Discord's real endpoints.
server.stop()
Shuts both servers down. Call this in your test teardown (afterEach
or equivalent) so ports get freed between tests.
Gateway events — which method for which event
The fake gateway handles the connection handshake automatically:
- On connect, it sends
HELLO(op 10) like the real gateway does. - When your client sends
IDENTIFY(op 2), it replies withREADY. - When your client sends
RESUME(op 6), it replies withRESUMED— useful for testing that your client's reconnect logic actually works, though this mock doesn't replay missed events like the real gateway does. - When your client sends a
HEARTBEAT(op 1), it acks it (op 11). - If a client sends a payload over 4096 bytes, the connection closes with
code
4002, matching real gateway behavior — good for catching bugs where large payloads (e.g. huge presence updates) aren't chunked.
Beyond that, you control what events get sent using these:
| method | event fired | when to use it |
|---|---|---|
| server.emitMessage(overrides) | MESSAGE_CREATE | simulating a user sending a message |
| server.emitInteraction(overrides) | INTERACTION_CREATE | simulating a slash command / button / select menu use |
| server.gateway.emit(eventName, data) | anything | any other event — GUILD_MEMBER_ADD, VOICE_STATE_UPDATE, MESSAGE_REACTION_ADD, etc |
emitMessage(overrides)
server.emitMessage({
content: '!ping',
channelId: '333333333333333333',
guildId: '555555555555555555',
author: { id: '222222222222222222', username: 'someone', bot: false },
});All fields have sane defaults if you don't pass them, so
server.emitMessage({ content: 'hi' }) alone works fine for simple cases.
emitInteraction(overrides)
server.emitInteraction({
type: 2, // 2 = APPLICATION_COMMAND, 3 = MESSAGE_COMPONENT, 5 = MODAL_SUBMIT
data: { name: 'ping' },
channelId: '333333333333333333',
guildId: '555555555555555555',
});Same deal — defaults fill in the rest so you only need to override what matters for your test.
gateway.emit(eventName, data)
For anything not covered by the two helpers above, drop down to the raw
gateway and emit whatever event name + payload shape you need. Use
Discord's own gateway event docs for the payload shape of whichever
event you're simulating — this just delivers whatever data object you
give it under that event name.
server.gateway.emit('GUILD_MEMBER_ADD', {
guild_id: '555555555555555555',
user: { id: '222222222222222222', username: 'newperson' },
});REST mocking — registering and checking calls
server.rest.on(method, path, handler)
Registers what the fake REST server should respond with for a given
route. path should match exactly what your client will request,
including the /api/v10 prefix if your client sends it.
handler can be:
- a plain object — used directly as the JSON response body, status 200
- a function — receives
{ method, path, body }from the incoming request and returns{ status, body, headers }
// static response
server.rest.on('GET', '/api/v10/users/@me', {
id: '000000000000000000',
username: 'mock-bot',
});
// dynamic response based on what was sent
server.rest.on('POST', '/api/v10/channels/123/messages', (req) => ({
status: 200,
body: { id: '999', content: req.body.content, channel_id: '123' },
}));Any route you don't register returns a 404 with a message telling
you which route was missing — useful for catching bot behavior you
didn't expect during a test.
Checking what your bot actually did
| method | returns |
|---|---|
| server.rest.wasCalled(method, path) | true/false |
| server.rest.callsTo(method, path) | array of { method, path, body, headers } for every matching call |
| server.rest.reset() | clears recorded calls (call between tests if reusing one server instance) |
Shortcuts specifically for messages:
server.hasSentMessage('333333333333333333'); // true/false
server.sentMessages('333333333333333333'); // array of message bodies sentFull example: testing a ping command
const { MockDiscordServer } = require('@discord-toolkit/mock-gateway');
const { startMyBot } = require('../bot'); // your actual bot code
test('bot replies pong to !ping', async () => {
const server = new MockDiscordServer();
const { gatewayUrl, restUrl } = await server.start();
server.rest.on('POST', '/api/v10/channels/333333333333333333/messages', (req) => ({
status: 200,
body: { id: '1', content: req.body.content },
}));
const bot = await startMyBot({ gatewayUrl, restUrl }); // wire your bot to these URLs
server.emitMessage({ content: '!ping', channelId: '333333333333333333' });
// give the bot a tick to process and respond
await new Promise((r) => setTimeout(r, 50));
assert.ok(server.hasSentMessage('333333333333333333'));
assert.strictEqual(server.sentMessages('333333333333333333')[0].content, 'pong');
await bot.destroy();
await server.stop();
});Common mistakes
- Forgetting to register a REST route the bot actually calls. You'll
get a 404 in the fake server and your bot will likely error or silently
do nothing — check
server.rest.callsif a test isn't behaving. - Not awaiting a tick after emitting an event. Your bot processes events asynchronously; emitting a message and immediately asserting can race ahead of your bot's handler. Add a short delay or, better, hook into your bot's own "done processing" signal if it has one.
- Reusing one server across tests without resetting. Call
server.rest.reset()between tests (or spin up a freshMockDiscordServerper test) so call history doesn't leak between them.
Notes
- This isn't a full reimplementation of the gateway protocol — it covers
what's needed to get a client through HELLO/IDENTIFY/READY and then
push events at it. If your client does something more exotic (resume
logic, sharding handshakes), you may need to extend
MockGateway. - Ports default to random free ports (
0), so tests can run in parallel without clashing. - Pairs well with
@discord-toolkit/rate-limiter— you can register a 429 response on a route inserver.rest.on(...)to test your retry logic against simulated rate limits too.
Changelog
0.2.0
- Added
RESUME(op 6) →RESUMEDhandling for testing reconnect logic. - Oversized payloads (>4096 bytes) now close the socket with
4002, matching real gateway behavior.
0.1.0
- Initial release: fake gateway (HELLO/IDENTIFY/READY/heartbeat), fake REST server with route registration and call recording.
