@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

terminal
pnpm add -D @yaebal/test

quick 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.

bot.test.ts
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:

text.ts
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.

media.ts
// 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_id

buttons, 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.

interactions.ts
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

payments.ts
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."

scopes.ts
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:

bubble.test.ts
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 edit

filters (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.

onApi.ts
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:

apiError.ts
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

strict.ts
const env = createTestEnv(bot, { strictApi: true });      // throw on an unstubbed method
const env2 = createTestEnv(bot, { strictDispatch: true }); // throw if no handler consumed the update

the virtual clock — skip real time

TTL expirations, retry backoffs, debounces — none of it should cost real wall-clock time in a test suite.

clock.test.ts
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 teardown

intervals 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:

standalone-clock.ts
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.

test-pack.ts
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.

updates.ts
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 directly

findButton(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

webhook.test.ts
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; // → 200

collectUpdates() 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.

collector.test.ts
import { collectUpdates, messageUpdate } from "@yaebal/test";

const { sink, updates } = collectUpdates();
await sink.handleUpdate(messageUpdate({ text: "hi" }));
updates.length; // → 1

withFetch(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.

fetch.test.ts
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.

migration.diff
- 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

exportsignaturedescription
createTestEnv(bot, options?) => TestEnvthe main entry point
TestEnvclassapi, apiCalls, hooks, users, chats, createUser, createChat, dispatch/inject, onApi/offApi, lastApiCall/callsTo/clearApiCalls, lastBotMessage/botMessage, useFakeTimers/advanceTime, onPostDispatch, answeredPreCheckoutQuery, shutdown
UserActorclasssend/media/click/react/join/payments — see actors above
ChatActorclassid, type, title?, username?, members, setMembership/membershipOf, post
apiError(code, description, parameters?) => ApiErrorSentinelsimulate a real Telegram error response
isApiErrorSentinel(value) => value is ApiErrorSentineltypeguard for a stored apiError(...)
TestApiErrorclass extends TelegramErrortest subclass carrying the same structured .parameters
installTestClock(startAt?) => TestClockstandalone virtual clock: .now(), .advance(ms), .restore()
mockApi(options?) => MockApithe fake Api underneath TestEnv — usable standalone
findButton(markup, match) => FoundButton | undefinedfind an inline keyboard button by text
toPlain(value) => Tunwrap a builder's toJSON(), or pass through
createUpdate / messageUpdate / …see fixture builders aboveraw update construction
detectUpdateType(update) => UpdateNameinfer the payload key; defaults to "message"
collectUpdates() => UpdateCollectora minimal UpdateSink that records what it receives
webhookRequest(update, options?) => Requestbuild a webhook POST request carrying an update
withFetch(handler, fn) => Promise<T>stub globalThis.fetch for fn, restoring it after