Automated Testing
Write tests with Jest
Write automated tests for your Discord bot using Jest. Tests run against the mock server, giving you full control over Discord events and assertions.
Terminal output showing npx robo mock test with passing test results including test suite summary, individual test names with checkmarks, and timing information
Setup
Prerequisites
Mock Server requires @robojs/server as a peer dependency (a package that must be installed alongside @robojs/mock for it to work). Install both:
npx robo add @robojs/mock@nextIf you are using TypeScript with Jest, install the test tooling:
npm install --save-dev jest ts-jest @types/jestJest Configuration
Create or update your Jest config:
// jest.config.ts
export default {
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', { useESM: true }]
},
testMatch: ['**/__tests__/**/*.test.ts'],
reporters: [
'default',
'@robojs/mock/testing/jest-reporter'
]
}// jest.config.js
export default {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
reporters: [
'default',
'@robojs/mock/testing/jest-reporter'
]
}The custom reporter (a Jest plugin that formats and forwards test results) syncs test results to Stage UI for visual debugging.
Jest custom reporter output synced to Stage UI DevTools Tests tab, showing test file results alongside the terminal reporter output
Interaction Type Constants
When dispatching interactions (simulated Discord events like slash commands or button clicks), you'll use numeric type values. Here's a quick reference:
| Constant | Value | Description |
|---|---|---|
APPLICATION_COMMAND | 2 | Slash command interaction |
MESSAGE_COMPONENT | 3 | Button, select menu, or modal trigger |
APPLICATION_COMMAND_AUTOCOMPLETE | 4 | Autocomplete request |
MODAL_SUBMIT | 5 | Modal form submission |
Component types (used within MESSAGE_COMPONENT interactions):
| Constant | Value | Description |
|---|---|---|
BUTTON | 2 | Button component |
STRING_SELECT | 3 | String select menu |
USER_SELECT | 5 | User select menu |
ROLE_SELECT | 6 | Role select menu |
MENTIONABLE_SELECT | 7 | Mentionable select menu |
CHANNEL_SELECT | 8 | Channel select menu |
Creating Test Sessions
The recommended approach is createTestSession(), which creates an isolated session on the mock server. The bot should already be running separately (e.g., via npx robo mock test or npx robo mock start).
Each test file gets its own session:
import { createTestSession } from '@robojs/mock/testing'
import type { TestSession } from '@robojs/mock/testing'
describe('my feature', () => {
let session: TestSession
beforeAll(async () => {
session = await createTestSession(__filename)
})
afterAll(async () => {
await session?.destroy()
})
// tests go here
})import { createTestSession } from '@robojs/mock/testing'
describe('my feature', () => {
let session
beforeAll(async () => {
session = await createTestSession(__filename)
})
afterAll(async () => {
await session?.destroy()
})
// tests go here
})Session Properties
TestSession provides access to:
session.id // Session identifier
session.token // Bot token (mock:sess_xxx)
session.botUser // { id, username }
session.guilds // Array of test guilds
session.channels // Array of channels
session.guildId // First guild ID (convenience)session.id // Session identifier
session.token // Bot token (mock:sess_xxx)
session.botUser // { id, username }
session.guilds // Array of test guilds
session.channels // Array of channels
session.guildId // First guild ID (convenience)Custom Session Config
Configure guilds, users, and bot settings:
const session = await createTestSession(__filename, {
name: 'my-test',
config: {
botUser: {
username: 'TestBot'
},
guilds: [{
name: 'Test Server',
channels: [
{ name: 'general', type: 0 }, // 0 = Text channel
{ name: 'bot-spam', type: 0 } // 0 = Text channel
]
}],
users: [
{ username: 'TestUser' },
{ username: 'Admin' }
]
}
})const session = await createTestSession(__filename, {
name: 'my-test',
config: {
botUser: {
username: 'TestBot'
},
guilds: [{
name: 'Test Server',
channels: [
{ name: 'general', type: 0 }, // 0 = Text channel
{ name: 'bot-spam', type: 0 } // 0 = Text channel
]
}],
users: [
{ username: 'TestUser' },
{ username: 'Admin' }
]
}
})Alternative: Full Bot Startup
As an alternative to createTestSession(), you can use startMockRobo() which creates a session and starts the bot within the test process itself. Use this for fully self-contained tests where no external process is needed.
startMockRobo returns a MockRoboHandle with the same session properties plus a stop() method and access to the Discord.js client:
import { startMockRobo, dispatchInteraction, expectAction } from '@robojs/mock/testing'
import type { MockRoboHandle } from '@robojs/mock/testing'
describe('self-contained test', () => {
let bot: MockRoboHandle
beforeAll(async () => {
bot = await startMockRobo({ name: 'my-test', testFilePath: __filename })
})
afterAll(async () => {
await bot.stop()
})
it('responds to ping', async () => {
await dispatchInteraction(bot.sessionId, {
type: 2, // APPLICATION_COMMAND
data: { name: 'ping', type: 1 }, // CHAT_INPUT
channel_id: bot.channels[0].id
})
await expectAction(bot.sessionId, {
description: 'Bot should respond with pong',
type: 'interaction_response',
expected: {
response_data: { content: expect.stringContaining('Pong') }
}
})
})
})import { startMockRobo, dispatchInteraction, expectAction } from '@robojs/mock/testing'
describe('self-contained test', () => {
let bot
beforeAll(async () => {
bot = await startMockRobo({ name: 'my-test', testFilePath: __filename })
})
afterAll(async () => {
await bot.stop()
})
it('responds to ping', async () => {
await dispatchInteraction(bot.sessionId, {
type: 2, // APPLICATION_COMMAND
data: { name: 'ping', type: 1 }, // CHAT_INPUT
channel_id: bot.channels[0].id
})
await expectAction(bot.sessionId, {
description: 'Bot should respond with pong',
type: 'interaction_response',
expected: {
response_data: { content: expect.stringContaining('Pong') }
}
})
})
})Dispatching Events
Interactions
Trigger slash commands, buttons, and other interactions:
import { dispatchInteraction } from '@robojs/mock/testing'
// Slash command
await dispatchInteraction(session.id, {
type: 2, // APPLICATION_COMMAND
data: {
name: 'ping',
type: 1 // CHAT_INPUT
},
channel_id: session.channels[0].id,
guild_id: session.guildId
})import { dispatchInteraction } from '@robojs/mock/testing'
// Slash command
await dispatchInteraction(session.id, {
type: 2, // APPLICATION_COMMAND
data: {
name: 'ping',
type: 1 // CHAT_INPUT
},
channel_id: session.channels[0].id,
guild_id: session.guildId
})Gateway Events
Inject any Discord Gateway (real-time WebSocket) event:
import { dispatchEvent } from '@robojs/mock/testing'
await dispatchEvent(session.id, 'MESSAGE_CREATE', {
id: '123456789',
content: 'Hello bot',
channel_id: session.channels[0].id,
author: {
id: '987654321',
username: 'TestUser'
}
})import { dispatchEvent } from '@robojs/mock/testing'
await dispatchEvent(session.id, 'MESSAGE_CREATE', {
id: '123456789',
content: 'Hello bot',
channel_id: session.channels[0].id,
author: {
id: '987654321',
username: 'TestUser'
}
})Making Assertions
Default Timeouts: Most assertion functions default to 5000ms. The exception is expectNoAction which defaults to 500ms since it waits for the absence of an action.
expectAction
Assert that a specific action occurred:
import { expectAction } from '@robojs/mock/testing'
await expectAction(session.id, {
description: 'Bot should respond with pong',
type: 'interaction_response',
expected: {
response_data: {
content: 'Pong'
}
},
timeout: 5000
})import { expectAction } from '@robojs/mock/testing'
await expectAction(session.id, {
description: 'Bot should respond with pong',
type: 'interaction_response',
expected: {
response_data: {
content: 'Pong'
}
},
timeout: 5000
})Jest Matchers
Use Jest asymmetric matchers in expectations:
await expectAction(session.id, {
description: 'Bot sends embed',
type: 'message_sent',
expected: {
embeds: expect.arrayContaining([
expect.objectContaining({
title: expect.stringContaining('Results')
})
])
}
})await expectAction(session.id, {
description: 'Bot sends embed',
type: 'message_sent',
expected: {
embeds: expect.arrayContaining([
expect.objectContaining({
title: expect.stringContaining('Results')
})
])
}
})expectNoAction
Assert that an action did not occur:
import { expectNoAction } from '@robojs/mock/testing'
await expectNoAction(session.id, {
description: 'Bot should not respond to ignored message',
type: 'message_sent',
waitMs: 2000 // How long to wait before confirming no action (default: 500ms)
})import { expectNoAction } from '@robojs/mock/testing'
await expectNoAction(session.id, {
description: 'Bot should not respond to ignored message',
type: 'message_sent',
waitMs: 2000 // How long to wait before confirming no action (default: 500ms)
})waitForAction
Wait for an action and get the result:
import { waitForAction } from '@robojs/mock/testing'
const actions = await waitForAction(session.id, {
type: 'interaction_response',
timeout: 5000
})
expect((actions[0].data as any).content).toBe('Success')import { waitForAction } from '@robojs/mock/testing'
const actions = await waitForAction(session.id, {
type: 'interaction_response',
timeout: 5000
})
expect(actions[0].data.content).toBe('Success')For a typed alternative to (actions[0].data as any), see waitForInteractionResponse and waitForMessage which provide more targeted results. The Testing API page covers all available typed helpers.
waitForMessage
Wait specifically for a message:
import { waitForMessage } from '@robojs/mock/testing'
const action = await waitForMessage(session.id, {
channelId: session.channels[0].id,
timeout: 5000
})
expect((action.data as any).content).toContain('Welcome')import { waitForMessage } from '@robojs/mock/testing'
const action = await waitForMessage(session.id, {
channelId: session.channels[0].id,
timeout: 5000
})
expect(action.data.content).toContain('Welcome')Test Lifecycle
Cleanup
Always clean up sessions in afterAll:
afterAll(async () => {
await session?.destroy()
})afterAll(async () => {
await session?.destroy()
})Resetting State
Reset session between tests if needed:
import { resetSession } from '@robojs/mock/testing'
beforeEach(async () => {
await resetSession(session.id)
})import { resetSession } from '@robojs/mock/testing'
beforeEach(async () => {
await resetSession(session.id)
})Running Tests
With Robo CLI
npx robo mock testThis starts the mock server, runs Jest, and keeps the server running for inspection. Use --no-watch to exit immediately after tests complete.
You should see the mock server start on port 6625, followed by Jest running your test files. Passing tests show green checkmarks; failures show red crosses with details.
Manual Mode
Start mock server separately:
# Terminal 1: Start mock server
npx robo mock start
# Terminal 2: Run tests
npm testWatch Mode
For development:
npx robo mock test --watchTroubleshooting
Common issues and solutions when running automated tests.
Tests time out waiting for actions
If expectAction or waitForAction consistently times out:
- Bot not connected -- Ensure the mock server is running and the bot has connected before dispatching events. Use
startMockRoboor verifynpx robo mock startis active. - Wrong action type -- Use
getSessionActions(session.id)to inspect what actions were actually recorded. You may be waiting formessage_sentwhen the bot replies viainteraction_response. - Wrong channel or guild -- Double-check that
channel_idandguild_idmatch the session's channels and guilds.
Session creation fails
- Server not running --
createTestSessionrequires a running mock server. Usenpx robo mock test(which starts one automatically) or start one manually withnpx robo mock start. - Port conflict -- If port 6625 is already in use, set a different port with
--portorROBO_MOCK_PORT.
Tests pass locally but fail in CI
- Missing build step -- Run
npx robo buildbeforenpx robo mock testin CI. The bot needs compiled output to start. - Timing differences -- CI environments are often slower. Increase
timeoutvalues for assertions that are sensitive to execution speed. - Environment variables -- Ensure
ROBO_MOCK_MODE=trueandNODE_ENV=testare set in your CI environment.
"Cannot find module" errors
- Missing dependencies -- Ensure
@robojs/mockand@robojs/serverare both installed. Mock Server requires the server plugin as a peer dependency (a package that must be installed alongside it). - ESM/CJS mismatch -- If using TypeScript with Jest, ensure
extensionsToTreatAsEsm: ['.ts']is set in your Jest config andts-jestis configured withuseESM: true.
Actions recorded but assertions fail
- Data shape mismatch -- The
expectedfield inexpectActionis matched againstaction.data. UsegetSessionActionsto log the full action shape and adjust yourexpectedobject accordingly. - Stale actions from previous tests -- Use
resetSession(session.id)inbeforeEachorclearSessionActions(session.id)to start with a clean action history.
CI Integration
GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx robo build
- run: npx robo mock testGitHub Actions workflow run showing passing mock tests with green checkmarks, job summary, and test output logs
Environment Variables
Set these in CI:
env:
ROBO_MOCK_MODE: true
NODE_ENV: testStage UI DevTools panel showing the Tests tab with a list of test assertions, each showing pass or fail status, description text, and expandable expected vs actual comparison for failed assertions
Best Practices
Test Isolation
Each test file should create its own session. Avoid sharing state between test files.
Timeouts
Set appropriate timeouts for async operations:
await expectAction(session.id, {
description: 'Slow operation',
type: 'message_sent',
timeout: 10000 // 10 seconds for slow operations
})await expectAction(session.id, {
description: 'Slow operation',
type: 'message_sent',
timeout: 10000 // 10 seconds for slow operations
})Descriptive Assertions
Use clear descriptions in expectAction:
// Good
description: 'Bot should send welcome message with user mention'
// Less helpful
description: 'Check message'// Good
description: 'Bot should send welcome message with user mention'
// Less helpful
description: 'Check message'Sequential vs Parallel
Run tests sequentially when they share external state:
export default {
// Run integration tests sequentially
projects: [{
displayName: 'integration',
testMatch: ['**/__tests__/integration/**/*.test.ts'],
runInBand: true
}]
}export default {
// Run integration tests sequentially
projects: [{
displayName: 'integration',
testMatch: ['**/__tests__/integration/**/*.test.js'],
runInBand: true
}]
}