Skip to content
gramiojsPublic

About

testing bots with GramIO

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

33 Commits

Folders and files

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. Builder instances (e.g. InlineKeyboard) in params.reply_markup and params.results[].reply_markup are unwrapped to plain JSON before recording, so you never need to JSON.parse(JSON.stringify(...)) to assert on them.
  • env.clearApiCalls() β€” empties the API-call journal without deleting messages or breaking live references
  • env.lastApiCall(method) β€” returns the most recent recorded call for method, or undefined if none
  • env.filterApiCalls(method) β€” returns all recorded calls for method with typed params and response
  • env.lastBotMessage(opts?) β€” returns the bot's most recent text, media, album item, poll, or invoice from the current API-call journal, or undefined. Filters (all optional, combined with AND): { chat }, { withReplyMarkup: true }, and { where: (call) => bool } (the legacy where predicate applies to sendMessage calls only). Successful edits update the same object; deleted messages are excluded. Clearing the journal clears this lookup's send observations, but botMessage() and captured references remain available.
  • env.botMessage(chatId, messageId) β€” look up a specific bubble by id, or undefined if the bot never sent that message
  • env.users / env.chats β€” all created users and chats
  • env.updates β€” snapshots of delivered updates, with environment-local update IDs
  • env.message(chatId, messageId) β€” look up any live user or bot message
  • env.dispose() β€” restore the bot's original API; use a fresh bot per environment

Model a conversation

All high-level sends share one message-ID sequence per chat and one chat.messages history, including replies from the bot. Actors and chats cannot be silently moved between environments. API calls and delivered updates are snapshots; editing a message does not rewrite earlier updates or reply snapshots.

import { InlineKeyboard } from "gramio";

bot.command("start", (ctx) => ctx.sendPhoto("file_id", {
    caption: "Choose an action",
    reply_markup: new InlineKeyboard().text("Next", "next"),
}));
bot.callbackQuery("next", async (ctx) => {
    await ctx.answer();
    await ctx.editCaption("Ready", {
        reply_markup: new InlineKeyboard().text("Finish", "finish"),
    });
});

const env = new TelegramTestEnvironment(bot);
const alice = env.createUser({ first_name: "Alice" });
const group = env.createChat({ type: "supergroup", title: "Discussion" });
await alice.in(group).sendMessage("/start"); // command entity is inferred
const menu = env.lastBotMessage({ chat: group });
if (!menu) throw new Error("Bot did not send a menu");
await alice.on(menu).clickByText("Next");
expect(menu.payload.caption).toBe("Ready");
await alice.on(menu).reply("Thanks!"); // stays in the group

user.on(message) also offers edit(text), editCaption(text), forward(chat?), pin(), press(replyButtonText), and vote(optionIndices). Text/contact reply-keyboard buttons are supported by press; buttons requiring extra data give an explicit hint to use a corresponding send action. Voting supports open non-anonymous polls, including changing and retracting votes with [].

For uncommon message types, use await user.send(new MessageObject().chat(group).venue({ title: "Office" })). The actor supplies a fresh ID, date and sender, registers the message, and delivers the update. Builders are fixtures, not a complete Telegram schema validator. env.emitUpdate() remains a low-level escape hatch: it snapshots and delivers exactly the supplied update, without importing it into chat history or inferring missing fields.

Topics and the first channel comment

const forum = env.createChat({ type: "supergroup", title: "Forum", is_forum: true });
await alice.in(forum).inTopic(41).sendMessage("Hello topic");
await alice.in(forum).inTopic(41).sendPhoto({ caption: "In the same topic" });

const channel = env.createChat({ type: "channel", title: "News" });
const post = await channel.post("Announcement");
const root = await env.createDiscussion(post, group);
const first = await alice.on(root).reply("First comment");
await alice.on(first).reply("Nested comment");

createDiscussion(post, supergroup) explicitly models the automatic channel forward and sets the discussion root ID. Ordinary replies do not invent a thread ID. sendReply, sendMessage(..., { reply_to }), media reply options, and .replyTo() inherit an existing thread. Explicit topic scopes reject conflicting chat/thread targets. channel.editPost(post, text) delivers edited_channel_post; users may only edit their own messages.

Inline results

const query = await alice.in(group).sendInlineQuery("cats");
// The bot must have successfully answered this query with result "cat-1".
const article = await alice.in(group).sendInlineResult("cat-1", query);
await alice.on(article).clickByText("Open");

sendInlineResult supports text articles, cached/URL photo, video, audio, document, voice, GIF/MPEG4 GIF, sticker, location, venue and contact results, as well as text/location/venue/contact/invoice input_message_content. It emits only chosen_inline_result, not an invented incoming message. Successful answers survive clearApiCalls(). Game content cannot be inferred from game_short_name; use explicit fixtures and low-level update injection for that case.

Results with inline keyboards get a unique bubble.inlineMessageId. env.inlineMessage(id) returns that same live bubble. Edits by inline_message_id return true while updating its content/buttons; subsequent clickByText emits an inline callback with inline_message_id and no message. This supports loading placeholders replaced by real audio/photo after selection. Unknown IDs and direct uploads in inline edits reject; upload a file through a normal send first and reuse its file_id. Results without keyboards do not invent an inline ID. chooseInlineResult() remains available for standalone update injection.

Files and downloads

Normal File/Blob uploads automatically preserve metadata and register their bytes. Cached reuse keeps file_id/file_unique_id stable, including through albums and media edits. URL media receives a local reusable ID; URLs are never fetched. Promised uploads are resolved at runtime before recording (current upstream API declarations still require callers to await them).

const sent = await bot.api.sendAudio({
    chat_id: alice.payload.id,
    audio: new File(["test audio bytes"], "track.mp3", { type: "audio/mpeg" }),
    title: "Track", performer: "Artist", duration: 123,
});
const audio = sent.audio;
if (!audio) throw new Error("Expected audio");
expect(await bot.downloadFile(audio.file_id).text()).toBe("test audio bytes");

// Incoming fixtures only need explicit bytes when the bot actually downloads them.
const file = env.registerFile(new File(["fixture contents"], "fixture.txt"));
await alice.send(new MessageObject().document(file));

env.file(fileId) reads file metadata; built-in getFile, bot.downloadFile() and context.download() use the same registry. Fixtures with only a file_id have metadata but no invented body: downloading them throws a helpful error. The environment installs an instance-local file source and restores it on dispose(); no global fetch patch is used. Direct application fetch calls (including hand-built Telegram file URLs) remain outside this adapter and need an application-level test seam/mock.

Guest and Mini App prepared messages

await alice.in(group).sendGuestMessage(new MessageObject().audio());
// The bot can answerGuestQuery(), then edit the returned inline_message_id.

const prepared = await bot.api.savePreparedInlineMessage({
    user_id: alice.payload.id,
    allow_group_chats: true,
    result: { type: "article", id: "share", title: "Share", input_message_content: { message_text: "Shared" } },
});
await alice.in(group).sendPreparedInlineMessage(prepared.id);

Guest actions emit guest_message with a unique guest_query_id; env.guestMessage(id) reads the source. Built-in answerGuestQuery creates a live inline response, rejecting unknown/already answered queries. savePreparedInlineMessage stores a snapshot with a simulated five-minute expiration; sharing checks ownership, expiration and allowed chat type. Sharing does not fabricate an incoming message or chosen-result update. Guest/prepared helpers also accept .inTopic(id) scopes. Their unscoped forms accept (inputOrId, chat?, messageOptions?).

Errors and background retries

By default, a handler failure rejects the awaited user action even if the bot has an onError handler. env.errors records { updateId, error } entries without mixing concurrent updates. This deliberately prevents tests passing merely because an API call was recorded before the handler crashed. To test error recovery/telemetry without rejecting the action, explicitly use new TelegramTestEnvironment(bot, { throwOnError: false }) and assert the error journal.

For timer-driven retries, wait for a concrete outcome:

await env.waitFor(() => env.lastBotMessage()?.payload.text === "Ready", { timeout: 2000 });

waitFor accepts sync/async predicates, polls every 5ms by default, propagates predicate failures and times out (default 1000ms), including when an async predicate hangs. It neither patches timers nor claims that every background job has finished. A plugin that schedules a retry but rejects the original call still rejects it here; catch/assert that failure before waiting for the retry outcome.

Simulation boundaries and compatibility

  • Built-in mocks are the default: ordinary conversations need no onApi setup. Use onApi to override an answer, simulate failures, or supply external data that the environment does not have. offApi restores the built-in response.
  • Forwarding/copying (single and batch) uses tracked messages and preserves album grouping. Copies are available through botMessage/lastBotMessage even though Telegram returns only their IDs. stopPoll closes the tracked poll; live-location edits/stops update the same message reference.
  • Bot name/description/short-description setters and getters preserve language-specific state; menu buttons preserve chat-specific state. Profile-photo reads default to an empty list; use an override for users with photos.
  • Request interception preserves GramIO's formatting, preRequest, onApiCall, response/error hooks and suppress: true. It does not patch global fetch or perform real HTTP requests. Async mocks are supported.
  • Default responses cover text/media/album/poll/invoice sends, message edits/deletes, basic chat/member/file reads, command registration, pin/unpin, and common acknowledgement methods. Unsupported methods throw a useful env.onApi("method", handler) hint instead of returning an incorrectly typed true. This is not a complete Telegram server: permissions, delivery, file transcoding, webhook registration, and every Bot API restriction are not simulated.
  • Failed edits/deletes leave state unchanged. Successful deletion removes the message from active history/lookups and marks existing references isDeleted; subsequent user actions reject deleted messages. No ordinary deletion update is invented.
  • ApiCall.response is typed as a successful method result or TelegramError; narrow with instanceof TelegramError before reading successful-result fields.
  • Replies/pins contain independent snapshots without nested reply_to_message, matching Telegram's wire format. Do not assert JavaScript reference identity between a reply and its parent.
  • Membership actions are idempotent. Cross-chat actor replies/pins, cross-environment entities, duplicate explicit identities and invalid topic IDs are rejected.
  • API replies use existing targets; missing same-chat targets are allowed with allow_sending_without_reply. Cross-chat API replies use external_reply with the source attribution/media and do not inherit the source thread.
  • Generated group/channel chat IDs are negative; generated private-chat IDs are positive. Message and update sequences belong to the environment. Do not depend on old module-global counter values.
  • clearApiCalls() no longer deletes state. Use a fresh environment to reset the conversation. Capture references or use botMessage() when continuing a conversation after clearing observations.

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
    message_thread_id?: number;        // explicit topic/discussion
}

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. If the original message has a message_thread_id, the reply preserves it, including when replying to another comment within a discussion.

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
    reply_to?: MessageObject;             // reply in the same chat/thread
    message_thread_id?: number;
}

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. Accepts both plain JSON and Builder instances (e.g. InlineKeyboard from @gramio/keyboards) β€” Builders are unwrapped via toJSON() automatically.

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"

Most commonly paired with env.lastBotMessage() β€” the bubble's reply_markup stays in sync with the bot's edits automatically, so no manual refresh is needed between a button click and the next:

bot.on("message", (ctx) =>
    ctx.send("Pick:", {
        reply_markup: new InlineKeyboard().text("Next", "next"),
    }),
);
bot.on("callback_query:next", (ctx) =>
    ctx.editText("Done!", {
        reply_markup: new InlineKeyboard().text("Restart", "restart"),
    }),
);

await user.sendCommand("start");
const bubble = env.lastBotMessage()!;
await user.on(bubble).clickByText("Next");    // triggers the edit
await user.on(bubble).clickByText("Restart"); // same bubble, updated markup

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(threadId?) is_topic_message = true, optionally set message_thread_id
.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) => {
    const chat = env.resolveChat(params.chat_id);
    return {
        message_id: env.nextMessageId(chat.payload.id),
        date: Math.floor(Date.now() / 1000),
        chat: chat.payload,
        from: env.botUser,
        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");
    }
    const chat = env.resolveChat(params.chat_id);
    return {
        message_id: env.nextMessageId(chat.payload.id),
        date: Math.floor(Date.now() / 1000),
        chat: chat.payload,
        from: env.botUser,
        text: params.text,
    };
});

Resetting

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

About

testing bots with GramIO

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages