On This Page

Test a Bot Without Fluxer

Run commands and handlers against an in-memory Fluxer with the testing entry points, without a token or network

The @neontechspace/fluxerly/testing entry point creates a real client connected to an in-memory Fluxer. A test sends the bot a fake message or event and checks the requests the bot made. No token, network or Fluxer account is needed, and the events pass through the SDK's own decoding, caches and handlers

Share the Bot's Options with Its Tests

A bot started with runBot is tested with createTestBot, which takes the same options. It covers handlers, commands and setup but not runBot's supervision, so a stopped subscription, such as one set to overflow: "stop" whose queue fills, does not stop the test bot. The Effect test bot has the same limit. Test runBot's terminal failures and process-signal handling separately, and call testBot.client.shutdown({ drainMs }) to test a drain. The bot file that fluxerly init writes, shown in the quick start, exports those options without the token as bot and starts the bot only when Node.js runs the file:

import { defineBot, runBot } from "@neontechspace/fluxerly"

// The bot's options without its token, so its test can run them through createTestBot without a token or a network
export const bot = defineBot({
    commands: {
        prefix: "!",
        commands: {
            // Returning the reply reports a failed send to the log
            ping: { execute: ({ reply }) => reply("Pong!") },
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
import { defineBot, runBot } from "@neontechspace/fluxerly"

// The bot's options without its token, so its test can run them through createTestBot without a token or a network
export const bot = defineBot({
    commands: {
        prefix: "!",
        commands: {
            // Returning the reply reports a failed send to the log
            ping: { execute: ({ reply }) => reply("Pong!") },
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })

A test imports bot from this file, so the bot and its tests run the same events, commands and setup. The defineBot function returns the options unchanged and keeps the types of command arguments, which an object annotated with BotOptions loses. A larger bot can keep its options in a module of their own instead, such as bot-options.ts, which both the bot file and the tests import

Write a First Test

This test uses node:test, the test runner built into Node.js, so nothing else needs installing. A project from fluxerly init already has a similar test as bot.test.js, or bot.test.ts for TypeScript, and runs it with npm test, pnpm test or bun run test

import assert from "node:assert/strict"
import { test } from "node:test"
import { defineBot } from "@neontechspace/fluxerly"
import { createTestBot } from "@neontechspace/fluxerly/testing"

// In a project, import bot from ./bot.ts. It is repeated here so the example runs on its own
const bot = defineBot({
    commands: {
        prefix: "!",
        commands: {
            ping: {
                description: "Check that the bot replies",
                execute: ({ reply }) => reply("Pong!"),
            },
        },
    },
import assert from "node:assert/strict"
import { test } from "node:test"
import { defineBot } from "@neontechspace/fluxerly"
import { createTestBot } from "@neontechspace/fluxerly/testing"

// In a project, import bot from ./bot.ts. It is repeated here so the example runs on its own
const bot = defineBot({
    commands: {
        prefix: "!",
        commands: {
            ping: {
                description: "Check that the bot replies",
                execute: ({ reply }) => reply("Pong!"),
            },
        },
    },

Run every test file in the project:

node --test

Node.js finds test files such as bot.test.js and bot.test.ts and runs TypeScript directly

What the Test Does

  1. The createTestBot call creates a test client and registers the bot's events and commands on it, as runBot would. The unset token falls back to a test token, and processSignals is ignored. The await using declaration shuts the client down when the test ends, even after a failed assertion
  2. The ready() call runs the bot's setup, if it has one, connects to the fake gateway and waits for READY
  3. The say call delivers a message from a fake user, exactly as Fluxer would send it, waits until the bot has stopped working and returns the messages the bot sent meanwhile. The fake Fluxer API answers each message the bot sends with that message, so no response needs registering
  4. The test checks the content of what the bot sent, and an empty list shows that the second test's message was ignored

For other events, the emit call delivers a Fluxer event built by fixtures, and idle() waits until the bot has stopped working, as the third test shows. To control how the fake Fluxer API answers a request, rest.respond registers a response and returns a route whose next() waits for the next request it answers. These waits have no deadline unless timeoutMs is passed, as in bot.idle({ timeoutMs: 2_000 }), so set a timeout in the test runner, such as node --test --test-timeout=10000, to fail a bot that never settles instead of hanging

Use Fake Timers in a Test

A timeoutMs on next(), idle() or say uses real timers, so faking the global timers does not change it. The SDK runs its work on setImmediate, so fake timers must leave setImmediate real, as in Vitest's vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] }). When setImmediate is faked, no handler can run, and idle() rejects with ConfigurationError instead of settling

The third test passes its own options with a command that throws. Without an onError hook (see handle failures in one place), the failure is logged as commands.failed. The failures() method returns such unhandled failures and marks them as expected. When a handler or command throws or returns a failed Result without onError and the test never reads the failure from failures(), shutdown rejects with UnhandledTestFailuresError, so a broken handler fails the test even without an assertion

Requests Without a Registered Response

A message send or edit that matches no rest.respond route receives the sent message back, as if Fluxer accepted it. Any other request without a route receives a 404 shaped like Fluxer's, and logs() gains a testing.unmatchedRequest Warn record. When a command returns a request that fails this way, the failure is logged as commands.failed and counts like a thrown error. The failures() method returns it, and shutdown rejects unless the test read it

Test Handlers Registered with client.on

Code that registers handlers on a client with client.on, instead of through runBot, is tested with createTestClient. It creates the same test client without bot options. Unlike runBot handlers, client.on handlers receive the event itself and have no reply, so they send through client methods

import assert from "node:assert/strict"
import { test } from "node:test"
import { createTestClient } from "@neontechspace/fluxerly/testing"

// The application passes its own client and welcome channel
export function installWelcome(client, channelId) {
    return client.on("guildMemberAdd", (member) => client.messages.send(channelId, `Welcome, ${member.userId}!`))
}

test("new members are welcomed", async () => {
    await using bot = createTestClient()
    const sent = bot.rest.respond("POST /channels/:id/messages", { body: bot.fixtures.message() })
    installWelcome(bot.client, bot.fixtures.ids.channel)

    await bot.ready()
    bot.emit("GUILD_MEMBER_ADD", bot.fixtures.member())

    const request = await sent.next()
    assert.match(String(request.body.content), /^Welcome/)
})
import assert from "node:assert/strict"
import { test } from "node:test"
import type { Client } from "@neontechspace/fluxerly"
import { createTestClient } from "@neontechspace/fluxerly/testing"

// The application passes its own client and welcome channel
export function installWelcome(client: Client, channelId: string) {
    return client.on("guildMemberAdd", (member) => client.messages.send(channelId, `Welcome, ${member.userId}!`))
}

test("new members are welcomed", async () => {
    await using bot = createTestClient()
    const sent = bot.rest.respond("POST /channels/:id/messages", { body: bot.fixtures.message() })
    installWelcome(bot.client, bot.fixtures.ids.channel)

    await bot.ready()
    bot.emit("GUILD_MEMBER_ADD", bot.fixtures.member())

    const request = await sent.next()
    assert.match(String((request.body as { content?: unknown }).content), /^Welcome/)
})

Match Gateway Event Filtering

The test gateway keeps the gateway.ignoredEvents list sent at Identify. An emit call throws ConfigurationError instead of delivering a dispatch Fluxer would suppress, and the rejected dispatch consumes no sequence number. This catches tests that would pass only because the fake gateway delivered an event the real bot cannot receive

With gateway.ignoredEvents: "auto", register handlers, waits and collectors before ready() so Identify sees them. A bot's handlers, commands and setup registrations are already installed at that point. A late registration cannot undo the current session's filtering, and Resume keeps that list. A new Identify recomputes automatic filtering. For an explicit list, remove the source dispatch type from the options before creating the client, or use gateway.ignoredEvents: [] to disable suppression

Suppressed MESSAGE_CREATE still arrives for a direct bot mention, @here or @everyone, but not merely for a role mention, an unmentioned direct message or the bot's own message. Reaction batches use their source event's filter: Ignoring MESSAGE_REACTION_ADD prevents MESSAGE_REACTION_ADD_MANY, while ignoring MESSAGE_REACTION_ADD_MANY alone does not. Under automatic filtering, a messageReactionAddMany handler retains that source dispatch

Inspect What the Bot Sent

The test client records everything, without the token or Authorization header:

  • HTTP requests: The requests() method lists each one with its method, path, query, parsed JSON body and uploaded files
  • Gateway commands: The commands() method lists what the client sent to the gateway, such as presence updates
  • Logs: The logs() method returns the records the client produced, which are kept out of the console
  • Failures: The failures() method returns the Error records of handler and command failures no onError hook received, even when the logging level or deduplication keeps them out of logs()
  • Counters: The counters() method returns the same running totals as client.diagnostics().counters

Each rest.respond call returns a route whose requests() lists only the requests it answered, next() waits for the next one and remove() stops it. The newest matching response wins. A matcher can be a "METHOD /path/:param" string, a RegExp, an object or a function

Fixtures, Shards and Reconnects

Each test client has its own fixtures, whose builders return snake_case wire payloads for users, the bot user, guilds, GUILD_CREATE bodies, channels, roles, members and messages. Their defaults refer to one community, one channel and one author, and fixtures.ids holds those IDs. The IDs are deterministic, so the same calls produce the same IDs in every run. The shared fixtures export and createFixtures() build payloads outside a test client. Pass { shardId } to emit to choose the receiving shard in a sharded client. Call disconnect({ code }) to close a shard's connection from the gateway side and watch the client resume, or fail for a code that ends the session. Every recoverable code resumes the same session, including 4007, so sessionEpoch stays the same. Pass endSession: true to make the test gateway forget the session first, so the client identifies a new one and the next event's context.sessionEpoch is higher. The createTestClient function accepts every createClient option except transport and instance, which the test transport owns. The createTestBot function accepts the bot's runBot options and ignores signal, processSignals, reportFailure and drainMs

Test Effect Programs

The @neontechspace/fluxerly/effect/testing entry point provides the same createTestBot and createTestClient for the Effect API. Its test clients are scoped: Closing the Scope shuts the client down, and ready, emit, next and idle are Effects. The Effect application testing guide covers testing Effect bots, including time-based behavior

The reference lists every control and option: Testing for the default API and Effect testing for the Effect API