LogoRobo.js

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

FocusThe terminal output with test results and pass/fail statusZoom100%NotesRun npx robo mock test in the mockbot-ts template to capture real test output. Show a few passing tests with green checkmarks and the summary line at the bottom.

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@next

If you are using TypeScript with Jest, install the test tooling:

npm install --save-dev jest ts-jest @types/jest

Jest Configuration

Create or update your Jest config:

jest.config.ts
// 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
// 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

FocusThe connection between Jest reporter output and the DevTools Tests tab visualizationZoom100%NotesShow the DevTools Tests tab with test results that came from the Jest custom reporter. Include test file names, individual test case results with pass/fail status, and durations. This demonstrates how the reporter bridges terminal tests to the visual UI.

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:

ConstantValueDescription
APPLICATION_COMMAND2Slash command interaction
MESSAGE_COMPONENT3Button, select menu, or modal trigger
APPLICATION_COMMAND_AUTOCOMPLETE4Autocomplete request
MODAL_SUBMIT5Modal form submission

Component types (used within MESSAGE_COMPONENT interactions):

ConstantValueDescription
BUTTON2Button component
STRING_SELECT3String select menu
USER_SELECT5User select menu
ROLE_SELECT6Role select menu
MENTIONABLE_SELECT7Mentionable select menu
CHANNEL_SELECT8Channel 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:

__tests__/my-feature.test.ts
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
})
__tests__/my-feature.test.js
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:

__tests__/custom-session.test.ts
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' }
    ]
  }
})
__tests__/custom-session.test.js
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:

__tests__/self-contained.test.ts
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') }
      }
    })
  })
})
__tests__/self-contained.test.js
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:

__tests__/dispatch-example.test.ts
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
})
__tests__/dispatch-example.test.js
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:

__tests__/gateway-example.test.ts
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'
  }
})
__tests__/gateway-example.test.js
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:

__tests__/assertions.test.ts
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
})
__tests__/assertions.test.js
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:

__tests__/matchers.test.ts
await expectAction(session.id, {
  description: 'Bot sends embed',
  type: 'message_sent',
  expected: {
    embeds: expect.arrayContaining([
      expect.objectContaining({
        title: expect.stringContaining('Results')
      })
    ])
  }
})
__tests__/matchers.test.js
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:

__tests__/no-action.test.ts
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)
})
__tests__/no-action.test.js
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:

__tests__/wait-action.test.ts
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')
__tests__/wait-action.test.js
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:

__tests__/wait-message.test.ts
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')
__tests__/wait-message.test.js
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:

__tests__/stateful.test.ts
import { resetSession } from '@robojs/mock/testing'

beforeEach(async () => {
  await resetSession(session.id)
})
__tests__/stateful.test.js
import { resetSession } from '@robojs/mock/testing'

beforeEach(async () => {
  await resetSession(session.id)
})

Running Tests

With Robo CLI

npx robo mock test

This 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 test

Watch Mode

For development:

npx robo mock test --watch

Troubleshooting

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 startMockRobo or verify npx robo mock start is active.
  • Wrong action type -- Use getSessionActions(session.id) to inspect what actions were actually recorded. You may be waiting for message_sent when the bot replies via interaction_response.
  • Wrong channel or guild -- Double-check that channel_id and guild_id match the session's channels and guilds.

Session creation fails

  • Server not running -- createTestSession requires a running mock server. Use npx robo mock test (which starts one automatically) or start one manually with npx robo mock start.
  • Port conflict -- If port 6625 is already in use, set a different port with --port or ROBO_MOCK_PORT.

Tests pass locally but fail in CI

  • Missing build step -- Run npx robo build before npx robo mock test in CI. The bot needs compiled output to start.
  • Timing differences -- CI environments are often slower. Increase timeout values for assertions that are sensitive to execution speed.
  • Environment variables -- Ensure ROBO_MOCK_MODE=true and NODE_ENV=test are set in your CI environment.

"Cannot find module" errors

  • Missing dependencies -- Ensure @robojs/mock and @robojs/server are 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 and ts-jest is configured with useESM: true.

Actions recorded but assertions fail

  • Data shape mismatch -- The expected field in expectAction is matched against action.data. Use getSessionActions to log the full action shape and adjust your expected object accordingly.
  • Stale actions from previous tests -- Use resetSession(session.id) in beforeEach or clearSessionActions(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 test

GitHub Actions workflow run showing passing mock tests with green checkmarks, job summary, and test output logs

FocusThe GitHub Actions CI workflow with test results showing pass/fail statusZoom100%NotesShow a GitHub Actions workflow run page with the test job completed successfully. Include green checkmarks on steps, the test output in the logs section showing passing tests, and the overall workflow status badge.

Environment Variables

Set these in CI:

env:
  ROBO_MOCK_MODE: true
  NODE_ENV: test

Stage 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

FocusThe Tests tab in the DevTools panel with assertion resultsZoom100%NotesOpen Stage UI, connect to a test session, and open the DevTools panel. Navigate to the Tests tab to show assertion results synced from the Jest reporter. Ideally show a mix of passed and failed assertions with diff output visible.

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:

jest.config.ts
export default {
  // Run integration tests sequentially
  projects: [{
    displayName: 'integration',
    testMatch: ['**/__tests__/integration/**/*.test.ts'],
    runInBand: true
  }]
}
jest.config.js
export default {
  // Run integration tests sequentially
  projects: [{
    displayName: 'integration',
    testMatch: ['**/__tests__/integration/**/*.test.js'],
    runInBand: true
  }]
}

Next Steps

On this page