Skip to content
GitHub

Testing themes, guards and cooldowns

MeoCord 4.2 · Testing · page 35 of 41 · since 4.1.0

Test what a theme gives a handler, why a guard refused, when a cooldown lets a user in, and what fails.

You'll learn

  • Change a theme for one module, and run a service in a theme
  • Test a guard alone and read what a handler is set up with
  • Move a cooldown along with fake timers
  • Test what the member sees when something fails

Most handler tests are an invoke and a look at getResponse. Some parts of a bot need a little more: a theme that changes per server, a guard that reads the handler's metadata, a cooldown that only lets a user in after time has passed, and the answers a member gets when something goes wrong.

This page collects the patterns for each.

When to use it

Reach for these when a test depends on more than the handler's input:

  • the theme the handler answers in, or that a service reads with useTheme();
  • a guard's decision, on its own or as the handler is set up;
  • a cooldown over time;
  • a failure the member should see answered.

For a handler with none of these, Invoke and dispatch is all you need.

Example

testing/themes.spec.ts
it('changes one token of the app’s theme for one module', async () => {
  const module = MeoCordTestingModule.create({ app: ShopApp, controllers: [ShopController] })
    .overrideTheme({ colors: { primary: '#E3606D' } })
    .compile()
  const click = createMockInteraction(ButtonInteraction, { customId: 'shop/buy' })

  await module.invoke(ShopController, 'buy', click)

  expect(colourOf(click)).toBe(resolveColor('#E3606D'))
})

overrideTheme changes the app's primary colour for this module alone. The embed the handler sends has no colour of its own, so respond() fills it from the theme, and the test reads the colour back from getResponse.

How it works

A testing module runs each call in its theme as the bot does: MeoCord's defaults, the app's theme, each @UseTheme, then what themeFor looks up for the call's server and user. Guards and cooldowns run in the same pipeline, in the bot's order. Each module counts cooldowns in a store of its own: a fresh MemoryCooldownStore, or a new instance of the app's cooldownStore, which shares its counts when it keeps them outside the process, as a Redis store does. A test that provides CooldownStore itself gets that store instead, and a useValue provider is one instance, shared by every module given it.

The helpers here change one of those inputs for one test, and leave the rest as the bot has them.

Themes

overrideTheme(theme) merges over the app's @MeoCord({ theme }) for one module, as the example shows. It names only the tokens it changes, and each @UseTheme still goes over it.

overrideThemeFor(resolvers) replaces the app's themeFor, or removes it with undefined. The results are cached as the bot caches them, in the module's own ThemeCache, module.themeCache, so a mock resolver shows each lookup:

testing/themes.spec.ts
it('looks a server’s theme up once, until the module’s cache is cleared', async () => {
  const guild = vi.fn(() => ({ colors: { primary: '#26A042' } }) as const)
  const module = MeoCordTestingModule.create({ app: ShopApp, controllers: [ShopController] })
    .overrideThemeFor({ guild })
    .compile()
  const click = () =>
    createMockInteraction(ButtonInteraction, { customId: 'shop/buy', guildId: '100000000000000001' })

  await module.dispatch(click())
  await module.dispatch(click())
  module.themeCache.invalidateGuild('100000000000000001')
  await module.dispatch(click())

  expect(guild).toHaveBeenCalledTimes(2)
})

overrideThemeFor takes a class implementing ThemeResolver too, in place of functions, and functions in place of the app's class. The module binds the class as the app does, so overrideProvider replaces the class, or a service it injects:

testing/themes.spec.ts
it('replaces the service a themeFor class injects', async () => {
  const module = MeoCordTestingModule.create({ app: ShopApp, controllers: [ShopController] })
    .overrideThemeFor(UserThemes)
    .overrideProvider(PrefsService)
    .useValue({ themeOf: async () => ({ colors: { primary: '#26A042' } }) })
    .compile()
  const click = createMockInteraction(ButtonInteraction, { customId: 'shop/buy' })

  await module.invoke(ShopController, 'buy', click)

  expect(colourOf(click)).toBe(resolveColor('#26A042'))
})

A service or presenter tested without a module runs in a theme with withTheme(theme, fn). It takes a whole theme that createMockTheme(overrides?) builds, frozen, with the overrides merged over the defaults, or just the roles to change, which it merges the same way:

testing/themes.spec.ts
it('runs a service in a theme, with no module', () => {
  const theme = createMockTheme({ emojis: { success: '🎉' } })

  const line = withTheme(theme, () => new Receipts().paid(5))

  expect(line).toBe('🎉 Paid 5 coins')
})

When an app adds tokens of its own, createMockTheme requires them, as the app's root theme does, since they have no default. Once init({ ready: true }) has run, the module's app theme is also the one useTheme() reads outside any call, until close(), unless another module or app in the same process was ready first, which keeps it: close each module in afterEach. Theming covers the theme itself.

Guards

A guard that reads the handler's metadata can be tested alone. Build it with an ExecutionContext for that handler, from createExecutionContext(Controller, 'method', { args }):

controllers/slash/moderation.slash.controller.spec.ts
it('reads the roles from the handler, in a unit test of the guard', () => {
  // Created without a guildId, the mock is outside a server, with no member to have the roles
  const interaction = createMockInteraction(ChatInputCommandInteraction)
  const guard = new RolesGuard(createExecutionContext(ModerationSlashController, 'ban', { args: [interaction] }))

  expect(guard.canActivate(interaction)).toBe(false)
})

To check how a handler is set up, without running it, inspectHandler lists its guards, interceptors, filters and cooldowns in the order dispatch applies them, with each guard's params, and reads its metadata:

controllers/slash/moderation.slash.controller.spec.ts
it('lists what each handler is set up with', () => {
  expect(inspectHandler(ModerationSlashController, 'trade').guards).toEqual([
    { provide: ChannelGuard, params: { channelIds: ['111111111111111111'] } },
  ])
  expect(inspectHandler(ModerationSlashController, 'ban').get(Roles)).toEqual([ROLE_IDS.admin, ROLE_IDS.moderator])
})

With { app }, the app's global guards come first. Through invoke, a guard that returns false resolves with ran: false, and one that throws GuardDeniedError rejects with it.

Cooldowns

Each testing module here counts in a fresh store, so the second call within the window is refused and a different user is let in:

controllers/slash/daily.slash.controller.spec.ts
it('refuses a second call within three seconds, per user', async () => {
  // Each testing module counts in a fresh store
  const module = MeoCordTestingModule.create({ controllers: [DailySlashController] }).compile()

  await module.invoke(DailySlashController, 'daily', from('1'))

  await expect(module.invoke(DailySlashController, 'daily', from('1'))).rejects.toBeInstanceOf(CooldownError)
  await expect(module.invoke(DailySlashController, 'daily', from('2'))).resolves.toEqual({ ran: true })
})

it('lists its cooldowns with their defaults', () => {
  expect(inspectHandler(DailySlashController, 'daily').cooldowns).toEqual([
    expect.objectContaining({ seconds: 3, uses: 1, per: 'user' }),
    expect.objectContaining({ seconds: 60, uses: 5, per: 'user' }),
  ])
})

To see the window end, use fake timers and move time along:

testing/cooldown-time.spec.ts
it('lets the user in again once the three seconds have passed', async () => {
  const module = MeoCordTestingModule.create({ controllers: [DailySlashController] }).compile()
  await module.invoke(DailySlashController, 'daily', from('1'))

  await expect(module.invoke(DailySlashController, 'daily', from('1'))).rejects.toBeInstanceOf(CooldownError)
  await vi.advanceTimersByTimeAsync(3_000)

  await expect(module.invoke(DailySlashController, 'daily', from('1'))).resolves.toEqual({ ran: true })
})

A cooldown store of your own is checked against the same behaviour MeoCord's stores meet, with testCooldownStore; see Cooldown stores.

Failure paths

What a member sees when something goes wrong deserves a test as much as the happy path:

  • An error no filter handles rejects invoke, so await expect(...).rejects.toThrow(...) checks it. Through dispatch, the member gets the fallback's answer first, and getResponse shows it.
  • An error a filter handled resolves, with error set, and getResponse shows the filter's answer.
  • A UserError is the member's own outcome: dispatch resolves with it, after answering an interaction privately, or replying to a message in its channel.
  • Discord's own errors come from a mock that rejects with createDiscordError(code). Here the author has closed their DMs, and the review must be recorded anyway:
tutorial/review.controller.spec.ts
it('still records the verdict when the author has closed their DMs', async () => {
  const { module } = setup()
  const interaction = click('feedback/1/reject', STAFF)
  // 50007: Discord refuses to deliver a DM to this user
  interaction.client.users.send.mockRejectedValue(createDiscordError(50007))

  await expect(module.invoke(ReviewController, 'reject', interaction)).resolves.toEqual({ ran: true })
  expect(module.get(FeedbackService).get('1').status).toBe('rejected')
})

Gotchas

  • Fake timers left on break the next test. Turn them on in beforeEach and off in afterEach, with vi.useRealTimers(), as the cooldown example does.
  • A module shared across tests shares its cooldown counts. A second test sees the first test's calls. Build the module in the test, or in beforeEach.
  • overrideThemeFor replaces the app's resolvers. To test the app's own themeFor, don't override it: give the mock interaction the guildId or user the resolver expects.

Next steps

  • Theming: tokens, @UseTheme and themeFor, which these tests exercise.
  • Guards: writing the guards tested here.
  • Cooldowns: windows, uses and scopes.
  • Mocks: a collector's click, under test.