Run commands and handlers against an in-memory Fluxer with the testing entry points, without a token or network
The @neontechspace/ 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 run is tested with create, which takes the same options. It covers handlers, commands and setup but not run'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 run's terminal failures and process-signal handling separately, and call test 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 define function returns the options unchanged and keeps the types of command arguments, which an object annotated with Bot 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, or bot 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!"),
},
},
},
})
test("!ping replies Pong!", async () => {
await using testBot = createTestBot(bot)
await testBot.ready()
const replies = await testBot.say("!ping")
assert.deepEqual(replies.map((message) => message.content), ["Pong!"])
})
test("other messages get no reply", async () => {
await using testBot = createTestBot(bot)
await testBot.ready()
assert.deepEqual(await testBot.say("hello"), [])
})
test("a command that throws is reported", async () => {
await using testBot = createTestBot({
commands: {
prefix: "!",
commands: {
broken: {
execute: () => {
throw new Error("Not implemented yet")
},
},
},
},
})
await testBot.ready()
testBot.emit("MESSAGE_CREATE", testBot.fixtures.message({ content: "!broken" }))
await testBot.idle()
assert.deepEqual(testBot.failures().map((record) => record.code), ["commands.failed"])
})
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!"),
},
},
},
})
test("!ping replies Pong!", async () => {
await using testBot = createTestBot(bot)
await testBot.ready()
const replies = await testBot.say("!ping")
assert.deepEqual(
replies.map((message) => message.content),
["Pong!"],
)
})
test("other messages get no reply", async () => {
await using testBot = createTestBot(bot)
await testBot.ready()
assert.deepEqual(await testBot.say("hello"), [])
})
test("a command that throws is reported", async () => {
await using testBot = createTestBot({
commands: {
prefix: "!",
commands: {
broken: {
execute: () => {
throw new Error("Not implemented yet")
},
},
},
},
})
await testBot.ready()
testBot.emit("MESSAGE_CREATE", testBot.fixtures.message({ content: "!broken" }))
await testBot.idle()
assert.deepEqual(
testBot.failures().map((record) => record.code),
["commands.failed"],
)
})
Run every test file in the project:
node --test
Node.js finds test files such as bot and bot and runs TypeScript directly
What the Test Does
- The
createcall creates a test client and registers the bot's events and commands on it, asTest Bot runwould. The unset token falls back to a test token, andBot processis ignored. TheSignals await usingdeclaration shuts the client down when the test ends, even after a failed assertion - The
ready()call runs the bot'ssetup, if it has one, connects to the fake gateway and waits for READY - The
saycall 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 - 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 registers a response and returns a route whose next() waits for the next request it answers. These waits have no deadline unless timeout is passed, as in bot, 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 timeout on next(), idle() or say uses real timers, so faking the global timers does not change it. The SDK runs its work on set, so fake timers must leave set real, as in Vitest's vi. When set is faked, no handler can run, and idle() rejects with Configuration instead of settling
The third test passes its own options with a command that throws. Without an on hook (see handle failures in one place), the failure is logged as commands. The failures() method returns such unhandled failures and marks them as expected. When a handler or command throws or returns a failed Result without on and the test never reads the failure from failures(), shutdown rejects with Unhandled, 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 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 Warn record. When a command returns a request that fails this way, the failure is logged as commands 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, instead of through run, is tested with create. It creates the same test client without bot options. Unlike run handlers, client 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 list sent at Identify. An emit call throws Configuration 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, 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 to disable suppression
Suppressed MESSAGE_ 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_ prevents MESSAGE_, while ignoring MESSAGE_ alone does not. Under automatic filtering, a message 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 noonhook received, even when the logging level or deduplication keeps them out ofError logs() - Counters: The
counters()method returns the same running totals asclient.diagnostics() .counters
Each rest 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 / 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_ bodies, channels, roles, members and messages. Their defaults refer to one community, one channel and one author, and fixtures holds those IDs. The IDs are deterministic, so the same calls produce the same IDs in every run.
The shared fixtures export and create build payloads outside a test client.
Pass { shard to emit to choose the receiving shard in a sharded client. Call disconnect( 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 session stays the same. Pass end to make the test gateway forget the session first, so the client identifies a new one and the next event's context is higher.
The create function accepts every create option except transport and instance, which the test transport owns. The create function accepts the bot's run options and ignores signal, process, report and drain
Test Effect Programs
The @neontechspace/ entry point provides the same create and create 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