Skip to content

gramiojs/test

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

19 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

@gramio/test

npm npm downloads JSR JSR Score

An event-driven test framework for bots built with GramIO. Users are the primary actors β€” they send messages, join/leave chats, click inline buttons β€” and the framework manages in-memory state and emits the correct Telegram updates to the bot under test.

Installation

bun add -d @gramio/test

Quick Start

import { describe, expect, it } from "bun:test";
import { Bot, format, bold } from "gramio";
import { TelegramTestEnvironment } from "@gramio/test";

describe("My bot", () => {
    it("should reply to /start", async () => {
        const bot = new Bot("test");
        bot.command("start", (ctx) => ctx.send("Welcome!"));

        const env = new TelegramTestEnvironment(bot);
        const user = env.createUser({ first_name: "Alice" });

        await user.sendCommand("start");

        expect(env.apiCalls[0].method).toBe("sendMessage");
    });

    it("should handle formatted messages", async () => {
        const bot = new Bot("test");
        bot.on("message", (ctx) => ctx.send("Got it!"));

        const env = new TelegramTestEnvironment(bot);
        const user = env.createUser();

        // FormattableString from gramio's format`` tag β€” text and entities extracted automatically
        await user.sendMessage(format`Check out ${bold("this")} link`);
    });
});

API

TelegramTestEnvironment

The central orchestrator. Wraps a GramIO Bot, intercepts all outgoing API calls, and provides factories for users and chats.

const bot = new Bot("test");
const env = new TelegramTestEnvironment(bot);
  • env.createUser(payload?) β€” creates a UserObject linked to the environment
  • env.createChat(payload?) β€” creates a ChatObject (group, supergroup, channel, etc.)
  • env.emitUpdate(update) β€” sends a raw TelegramUpdate or MessageObject to the bot
  • env.onApi(method, handler) β€” override the response for a specific API method (see Mocking API Responses)
  • env.offApi(method?) β€” remove a custom handler (or all handlers if no method given)
  • env.apiCalls β€” array of { method, params, response } recording every API call the bot made
  • env.clearApiCalls() β€” empties the apiCalls array (useful between logical test phases)
  • env.lastApiCall(method) β€” returns the most recent recorded call for method, or undefined if none
  • env.users / env.chats β€” all created users and chats

UserObject β€” the primary actor

Users drive the test scenario. Create them via env.createUser():

const user = env.createUser({ first_name: "Alice" });

user.sendMessage(text, options?) β€” send a PM to the bot

Accepts a plain string or a format\` FormattableString(text and entities are extracted automatically). PassMessageOptions` to attach extra entities or set a reply.

import { format, bold, italic } from "gramio";

const msg = await user.sendMessage("Hello");

// FormattableString β€” entities are auto-extracted
await user.sendMessage(format`Hello ${bold("world")}`);

// With options
await user.sendMessage("reply here", { reply_to: msg });
await user.sendMessage(format`${italic("important")}`, { reply_to: msg });
interface MessageOptions {
    entities?: TelegramMessageEntity[]; // extra entities to merge
    reply_to?: MessageObject;           // sets reply_to_message
}

user.sendMessage(chat, text, options?) β€” send a message to a group

const group = env.createChat({ type: "group", title: "Test Group" });
await user.sendMessage(group, "Hello group");
await user.sendMessage(group, format`${bold("Bold")} in group`);

user.sendReply(message, text) β€” reply to a message

Shortcut that automatically sets reply_to_message and targets the same chat the original message was in.

const msg = await user.sendMessage("Hello");
await user.sendReply(msg, "Nice to meet you!");
await user.sendReply(msg, format`Thanks ${bold("a lot")}!`);

user.sendCommand(command, args?) β€” send a bot command

Produces the correct text and bot_command entity. Equivalent to a user typing /command args in Telegram.

await user.sendCommand("start");          // text: "/start"
await user.sendCommand("start", "ref42"); // text: "/start ref42"

// To a group:
await user.sendCommand(group, "help");

Media send methods

All media methods auto-generate file_id/file_unique_id and required fields. They all accept an optional leading ChatObject to send to a specific chat.

// Photo
await user.sendPhoto();
await user.sendPhoto({ caption: "Look!", spoiler: true });
await user.sendPhoto(group, { caption: format`${bold("Photo")} incoming` });

// Video
await user.sendVideo();
await user.sendVideo({ caption: "Watch this", spoiler: false });

// Document
await user.sendDocument();
await user.sendDocument({ caption: "file.pdf" });

// Voice message
await user.sendVoice();

// Audio file
await user.sendAudio();
await user.sendAudio({ caption: "My track" });

// Animation (GIF)
await user.sendAnimation();
await user.sendAnimation(group, { caption: "Funny gif" });

// Video note (circle video)
await user.sendVideoNote();

// Sticker (accepts Partial<TelegramSticker> overrides instead of MediaOptions)
await user.sendSticker();
await user.sendSticker({ emoji: "πŸ”₯", type: "custom_emoji" });

// Location
await user.sendLocation({ latitude: 48.8566, longitude: 2.3522 });

// Contact
await user.sendContact({ phone_number: "+1234567890", first_name: "Alice" });

// Dice
await user.sendDice();        // 🎲
await user.sendDice("🎯");
await user.sendDice(group, "πŸ€");
interface MediaOptions {
    caption?: string | FormattableString; // caption text (entities auto-extracted from FormattableString)
    spoiler?: boolean;                    // sets has_media_spoiler = true
}

user.join(chat) / user.leave(chat) β€” join or leave a group

Emits a chat_member update and a service message (new_chat_members / left_chat_member). Updates chat.members set.

await user.join(group);
expect(group.members.has(user)).toBe(true);

await user.leave(group);
expect(group.members.has(user)).toBe(false);

user.in(chat) β€” scope to a chat

Returns a UserInChatScope with the chat pre-bound. All methods on the scope delegate to the underlying user.

const group = env.createChat({ type: "group" });

await user.in(group).sendMessage("Hello");
await user.in(group).sendMessage(format`${bold("Hello")} group`);
await user.in(group).sendCommand("help");
await user.in(group).sendReply(originalMsg, "Thanks!");
await user.in(group).sendPhoto({ caption: "Look at this" });
await user.in(group).sendVideo();
await user.in(group).sendDocument();
await user.in(group).sendVoice();
await user.in(group).sendAudio();
await user.in(group).sendAnimation();
await user.in(group).sendVideoNote();
await user.in(group).sendSticker();
await user.in(group).sendLocation({ latitude: 51.5, longitude: -0.1 });
await user.in(group).sendContact({ phone_number: "+1" });
await user.in(group).sendDice("🎯");
await user.in(group).sendInlineQuery("cats");          // chat_type: "group"
await user.in(group).sendInlineQuery("cats", { offset: "10" });
await user.in(group).join();
await user.in(group).leave();

Chain .on(msg) to reach the message scope:

const msg = await user.sendMessage(group, "Pick one");
await user.in(group).on(msg).react("πŸ‘");
await user.in(group).on(msg).click("choice:A");

user.on(msg) β€” scope to a message

Returns a UserOnMessageScope with the message pre-bound. Useful when you already have a message and don't need to re-state the chat.

const msg = await user.sendMessage("Nice bot!");
await user.on(msg).react("πŸ‘");
await user.on(msg).react("❀", { oldReactions: ["πŸ‘"] });
await user.on(msg).click("action:1");

user.on(msg).clickByText(buttonText) β€” click an inline button by its label

Scans the message's inline_keyboard for a button whose text matches, then emits a callback_query for its callback_data. Throws if no inline keyboard is present or no button matches.

msg.payload.reply_markup = {
    inline_keyboard: [
        [{ text: "Option A", callback_data: "opt:a" }],
        [{ text: "Option B", callback_data: "opt:b" }],
    ],
};

await user.on(msg).clickByText("Option B"); // emits callback_query with data "opt:b"

user.editMessage(message, text) β€” edit a message

Updates the message's text in-memory and emits an edited_message update. Works with bot.on("edited_message", ...) handlers. GramIO exposes the edit timestamp as ctx.updatedAt.

const msg = await user.sendMessage("Original text");
await user.editMessage(msg, "Updated text");

// With FormattableString
await user.editMessage(msg, format`${bold("Bold")} new text`);

user.forwardMessage(message, toChat?) β€” forward a message

Emits a message update with forward_origin set. If toChat is omitted the message is forwarded to the user's private chat.

const original = await user.sendMessage(group, "Forward me!");
await user.forwardMessage(original);            // forward to own PM
await user.forwardMessage(original, otherGroup); // forward to another chat

user.sendMediaGroup(chat, payloads[]) β€” send multiple media as an album

Emits one message update per item, all sharing the same media_group_id. Returns an array of MessageObject.

const [msg1, msg2] = await user.sendMediaGroup(group, [
    { photo: [{ file_id: "f1", file_unique_id: "u1", width: 800, height: 600 }] },
    { photo: [{ file_id: "f2", file_unique_id: "u2", width: 800, height: 600 }] },
]);

expect(msg1.payload.media_group_id).toBe(msg2.payload.media_group_id);

user.pinMessage(message, inChat?) β€” pin a message

Emits a service message update with pinned_message set. GramIO routes these to the "pinned_message" event (not "message"), so listen with bot.on("pinned_message", ...).

const msg = await user.sendMessage("Important announcement");
await user.pinMessage(msg);        // pinned in msg's own chat
await user.pinMessage(msg, group); // pinned notification sent to a specific chat

user.click(callbackData, message?) β€” click an inline button

Emits a callback_query update.

const msg = await user.sendMessage("Pick an option");
await user.click("option:1", msg);

user.react(emojis, message?) β€” react to a message

Emits a message_reaction update. Works with bot.reaction() handlers.

Reaction state is tracked automatically on each MessageObject β€” you never need to declare what the user previously had. The old_reaction field of the emitted update is filled in from the message's in-memory state.

const msg = await user.sendMessage("Nice bot!");

// Add a reaction (old: [], new: ["πŸ‘"])
await user.react("πŸ‘", msg);

// Change reaction β€” old is auto-computed from memory (old: ["πŸ‘"], new: ["❀"])
await user.react("❀", msg);

// React with multiple emojis
await user.react(["πŸ‘", "πŸ”₯"], msg);

// Remove all reactions β€” pass an empty array (old: auto, new: [])
await user.react([], msg);

The current state is accessible on the message object:

msg.reactions.get(user.payload.id); // e.g. ["❀"]
msg.reactions.has(user.payload.id); // false after react([])

Multiple users can react independently β€” each user's state is tracked separately:

await alice.react("πŸ‘", msg);
await bob.react("❀", msg);

msg.reactions.get(alice.payload.id); // ["πŸ‘"]
msg.reactions.get(bob.payload.id);   // ["❀"]

Using ReactObject for full control:

// old_reaction is also auto-tracked when .on(msg) is used
await user.react(new ReactObject().on(msg).add("πŸ‘", "πŸ”₯"));

// Explicit .remove() overrides auto-tracking for old_reaction
await user.react(new ReactObject().on(msg).add("❀").remove("😒"));

Via scoped API β€” same auto-tracking applies:

await user.on(msg).react("πŸ‘");   // memory: ["πŸ‘"]
await user.on(msg).react("❀");    // old auto = ["πŸ‘"], new = ["❀"]
await user.on(msg).react([]);     // remove all, old auto = ["❀"]

user.sendInlineQuery(query, chatOrOptions?, options?) β€” send an inline query

Emits an inline_query update. Works with bot.inlineQuery() handlers. Pass a ChatObject as the second argument to automatically set chat_type.

// Simple β€” no chat context
const q = await user.sendInlineQuery("search cats");

// With chat β€” chat_type is derived automatically
const group = env.createChat({ type: "group" });
const q = await user.sendInlineQuery("search cats", group);

// With options only
await user.sendInlineQuery("search dogs", { offset: "10" });

// With chat + offset
await user.sendInlineQuery("search dogs", group, { offset: "10" });

user.chooseInlineResult(resultId, query, options?) β€” choose an inline result

Emits a chosen_inline_result update. Works with bot.chosenInlineResult() handlers.

await user.chooseInlineResult("result-1", "search cats");

// With inline_message_id for inline-mode messages
await user.chooseInlineResult("result-1", "search cats", { inline_message_id: "abc" });

ChatObject

Wraps TelegramChat with in-memory state tracking:

  • chat.members β€” Set<UserObject> of current members
  • chat.messages β€” MessageObject[] history of all messages in the chat

chat.post(text) β€” anonymous channel post

Emits a channel_post update with no from field β€” matching real Telegram channel behavior. Use this to test bot.on("channel_post", ...) handlers.

const channel = env.createChat({ type: "channel", title: "My Channel" });

await channel.post("Breaking news!");
await channel.post(format`Check out ${bold("this")}`);

MessageObject

Wraps TelegramMessage with a fluent builder API. Useful for constructing exotic messages that the user.send* shortcuts don't cover, then emitting them via env.emitUpdate().

import { format, bold, link } from "gramio";
import { MessageObject } from "@gramio/test";

// Basic
const message = new MessageObject({ text: "Hello" })
    .from(user)
    .chat(group);

// Formatted text β€” entities extracted from FormattableString
new MessageObject()
    .from(user)
    .text(format`Check out ${link("https://gramio.dev", "GramIO")}`)
    .replyTo(originalMsg);

// Photo with spoiler
new MessageObject()
    .from(user)
    .photo()                     // auto-generates file_id and two sizes
    .caption(format`${bold("Spoiler!")}`)
    .spoiler();

// Rich message
new MessageObject()
    .from(user).chat(group)
    .text("media group item")
    .photo()
    .mediaGroupId("group-1")
    .topicMessage()
    .protect();

Content methods:

Method Description
.from(user) Set from field; auto-creates private chat if no chat set
.chat(chat) Set chat field
.text(str | FormattableString) Set message text (entities auto-extracted from FormattableString)
.caption(str | FormattableString) Set caption (entities auto-extracted)
.entities(...entities) Append text entities
.captionEntities(...entities) Append caption entities

Attachment methods (all auto-generate file_id/file_unique_id):

Method Description
.photo(overrides?) Attach photo (default: two sizes 100Γ—100 and 800Γ—600)
.video(overrides?) Attach video (1280Γ—720, 10s)
.document(overrides?) Attach document
.audio(overrides?) Attach audio (30s)
.sticker(overrides?) Attach sticker (512Γ—512, type "regular")
.voice(overrides?) Attach voice (5s)
.videoNote(overrides?) Attach video note (240px, 10s)
.animation(overrides?) Attach animation (480Γ—270, 3s)
.contact(partial) Attach contact
.location(partial) Attach location
.dice(overrides?) Attach dice (🎲, random value)
.venue(partial) Attach venue
.game(partial) Attach game
.story(partial) Attach story
.poll(partial) Attach poll
.successfulPayment(overrides?) Attach successful payment

Structure methods:

Method Description
.replyTo(message) Set reply_to_message
.spoiler() has_media_spoiler = true
.protect() has_protected_content = true
.topicMessage() is_topic_message = true
.mediaGroupId(id) Set media_group_id
.effectId(id) Set effect_id
.viaBot(user) Set via_bot
.quote(text, entities?) Set reply quote (accepts FormattableString)
.linkPreviewOptions(options) Set link_preview_options

Payments

Simulate Telegram Payments: pre-checkout queries, shipping queries, and successful payment service messages.

user.sendPreCheckoutQuery(overrides?) β€” emit a pre_checkout_query update:

const bot = new Bot("test");
bot.on("pre_checkout_query", async (ctx) => {
    await ctx.answerPreCheckoutQuery({ ok: true });
});

const env = new TelegramTestEnvironment(bot);
const user = env.createUser();

await user.sendPreCheckoutQuery({
    currency: "XTR",
    total_amount: 100,
    invoice_payload: "product_123",
});

const call = env.lastApiCall("answerPreCheckoutQuery");
expect(call).toBeDefined();

user.sendShippingQuery(overrides?) β€” emit a shipping_query update:

await user.sendShippingQuery({
    invoice_payload: "physical_item",
});
// Default shipping address is San Francisco, US

user.sendSuccessfulPayment(overrides?) β€” full payment flow: emits pre_checkout_query first, verifies the bot approved it, then emits successful_payment. This mirrors real Telegram behavior where a successful payment is only possible after the bot confirms the pre-checkout query.

bot.on("pre_checkout_query", async (ctx) => {
    await ctx.answerPreCheckoutQuery({ ok: true });
});
bot.on("successful_payment", (ctx) => {
    // ctx.successfulPayment.invoicePayload, ctx.successfulPayment.totalAmount, etc.
});

await user.sendSuccessfulPayment({
    currency: "XTR",
    total_amount: 100,
    invoice_payload: "sub_monthly",
});

// Send to a specific chat:
await user.sendSuccessfulPayment(group, { invoice_payload: "group_purchase" });

// Scoped variant:
await user.in(group).sendSuccessfulPayment({ invoice_payload: "scoped" });

Throws if the bot doesn't handle pre_checkout_query or rejects it with ok: false β€” just like Telegram would never send successful_payment in those cases.

PreCheckoutQueryObject

Wraps TelegramPreCheckoutQuery with builder methods:

const query = new PreCheckoutQueryObject()
    .from(user)
    .currency("USD")
    .totalAmount(500)
    .invoicePayload("product_123")
    .shippingOptionId("express")
    .orderInfo({ name: "Alice" });

ShippingQueryObject

Wraps TelegramShippingQuery with builder methods:

const query = new ShippingQueryObject()
    .from(user)
    .invoicePayload("physical_item")
    .shippingAddress({ country_code: "DE", city: "Berlin" });

CallbackQueryObject

Wraps TelegramCallbackQuery with builder methods:

const cbQuery = new CallbackQueryObject()
    .from(user)
    .data("action:1")
    .message(msg);

ReactObject

Chainable builder for message_reaction updates. Use with user.react() or emit directly via env.emitUpdate().

Method Description
.from(user) Set the user who reacted (auto-filled by user.react())
.on(message) Attach to a message and infer the chat
.inChat(chat) Override the chat explicitly
.add(...emojis) Emojis being added (new_reaction)
.remove(...emojis) Emojis being removed (old_reaction)
const reaction = new ReactObject()
    .on(msg)
    .add("πŸ‘", "πŸ”₯")
    .remove("😒");

await user.react(reaction);

InlineQueryObject

Wraps TelegramInlineQuery with builder methods:

const inlineQuery = new InlineQueryObject()
    .from(user)
    .query("search cats")
    .offset("0");

ChosenInlineResultObject

Wraps TelegramChosenInlineResult with builder methods:

const result = new ChosenInlineResultObject()
    .from(user)
    .resultId("result-1")
    .query("search cats");

Inspecting Bot API Calls

The environment intercepts all outgoing API calls (no real HTTP requests are made) and records them:

const bot = new Bot("test");
bot.on("message", async (ctx) => {
    await ctx.send("Reply!");
});

const env = new TelegramTestEnvironment(bot);
const user = env.createUser();

await user.sendMessage("Hello");

expect(env.apiCalls).toHaveLength(1);
expect(env.apiCalls[0].method).toBe("sendMessage");
expect(env.apiCalls[0].params.text).toBe("Reply!");

Use env.clearApiCalls() to reset between logical phases of a test, and env.lastApiCall(method) to find the most recent call for a method without scanning the whole array:

await user.sendMessage("First");
await user.sendMessage("Second");

const last = env.lastApiCall("sendMessage");
expect(last?.params.text).toBe("Reply!"); // bot's response to "Second"

env.clearApiCalls();
expect(env.apiCalls).toHaveLength(0);

Mocking API Responses

Use env.onApi() to control what the bot receives from the Telegram API. Accepts a static value or a dynamic handler function:

// Static response
env.onApi("getMe", { id: 1, is_bot: true, first_name: "TestBot" });

// Dynamic response based on params
env.onApi("sendMessage", (params) => ({
    message_id: 1,
    date: Date.now(),
    chat: { id: params.chat_id, type: "private" },
    text: params.text,
}));

Simulating Errors

Use apiError() to create a TelegramError that the bot will receive as a rejected promise β€” matching exactly how real Telegram API errors work in GramIO:

import { TelegramTestEnvironment, apiError } from "@gramio/test";

// Bot is blocked by user
env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));

// Rate limiting
env.onApi("sendMessage", apiError(429, "Too Many Requests", { retry_after: 30 }));

// Conditional β€” error for some chats, success for others
env.onApi("sendMessage", (params) => {
    if (params.chat_id === blockedUserId) {
        return apiError(403, "Forbidden: bot was blocked by the user");
    }
    return { message_id: 1, date: Date.now(), chat: { id: params.chat_id, type: "private" }, text: params.text };
});

Resetting

env.offApi("sendMessage"); // reset specific method
env.offApi();              // reset all overrides

About

testing bots with GramIO

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors