Skip to content

Menu Plugin

Testing @grammyjs/menu with reply.clickButton().

Key insight

The @grammyjs/menu plugin uses an opaque internal callback_data format. Don't try to assert on reply.buttons[n].callbackData — it contains an internal identifier. Always match buttons by their visible text label.

Basic menu test

ts
import { Menu } from '@grammyjs/menu';
import { prepareBot } from 'grammy-testing';
import { Bot } from 'grammy';
import { describe, expect, it } from 'vitest';

describe('menu plugin', () => {
  it('triggers the registered handler on click', async () => {
    const bot = new Bot('test-token');
    let handlerRan = false;

    const menu = new Menu('main').text('Click me', async (ctx) => {
      handlerRan = true;
      await ctx.reply('Button clicked!');
    });

    bot.use(menu);
    bot.command('start', async (ctx) => {
      await ctx.reply('Choose:', { reply_markup: menu });
    });

    const { chats } = await prepareBot(bot);
    const user = chats.newUser();

    await user.sendCommand('/start');
    const reply = user.replies.lastOrThrow();

    expect(reply.text).toBe('Choose:');
    expect(reply.buttons.map((b) => b.text)).toContain('Click me');

    await reply.clickButton('Click me');

    expect(handlerRan).toBe(true);
    expect(user.replies.lastOrThrow().text).toBe('Button clicked!');
  });
});

Multi-button menu

ts
it('routes to the correct handler', async () => {
  const bot = new Bot('test-token');
  let chosen: string | undefined;

  const menu = new Menu('choice')
    .text('Yes', async (ctx) => {
      chosen = 'yes';
      await ctx.reply('You chose yes');
    })
    .text('No', async (ctx) => {
      chosen = 'no';
      await ctx.reply('You chose no');
    });

  bot.use(menu);
  bot.command('ask', async (ctx) => {
    await ctx.reply('Yes or no?', { reply_markup: menu });
  });

  const { chats } = await prepareBot(bot);
  const user = chats.newUser();

  await user.sendCommand('/ask');
  await user.replies.lastOrThrow().clickButton('No');

  expect(chosen).toBe('no');
  expect(user.replies.lastOrThrow().text).toBe('You chose no');
});

Dynamic menus

For menus that change based on state, assert on the updated reply after the click:

ts
const menu = new Menu('toggle').text(
  (ctx) => (ctx.session.enabled ? 'Disable' : 'Enable'),
  async (ctx) => {
    ctx.session.enabled = !ctx.session.enabled;
    await ctx.editMessageReplyMarkup({ reply_markup: menu });
  },
);

After clicking, check chats.editsFor(user) to verify the menu was updated.

Released under the MIT License.