@yaebal/test
the most complete test framework for Telegram bots on the market — virtual users and chats that send your bot real updates, a fully intercepted api, a virtual clock, and zero dependency on any test runner
install
pnpm add -D @yaebal/testquick start
createTestEnv(bot) wraps any Composer/Bot, intercepts every
outgoing api call (no real HTTP, ever), and hands you actor factories. actors send the bot real
updates — messages, commands, media, reactions, button clicks, joins, payments — the way real
Telegram users would.
import { Composer } from "@yaebal/core";
import { createTestEnv } from "@yaebal/test";
import { expect, test } from "vitest"; // or node:test, or whatever you use
import { bot } from "./bot.js";
test("replies to /start", async () => {
const env = createTestEnv(bot);
const linia = env.createUser({ firstName: "Linia" });
await linia.sendCommand("start");
expect(env.lastApiCall("sendMessage")?.params?.text).toBe("Welcome!");
});
test("clicking a button fires the callback handler", async () => {
const env = createTestEnv(bot);
const linia = env.createUser();
await linia.sendCommand("start");
const bubble = env.lastBotMessage({ withReplyMarkup: true });
if (bubble) await linia.on(bubble).clickByText("Next »");
});actors — users drive the scenario
env.createUser(options?) returns a UserActor. text and commands accept a
plain string or a format()/fmt result — entities are extracted
automatically:
await linia.sendMessage("hello");
await linia.sendMessage(group, "hello group"); // ChatActor as the first arg
await linia.sendReply(originalMsg, "thanks!"); // reply_to_message + same chat, inferred
await linia.sendCommand("start"); // text: "/start", bot_command entity
await linia.sendCommand("start", "ref42"); // text: "/start ref42"
import { bold, format } from "@yaebal/core";
// format()/fmt results work too — entities are extracted automatically
await linia.sendMessage(format`Check out ${bold("this")}`);media
every media shortcut auto-generates file_id/file_unique_id and whatever
fields Telegram requires, and accepts an optional leading ChatActor to target a
specific chat.
// every method auto-generates file_id/file_unique_id and Telegram's required fields
await linia.sendPhoto({ caption: "Look!", spoiler: true });
await linia.sendVideo();
await linia.sendDocument();
await linia.sendVoice();
await linia.sendAudio();
await linia.sendAnimation();
await linia.sendVideoNote();
await linia.sendSticker({ emoji: "🔥" });
await linia.sendLocation({ latitude: 48.8566, longitude: 2.3522 });
await linia.sendContact({ phone_number: "+1234567890", first_name: "Bob" });
await linia.sendDice("🎯");
await linia.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 }] },
]); // one update per item, all sharing media_group_idbuttons, reactions, joins
reaction state is tracked per-message automatically — old_reaction is inferred from
that user's last react() on the message, tracked independently per user.
await linia.click("vote:up", msg); // raw callback_data
await linia.on(msg).click("vote:up"); // same, message pre-bound
await linia.on(msg).clickByText("Next »"); // scans msg's inline_keyboard for the label
await linia.react("👍", msg); // old_reaction inferred from linia's last react()
await linia.react("❤", msg); // old: ["👍"], new: ["❤"] — inferred, not passed in
await linia.react([], msg); // clear all of linia's reactions
await linia.join(group); // chat.members + a chat_member update + service message
await linia.leave(group);inline mode & payments
await linia.sendInlineQuery("cats", group); // chat_type derived from group.type
await linia.chooseInlineResult("result-1", "cats");
// the full flow: pre_checkout_query → verifies the bot answered { ok: true } → successful_payment.
// throws if the bot never answers, or answers with ok: false — just like real Telegram would
// never deliver successful_payment in that case.
await linia.sendSuccessfulPayment({ invoice_payload: "sub_monthly" });.in(chat) / .on(message) — scopes
.in(chat) pre-binds every send method to a chat; .on(message) pre-binds
click/react/edit/forward/pin to a message. chain them for "as this user, in this chat, on this
message."
const group = env.createChat({ type: "group", title: "devs" });
await linia.in(group).sendMessage("morning team");
await linia.in(group).sendCommand("help");
await linia.in(group).join();
await linia.in(group).on(msg).clickByText("yes");
await linia.on(msg).click("action:1");
await linia.on(msg).react("👍");
await linia.on(msg).editMessage("updated");chats
env.createChat({ type, title?, username? }) builds a ChatActor (group/supergroup/channel/private). chat.members tracks who's joined; chat.setMembership/chat.membershipOf track arbitrary status for getChatMember-style assertions; chat.post(text) emits an anonymous
channel post (update.channel_post, no from) and throws on non-channel
chats.
inspecting what the bot did
env.apiCalls records every { method, params, result | error, at }. env.lastApiCall(method?) and env.callsTo(method) filter it; env.clearApiCalls() resets between logical phases of a test.
env.lastBotMessage(query?) — the bot's own messages, live
a BotMessage mirror of the bot's most recent send*/forwardMessage/copyMessage — populated straight from
the outgoing params, no onApi override required — and kept in sync in place as editMessageText/editMessageCaption/editMessageReplyMarkup calls land, even for a reference captured before the edit:
bot.on("message", (ctx) =>
ctx.send("Pick:", { reply_markup: { inline_keyboard: [[{ text: "Next", callback_data: "next" }]] } }),
);
bot.callbackQuery("next", async (ctx) => {
const { chat, message_id } = ctx.callbackQuery.message;
await ctx.api.call("editMessageText", { chat_id: chat.id, message_id, text: "Done!" });
});
await linia.sendMessage("hi");
const bubble = env.lastBotMessage()!;
await linia.on(bubble).clickByText("Next");
bubble.text; // "Done!" — same object, mutated in place, even though we captured it before the editfilters (optional, combined with AND): { chat } scope to a chat; { withReplyMarkup: true } skip plain status messages and find the last
interactive bubble; { where: (call) => boolean } an arbitrary predicate
over the call that produced/last touched the message. env.botMessage(chatId, messageId) looks one up directly.
mocking the api
without any setup: an auto-incrementing message_id for send*/copyMessage/forwardMessage, true for answerCallbackQuery, a stub bot for getMe, {} otherwise.
env.onApi("getMe", { id: 7, is_bot: true, first_name: "MyBot", username: "my_bot" });
// { times: 1 } is one-shot, then falls back to the next queued/permanent reply —
// perfect for "first call fails, second succeeds":
env.onApi("sendMessage", apiError(429, "Too Many Requests", { retry_after: 1 }), { times: 1 });
env.onApi("sendMessage", { message_id: 1, date: 0, chat: { id: 1, type: "private" } });apiError(code, description, parameters?)
simulate a real Telegram failure — the bot sees exactly what it would see in production:
import { apiError } from "@yaebal/test";
import { TelegramError } from "@yaebal/core";
env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));
// the bot sees a real TelegramError (TestApiError extends it):
try {
await ctx.reply("hi");
} catch (error) {
error instanceof TelegramError; // true
error.code; // 403
error.parameters; // response_parameters when Telegram sends them
}env.offApi(method?) drops a method's override (or every override, with no argument).
strictApi / strictDispatch
const env = createTestEnv(bot, { strictApi: true }); // throw on an unstubbed method
const env2 = createTestEnv(bot, { strictDispatch: true }); // throw if no handler consumed the updatethe virtual clock — skip real time
TTL expirations, retry backoffs, debounces — none of it should cost real wall-clock time in a test suite.
const env = createTestEnv(bot);
env.useFakeTimers(); // arm it *before* the code under test schedules a timer you want to control
await linia.sendCommand("start"); // handler calls setTimeout(..., 60 * 60 * 1000) internally
await env.advanceTime(60 * 60 * 1000); // fires it instantly
env.shutdown(); // restores real timers — always call this in teardownintervals re-arm and may fire multiple times in one advance() call; timers scheduled
from inside a firing callback are picked up by the same call. for standalone use outside a TestEnv:
import { installTestClock } from "@yaebal/test";
const clock = installTestClock(1_700_000_000_000);
setInterval(() => {/* … */}, 1000);
await clock.advance(3500); // fires 3 times
clock.restore();satellite-plugin test packs
a TestPack is an explicit hook (never a global registry — yaebal doesn't do implicit
plugin wiring) a plugin package can ship so its own tests, or yours, get sensible fixtures for
free. @yaebal/again ships one: import { againTestPack } from "@yaebal/again/test-pack" wires autoRetry onto env.api automatically.
import type { TestPack } from "@yaebal/test";
export function myPluginTestPack(options?: MyPluginOptions): TestPack {
return {
name: "my-plugin",
setup(env) {
installMyPlugin(env.api, options); // wire whatever the plugin needs onto env.api/env
},
};
}
const env = createTestEnv(bot, { packs: [myPluginTestPack()] });fixture builders — the escape hatch
every actor method is sugar over a raw Update. reach for these directly for shapes
the actors don't cover, or full field-by-field control — messageUpdate, editedMessageUpdate, channelPostUpdate, editedChannelPostUpdate, callbackUpdate, inlineQueryUpdate, chosenInlineResultUpdate, shippingQueryUpdate, preCheckoutQueryUpdate, pollUpdate, pollAnswerUpdate, myChatMemberUpdate, chatMemberUpdate, and chatJoinRequestUpdate. buildUser builds a User with an
auto-allocated id.
import { createUpdate, detectUpdateType, pollUpdate } from "@yaebal/test";
const poll = pollUpdate({ question: "coffee or tea?", options: ["coffee", "tea"] });
// hand-build any update shape; update_id is filled in for you
const update = createUpdate({
edited_message: { message_id: 1, date: 0, chat: { id: 1, type: "private" } },
});
detectUpdateType(update); // → "edited_message"
await env.dispatch(update); // ship it through the bot directlyfindButton(markup, match) searches a reply_markup (plain JSON, or a
builder instance like InlineKeyboard — unwrapped via toJSON() automatically) for a button whose text matches a string or regex, returning it with its row/col. this is what clickByText uses internally.
webhooks & runners
import { webhookCallback } from "@yaebal/core";
import { webhookRequest, messageUpdate } from "@yaebal/test";
const handler = webhookCallback(bot, { secretToken: "s3cret" });
const res = await handler(
webhookRequest(messageUpdate({ text: "hi" }), { secretToken: "s3cret" }),
);
res.status; // → 200collectUpdates() gives you a minimal UpdateSink (the { handleUpdate } shape webhooks and runners expect) that just records what it
receives — handy when you don't need a full Bot.
import { collectUpdates, messageUpdate } from "@yaebal/test";
const { sink, updates } = collectUpdates();
await sink.handleUpdate(messageUpdate({ text: "hi" }));
updates.length; // → 1withFetch(handler, fn) stubs globalThis.fetch for the duration of fn, restoring the original afterwards even if fn throws — for code that
proxies uploads or downloads.
import { withFetch } from "@yaebal/test";
await withFetch(
async () =>
new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/jpeg" } }),
async () => {
const res = await panelHandler(request);
res.status; // → 200
},
);upgrading from 0.1.x
0.2 replaces the old flat api (mockApi() + createContext() + runMiddleware() wired together by hand) with the actor-driven TestEnv above. mockApi, the update factories, findButton, webhookRequest, collectUpdates, and withFetch are all still
here — only createContext, messageContext, callbackContext,
and runMiddleware are gone, folded into TestEnv.dispatch/actors. setResult is now onApi.
- const { api, calls } = mockApi();
- await runMiddleware(bot, createContext(messageUpdate({ text: "/start" }), api));
- assert.equal(calls[0]?.method, "sendMessage");
+ const env = createTestEnv(bot);
+ await env.createUser().sendCommand("start");
+ assert.equal(env.lastApiCall("sendMessage")?.method, "sendMessage");api
| export | signature | description |
|---|---|---|
createTestEnv | (bot, options?) => TestEnv | the main entry point |
TestEnv | class | api, apiCalls, hooks, users, chats, createUser, createChat, dispatch/inject, onApi/offApi, lastApiCall/callsTo/clearApiCalls, lastBotMessage/botMessage, useFakeTimers/advanceTime, onPostDispatch, answeredPreCheckoutQuery, shutdown |
UserActor | class | send/media/click/react/join/payments — see actors above |
ChatActor | class | id, type, title?, username?, members, setMembership/membershipOf, post |
apiError | (code, description, parameters?) => ApiErrorSentinel | simulate a real Telegram error response |
isApiErrorSentinel | (value) => value is ApiErrorSentinel | typeguard for a stored apiError(...) |
TestApiError | class extends TelegramError | test subclass carrying the same structured .parameters |
installTestClock | (startAt?) => TestClock | standalone virtual clock: .now(), .advance(ms), .restore() |
mockApi | (options?) => MockApi | the fake Api underneath TestEnv — usable standalone |
findButton | (markup, match) => FoundButton | undefined | find an inline keyboard button by text |
toPlain | (value) => T | unwrap a builder's toJSON(), or pass through |
createUpdate / messageUpdate / … | see fixture builders above | raw update construction |
detectUpdateType | (update) => UpdateName | infer the payload key; defaults to "message" |
collectUpdates | () => UpdateCollector | a minimal UpdateSink that records what it receives |
webhookRequest | (update, options?) => Request | build a webhook POST request carrying an update |
withFetch | (handler, fn) => Promise<T> | stub globalThis.fetch for fn, restoring it after |