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.
bun add -d @gramio/testimport { 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`);
});
});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 aUserObjectlinked to the environmentenv.createChat(payload?)β creates aChatObject(group, supergroup, channel, etc.)env.emitUpdate(update)β sends a rawTelegramUpdateorMessageObjectto the botenv.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) inparams.reply_markupandparams.results[].reply_markupare unwrapped to plain JSON before recording, so you never need toJSON.parse(JSON.stringify(...))to assert on them.env.clearApiCalls()β empties the API-call journal without deleting messages or breaking live referencesenv.lastApiCall(method)β returns the most recent recorded call formethod, orundefinedif noneenv.filterApiCalls(method)β returns all recorded calls formethodwith typed params and responseenv.lastBotMessage(opts?)β returns the bot's most recent text, media, album item, poll, or invoice from the current API-call journal, orundefined. Filters (all optional, combined with AND):{ chat },{ withReplyMarkup: true }, and{ where: (call) => bool }(the legacywherepredicate applies tosendMessagecalls only). Successful edits update the same object; deleted messages are excluded. Clearing the journal clears this lookup's send observations, butbotMessage()and captured references remain available.env.botMessage(chatId, messageId)β look up a specific bubble by id, orundefinedif the bot never sent that messageenv.users/env.chatsβ all created users and chatsenv.updatesβ snapshots of delivered updates, with environment-local update IDsenv.message(chatId, messageId)β look up any live user or bot messageenv.dispose()β restore the bot's original API; use a fresh bot per environment
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 groupuser.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.
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.
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.
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.
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?).
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.
- Built-in mocks are the default: ordinary conversations need no
onApisetup. UseonApito override an answer, simulate failures, or supply external data that the environment does not have.offApirestores the built-in response. - Forwarding/copying (single and batch) uses tracked messages and preserves album grouping. Copies are available through
botMessage/lastBotMessageeven though Telegram returns only their IDs.stopPollcloses 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 andsuppress: true. It does not patch globalfetchor 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 typedtrue. 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.responseis typed as a successful method result orTelegramError; narrow withinstanceof TelegramErrorbefore 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 useexternal_replywith 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 usebotMessage()when continuing a conversation after clearing observations.
Users drive the test scenario. Create them via env.createUser():
const user = env.createUser({ first_name: "Alice" });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
}const group = env.createChat({ type: "group", title: "Test Group" });
await user.sendMessage(group, "Hello group");
await user.sendMessage(group, format`${bold("Bold")} in group`);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")}!`);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");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;
}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);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");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");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 markupUpdates 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`);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 chatEmits 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);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 chatEmits a callback_query update.
const msg = await user.sendMessage("Pick an option");
await user.click("option:1", msg);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 = ["β€"]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" });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" });Wraps TelegramChat with in-memory state tracking:
chat.membersβSet<UserObject>of current memberschat.messagesβMessageObject[]history of all messages in the chat
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")}`);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 |
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, USuser.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.
Wraps TelegramPreCheckoutQuery with builder methods:
const query = new PreCheckoutQueryObject()
.from(user)
.currency("USD")
.totalAmount(500)
.invoicePayload("product_123")
.shippingOptionId("express")
.orderInfo({ name: "Alice" });Wraps TelegramShippingQuery with builder methods:
const query = new ShippingQueryObject()
.from(user)
.invoicePayload("physical_item")
.shippingAddress({ country_code: "DE", city: "Berlin" });Wraps TelegramCallbackQuery with builder methods:
const cbQuery = new CallbackQueryObject()
.from(user)
.data("action:1")
.message(msg);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);Wraps TelegramInlineQuery with builder methods:
const inlineQuery = new InlineQueryObject()
.from(user)
.query("search cats")
.offset("0");Wraps TelegramChosenInlineResult with builder methods:
const result = new ChosenInlineResultObject()
.from(user)
.resultId("result-1")
.query("search cats");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);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,
};
});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,
};
});env.offApi("sendMessage"); // reset specific method
env.offApi(); // reset all overrides