Skip to content
GitHub

The testing module

MeoCord 4.2 · Testing · page 32 of 41 · since 4.0.0

Build your controllers and services in a test, with no Discord connection, and run them as the bot does.

You'll learn

  • Build a testing module from the classes a test needs
  • Swap a dependency, a guard or an interceptor for a stand-in
  • Run lifecycle hooks and keep tests apart

meocord/testing runs your controllers, services, guards and everything around them inside a test, with no Discord connection and no token. MeoCordTestingModule builds a container from the classes you list, as the bot builds one from @MeoCord, and the module it compiles runs handlers through the same pipeline the bot uses.

The mocks it comes with behave like discord.js, and work under Vitest, Jest, Node's test runner and bun test:

  • Vitest runs a generated app's tests as they come.
  • Jest needs Node's VM modules: meocord's CommonJS build loads packages that ship only as ES modules, which Jest reads only with them. Run it as NODE_OPTIONS=--experimental-vm-modules jest.
  • Node's test runner asserts through a mock's .mock.calls.
  • bun test asserts through .mock.calls too: Bun's matchers, such as toHaveBeenCalledWith, accept only Bun's own mocks.

Whatever the runner, the tests need decorator metadata compiled in, which MeoCord reads to inject a class's dependencies. Under Jest and Node's test runner, compile them with SWC or tsc with decorator metadata, as a generated app's Vitest config does with SWC; node --test on .ts files strips the types without transforming decorators, so a decorated class fails to load. bun test reads emitDecoratorMetadata from tsconfig.json.

When to use it

Use a testing module whenever the code under test is a controller, or anything MeoCord resolves for you: a service with injected dependencies, a guard, an interceptor, a presenter. It is how you check what a member sees.

A service that takes plain values needs no module: build it with new and test it as a class, as Testing a service shows. To check which handler a customId or a message reaches without running it, use resolveRoute instead.

Example

controllers/slash/greeting.slash.controller.spec.ts
import { ChatInputCommandInteraction } from 'discord.js'
import { createChatInputOptions, createMockInteraction, getResponse, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'

describe('GreetingSlashController', () => {
  const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController] }).compile()

  it('greets by name', async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction)
    interaction.options = createChatInputOptions({ name: 'Ada' })

    await module.invoke(GreetingSlashController, 'greet', interaction)

    expect(getResponse(interaction).calls).toEqual([
      { method: 'reply', payload: expect.objectContaining({ content: 'Hello, Ada!' }) },
    ])
  })
})

The module is built from one controller and whatever it injects. invoke runs the greet handler with a mock interaction whose options say Ada, and getResponse reports what the handler sent.

How it works

MeoCordTestingModule.create takes the controllers, providers and observers a test needs, and returns a builder. compile() binds them into a fresh container and returns the module:

  • Only what you list is built, plus what those classes inject. Nothing else from the app is loaded, so a test never starts a service it didn't ask for. To build the whole app instead, see Testing the whole app.
  • Each compile() is a new module, with its own services, its own cooldown counts and its own theme cache.
  • Handlers run through the pipeline: @Defer, guards, interceptors, validation, pipes, cooldowns and exception filters, in the bot's order. Invoke and dispatch covers the two ways to run one.

Pass the app class as app to add what @MeoCord declares: its global guards, interceptors and filters, its presenter, translator, message options, observers and theme, and its cooldown store and policy. The controllers are still the ones you list. A store that injects something, such as a database pool, needs it in providers, or a CooldownStore provider of your own, such as a MemoryCooldownStore, in the store's place.

testing/greeting.module.spec.ts
it('runs the app’s global guards first, with app', async () => {
  const module = MeoCordTestingModule.create({ app: App, controllers: [GreetingSlashController] }).compile()

  await expect(module.invoke(GreetingSlashController, 'greet', greet('666666666666666666'))).resolves.toEqual({
    ran: false,
  })
  await expect(module.invoke(GreetingSlashController, 'greet', greet('1'))).resolves.toEqual({ ran: true })
  expect(inspectHandler(GreetingSlashController, 'greet', { app: App }).guards).toEqual([BlocklistGuard])
})

Swapping a dependency

overrideProvider(Class).useValue(stub) replaces a dependency with a stand-in that has only the members the test uses. A misspelled member is a compile error, so the stand-in can't drift from the class:

testing/greeting.module.spec.ts
it('swaps a dependency for a stand-in', async () => {
  const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController] })
    .overrideProvider(GreetingService)
    .useValue({ buildGreeting: name => `Hi ${name}` })
    .compile()
  const interaction = greet('1')

  await module.invoke(GreetingSlashController, 'greet', interaction)

  expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Hi Ada' })
})

A provider can also be listed in any shape the app takes, useValue, useClass or useFactory, under a class or a token; see Providers. overrideGuard, overrideInterceptor and overrideFilter swap the stages around a handler the same way, wherever they apply: globally, on the controller or on the method.

module.get(Class) returns an instance, for a direct test of a service as the container built it.

Testing the whole app

MeoCordTestingModule.fromApp(App) builds the module as the bot builds itself: every controller, service and provider @MeoCord lists, with what the app option takes from it: its global guards, interceptors and filters, presenter, translator, message options, theme, observers and cooldown store. A test lists nothing again, and replaces what it must by token in providers, before anything is made:

recipes/database/app.spec.ts
// A pool in memory, answering the two queries the store makes
function memoryPool() {
  const rows: { id: number; user_id: string; text: string }[] = []
  return {
    query: vi.fn(async (sql: string, [userId, text]: string[] = []) => {
      if (sql.startsWith('INSERT')) rows.push({ id: rows.length + 1, user_id: userId, text })
      return { rows: rows.filter(row => row.user_id === userId).map(({ id, text }) => ({ id, text })) }
    }),
  }
}

describe('the notes app', () => {
  it('saves and lists a note through its own wiring, with the database in memory', async () => {
    // Every controller, service and provider comes from @MeoCord; the database factory never runs
    const module = await MeoCordTestingModule.fromApp(App, {
      providers: [{ provide: DATABASE, useValue: memoryPool() }],
    })
      .compile()
      .init()
    const user = createMockInteraction(User, { id: '111' })
    const note = createMockInteraction(ChatInputCommandInteraction, {
      commandName: 'note',
      user,
      options: createChatInputOptions({ text: 'Water the plants' }),
    })
    const notes = createMockInteraction(ChatInputCommandInteraction, { commandName: 'notes', user })

    await module.dispatch(note)
    await module.dispatch(notes)

    expect(getResponse(notes).calls.at(-1)?.payload).toMatchObject({ content: '1. Water the plants' })
    await module.close()
  })
})

compile() runs no factory. init() runs each one the test didn't replace and makes the app's services, as the bot does before it logs in; the database factory replaced here never runs, so nothing connects. fromApp's controllers and observers options add a test's own, and the override* methods still apply.

The module makes no Discord Client: a class that injects one, a guard, interceptor or filter included, is refused where it is resolved, naming each class of the module that injects it, until the test provides one, such as { provide: Client, useValue: createMockClient() }.

Lifecycle hooks in a test

init() resolves the module's factory providers, including the ones that return a promise, and runs no hook. init({ ready: true }) also runs every onReady hook once, as the bot does when it comes online. close() runs the onShutdown hooks of everything the module built:

testing/lifecycle.spec.ts
it('runs onReady once the module is ready, and onShutdown on close', async () => {
  const client = createMockClient()
  const module = await MeoCordTestingModule.create({
    providers: [{ provide: ReminderScheduler, useClass: ReminderScheduler }],
  })
    .compile()
    .init({ ready: { client } })
  module.get(ReminderScheduler).add('111111111111111111', 'Water the plants')

  await vi.advanceTimersByTimeAsync(60_000)
  await module.close()
  await vi.advanceTimersByTimeAsync(60_000)

  expect(client.users.send).toHaveBeenCalledTimes(1)
})
  • Order. The hooks run as the bot runs them: onReady one at a time, each class after the classes it injects; onShutdown in reverse, so a pool closes after everything that uses it.
  • The client. onReady receives a mock client and { primary: true }, unless you pass init({ ready: { client, primary } }).
  • Failures. Every hook runs even when one throws, and init or close then rejects with that error, or with an AggregateError naming each hook that threw.
  • The cooldown store. The app's store, or the CooldownStore a test provides in its place, gets its onReady first and its onShutdown last, once the store operations its calls started have settled.
  • A shutdown that hangs. close() waits for the whole sequence for up to shutdownTimeout, 10 seconds unless create() or fromApp() sets it, as the bot's shutdownTimeout does. It then stops waiting and logs that it did, and still rejects with a hook that failed before then. A test whose fake store never answers, or whose onShutdown never settles, sets it short, such as shutdownTimeout: 50.
  • The theme outside calls. Once ready, the module's app theme is the one useTheme() reads outside any call, until close(), unless another module or app in the same process was ready first, which keeps it. See Testing recipes.

Running tests

A generated app has Vitest set up: vitest.config.ts compiles with SWC, which gives the decorator metadata injection needs, and every generated component has a spec beside it. A generated controller's spec invokes its handler through the testing module and checks the answer, so it fails when the handler stops answering.

Shell
npm test                # once
npm run test:watch      # on every change
npm run test:coverage   # with a coverage report

vitest.setup.ts runs before every spec file. It has MeoCord's mocks made with vi.fn, so Vitest treats them as its own, and strict, so they compute what discord.js computes, and resets them after every test:

config/vitest.setup.ts
import 'reflect-metadata'
import { resetAllMocks, useMockFn, useStrictMocks } from 'meocord/testing'
import { afterEach, vi } from 'vitest'

// meocord/testing makes its mocks with vi.fn, so Vitest's matchers, config and vi.mocked() treat them as its own
useMockFn(vi.fn)
// Mocks compute what discord.js computes, such as a message's editable or a member's kickable, rather than placeholders
useStrictMocks()
// Every test starts from meocord's mocks as they were created
afterEach(() => resetAllMocks())

Tests don't load .env, so a real token never reaches a spec unless you ask for it, and tests run the same before and after a build. A project whose tests need its variables loads the files meocord.config.ts reads under test, in vitest.setup.ts: config({ path: ['.env.test.local', '.env.test', '.env'], quiet: true }), with config from dotenv.

What tests can't tell you

The mocks behave like discord.js, but they aren't Discord. These fail only against the real thing:

  • Discord's limits: 2,000 characters in a message, 25 choices, 5 buttons in a row, 45 characters in a modal title, and the rest of Discord's validation.
  • Permissions and role order: a bot whose role sits below a member's can't time them out, whatever the code says.
  • Intents: an event the bot never receives because its intent is missing.
  • Startup: a missing or invalid token, a config that doesn't load, a native addon built for another platform.

Before a release, start the bot against a test server with npx meocord start --dev, and use each changed command once. Start it with a missing and a wrong token too, and check that it says so and exits.

In CI

The checks a generated project runs locally are the ones to run in CI. Tests need no token and no network, so CI needs no secrets:

YAML
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run test:coverage
      - run: npx meocord build --prod

Gotchas

  • State set once for a whole describe is gone after the first test. vitest.setup.ts resets every MeoCord mock after each test, through resetAllMocks(); a vi.fn() of your own only has its calls cleared. Set what a mock returns in the test that relies on it, or in beforeEach.
  • A module shared across tests shares its state. Cooldown counts and service fields carry over. Build one module per test, or per describe when the tests change nothing in it.
  • Calling a controller method directly skips the pipeline. module.get(Controller).method(interaction) runs its own guards, but no interceptors, validation or filters. Use invoke to test what dispatch runs around a handler.

Build it

The feedback bot opens a form with /feedback and posts what members send for staff to review. Give its controller a spec. The settings the bot reads from the environment become test values, and each test runs a handler as dispatch would:

tutorial/feedback.controller.spec.ts
describe('FeedbackController', () => {
  const settings = { reviewChannelId: '500', staffRoleId: '900' }
  const compile = () =>
    MeoCordTestingModule.create({
      controllers: [FeedbackController],
      // The real settings read the environment; the test says where the review channel is
      providers: [{ provide: FeedbackSettings, useValue: settings }],
    }).compile()
  const ada = createMockInteraction(User, { id: '111', username: 'ada' })

  it('opens the form in the member’s language', async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction, { user: ada, locale: Locale.Indonesian })

    await compile().invoke(FeedbackController, 'open', interaction)

    const [call] = getResponse(interaction).calls
    expect(call.method).toBe('showModal')
    expect(JSON.parse(JSON.stringify(call.payload))).toMatchObject({
      custom_id: 'feedback/submit',
      title: 'Kirim masukan',
    })
  })

  it('opens the form once every five minutes for each member', async () => {
    const module = compile()
    const open = () => createMockInteraction(ChatInputCommandInteraction, { user: ada })

    await module.invoke(FeedbackController, 'open', open())

    await expect(module.invoke(FeedbackController, 'open', open())).rejects.toBeInstanceOf(CooldownError)
  })

  it('posts the feedback for review with its buttons, and thanks the author privately', async () => {
    const channel = createMockChannel(TextChannel)
    channel.isSendable.mockReturnValue(true)
    const client = createMockClient()
    client.channels.fetch.mockResolvedValue(channel as never)
    const interaction = createMockInteraction(ModalSubmitInteraction, {
      customId: 'feedback/submit',
      user: ada,
      client: client as never,
      fields: createModalFields({ about: 'Music bot', details: 'It skips songs.' }),
    })

    await compile().invoke(FeedbackController, 'submit', interaction)

    expect(client.channels.fetch).toHaveBeenCalledWith('500')
    const post = JSON.parse(JSON.stringify(channel.send.mock.calls[0][0]))
    expect(post.embeds[0]).toMatchObject({
      title: 'Feedback #1 from ada',
      description: '**Music bot**\nIt skips songs.',
    })
    expect(post.components[0].components.map((button: { custom_id: string }) => button.custom_id)).toEqual([
      'feedback/1/approve',
      'feedback/1/reject',
    ])
    expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks! The staff will read it soon.' })
    expect(interaction.ephemeral).toBe(true)
  })
})

Run npm test. The form opens in Ada's language and refuses a second try within five minutes, and their submission reaches the review channel with its buttons.

Next steps

  • Invoke and dispatch: the two ways to run a handler, and which to use when.
  • Mocks: the interactions, messages, users and errors a test passes in.
  • Testing recipes: themes, guards, cooldowns and collectors under test.
  • Services: designing services a test can build with new.