Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~18 мин +20 XP

Тестирование бота: хендлеры без Telegram

Тесты телеграм-бота на Go не требуют Telegram: логика хендлера — это чистая функция, которая получает Update и возвращает ответ, а отправка прячется за интерфейс, который в тесте заменяет фейк. Разберём разделение кода на логику и транспорт, мок Update руками, table-driven тесты и интерфейс-отправитель — набор, после которого правки бота проверяются за секунды, а не пересылкой сообщений в чат.

Содержание урока · 9 разделов

Сценарий, знакомый каждому, кто содержал бота: правка на одну строку в хендлере команды, и вечером — проверка руками. Открыть чат, набрать команду, посмотреть ответ, повторить для трёх граничных случаев, забыть четвёртый — и через неделю пользователь присылает скриншот паники. Ручная проверка не масштабируется принципиально: у бота двадцать команд, у каждой пять-десять веток, и ни один человек не прогонит это перед каждым коммитом. Хорошая новость в том, что бот — необычно удобный объект для тестов: вход — структура Update, выход — сообщение или факт отправки, и между ними обычный Go, который можно вызвать из теста напрямую. Telegram при этом не нужен вовсе: апдейты собираются руками как обычные структуры, а сеть отрезается интерфейсом. Этот урок даёт минимальный, но полный набор: чистые функции для логики, фейковый отправитель вместо BotAPI, таблицы кейсов для команд — и разбор ошибки, из-за которой боты падают на проде при уже зелёных тестах.

Хендлер как чистая функция: Update на входе, ответ на выходе#

Ключевое решение выносится в архитектуру и выглядит так: логика команды не отправляет ничего сама — она принимает апдейт и решает, что ответить. Сигнатура вида handleStart(u tgbotapi.Update) string — это чистая функция: одинаковый вход даёт одинаковый выход, никакого мира вокруг. Весь богатый код курса — разбор Command и CommandArgs, состояния из урока про диалог, валидация ввода — укладывается в функции с такой формой, а отправка остаётся в одном месте: цикл апдейтов вызывает логику и делает bot.Send с результатом. Цена решения — одна лишняя прослойка, выгода — тестовая поверхность без моков: тест вызывает handleStart с собранной структурой и сравнивает строку с ожидаемой. Функции, которым нужен побочный эффект — сохранить напоминание, записать заказ, — принимают зависимости аргументами: хранилище из урока про хранение данных передаётся явно, и в тесте подставляется карта вместо Redis. Это тот же принцип, по которому в http-курсах логика отделяется от транспорта, — и он переносится на бота один в один.

Чистый хендлер команды

GO
// Логика без побочных эффектов: Update превращается в ответ
func handleAdd(u tgbotapi.Update, store *Tasks) string {
        if u.Message == nil || !u.Message.IsCommand() {
                return "Пишите команду /add и текст задачи"
        }
        args := u.Message.CommandArgs()
        if strings.TrimSpace(args) == "" {
                return "Пустая задача: напишите текст после /add"
        }
        store.Add(u.Message.Chat.ID, args)
        return "Задача добавлена: " + args
}

// Транспорт отдельно: цикл апдейтов вызывает логику и шлёт
for update := range updates {
        if update.Message != nil {
                msg := tgbotapi.NewMessage(update.Message.Chat.ID,
                        handleAdd(update, tasks))
                _, _ = bot.Send(msg)
        }
}

Проверки на nil и IsCommand живут в логике — там, где их видно тесту. Аргумент store — зависимость, переданная снаружи: в тесте это карта-хранилище, в проде Redis-бэкенд с теми же методами. Цикл апдейтов перестал содержать условия и стал тупым транспортом — его не нужно тестировать логикой, он просто пересылает.

Интерфейс-отправитель: фейк вместо BotAPI#

Не вся логика возвращает строку: напоминания рассылаются в разные чаты, ошибки уходят в отдельный канал, реакции ставятся на сообщения. Для таких случаев логика зависит от отправки, и зависимость оформляется интерфейсом: тип Sender с методом Send(msg tgbotapi.Chattable) (tgbotapi.Message, error). Указатель tgbotapi.BotAPI удовлетворяет ему из коробки — у библиотеки есть ровно такой метод, — а в тесте подставляется фейк, который ничего не отправляет, но запоминает, что и куда должны были отправить. Фейк — обычная структура со срезом записанных вызовов и управляемой ошибкой: она позволяет проверить и путь, когда Telegram недоступен. После этого логика напоминаний из урока про тикер тестируется без сети: тест вызывает функцию с фейковым отправителем и смотрит в срез — кому и что ушло. Граница приёма: интерфейс нужен ровно один, на границе с Telegram; оборачивать в интерфейсы хранилище и конфигурацию сразу — путь к тестам, которые проверяют собственные моки.

Sender и его фейк

GO
type Sender interface {
        Send(c tgbotapi.Chattable) (tgbotapi.Message, error)
}

// Фейк: копит вызовы вместо похода в сеть
type FakeSender struct {
        Sent []tgbotapi.Chattable
        Err  error
}

func (f *FakeSender) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
        f.Sent = append(f.Sent, c)
        return tgbotapi.Message{}, f.Err
}

// Логика зависит от интерфейса, а не от *tgbotapi.BotAPI
func broadcastReminders(bot Sender, due []Reminder) {
        for _, rem := range due {
                _, _ = bot.Send(tgbotapi.NewMessage(rem.ChatID,
                        "Напоминание: "+rem.Text))
        }
}

BotAPI уже умеет Send с такой сигнатурой, поэтому продовый код принимает Sender и получает живого бота, а тест — FakeSender. Тест читает f.Sent: сколько сообщений, в какие чаты, с каким текстом. Поле Err моделирует сбой Telegram и позволяет проверить поведение при недоступности API — без попыток устроить настоящий сбой.

Мок Update и table-driven тесты#

Update — это обычная структура с полями-указателями: Message, CallbackQuery, EditedMessage, PreCheckoutQuery. Мок собирается руками за три строки: tgbotapi.Update с Message, в которой заполнены Chat.ID, From, Text и Command. Нюанс, на котором стоят настоящие тесты бота: поле Message — указатель, и апдейт может прийти вообще без него — отредактированное сообщение лежит в EditedMessage, нажатие инлайн-кнопки в CallbackQuery, и хендлер, который смело разыменовывает update.Message, паникует на первом же колбэке. Table-driven тест закрывает это системно: таблица кейсов с названием, входным апдейтом и ожидаемым ответом, цикл с t.Run — формат тот же, что разбирается в уроке про тестирование курса go-advanced. Одна таблица живёт рядом с хендлером и растёт с каждой найденной в проде странностью: пустые аргументы, команда с лишними пробелами, сообщение от бота, апдейт без Message. Каждый багфикс начинается с новой строки таблицы — и возвращение старых ошибок становится невозможным по построению.

Table-driven тест хендлера

GO
func TestHandleAdd(t *testing.T) {
        store := NewTasks()
        tests := []struct {
                name string
                upd  tgbotapi.Update
                want string
        }{
                {"пустые аргументы", updWithText("/add"),
                        "Пустая задача: напишите текст после /add"},
                {"обычная задача", updWithText("/add позвонить маме"),
                        "Задача добавлена: позвонить маме"},
                {"не команда", updWithText("привет"),
                        "Пишите команду /add и текст задачи"},
                {"апдейт без сообщения", tgbotapi.Update{},
                        "Пишите команду /add и текст задачи"},
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        if got := handleAdd(tt.upd, store); got != tt.want {
                                t.Errorf("got %q, want %q", got, tt.want)
                        }
                })
        }
}

func updWithText(text string) tgbotapi.Update {
        return tgbotapi.Update{Message: &tgbotapi.Message{
                Chat: &tgbotapi.Chat{ID: 42},
                Text: text,
        }}
}

Хелпер updWithText строит апдейт без шума: указатели Message и Chat обязаны быть настоящими структурами, иначе хендлер увидит nil. Кейс с пустым tgbotapi.Update — тот самый сторож: он ловит панику на разыменовании nil раньше, чем её увидит пользователь, и любые новые ветки апдейтов добавляются строками этой таблицы.

Что тестировать в боте, а что нет#

Распределение усилий подскажет цена ошибки. Тестировать безусловно стоит: разбор команд и аргументов, валидацию пользовательского ввода — в боте это первое место для инъекций и странностей, состояния диалога из урока про FSM — переходы по шагам и сброс при нехватке данных, расчёт срабатывания напоминаний, идемпотентность платежных веток. Тестировать дорого и почти бессмысленно: саму библиотеку tgbotapi — её проверяют авторы, сеть и Telegram — им не нужен ваш тест, чтобы работать так, как работает. Между этими полюсами живёт форматирование сообщений: HTML-разметка из урока про форматирование ломается на пользовательском вводе, и тест на экранирование ввода с угловыми скобками окупается одним инцидентом. Практический ориентир: всё, что можно сломать строкой от пользователя, тестируется таблицей кейсов; всё, что зависит от ответов Telegram, тестируется фейковым отправителем с управляемыми ошибками; всё остальное — на честном слове и код-ревью.

Слои тестов бота#

СлойИнструментЧто ловит
Чистая логикафункция Update на входе, таблица кейсовНеверные ответы команд, паники на nil
ОтправкаSender-интерфейс и фейкКому и что уходит, поведение при сбоях API
Состояниякарта FSM в тестеЗастрявшие диалоги, потерянные шаги
Интеграцияреальный токен тестового ботаТранспорт, таймауты, авторизацию и 429

Золотые фикстуры и скорость прогона#

Сколько тестов хватает — вопрос, на который у бота есть честный ответ по времени прогона: весь юнит-набор обязан выполняться за доли секунды, и как только прогон переваливает за секунды, тесты перестают запускаться при каждой правке, а дальше их никто не обновляет. Полезный приём из практики — золотые фикстуры: реальные апдейты из логов, странные и сложные, сохраняются в файлы тестовых данных и прогоняются через хендлеры. Пользователь прислал сообщение из тысячи эмодзи, перешлённое сообщение без автора, колбэк от кнопки из старой версии меню — каждый такой случай становится файлом и строкой таблицы, и богатство входа перестаёт зависеть от фантазии автора тестов. Отдельно ценны фикстуры обновлений из платёжных и вебхук-веток: их невозможно руками сочинить правильно с первого раза, а сохранить настоящий апдейт — минута работы. Обратное правило тоже работает: тест, который не поймал бы ни одного реального бага за полгода, удаляется без жалости — набор тестов бота должен быть живым, а не декоративным.

Проверка: зависимость отправки

Логика должна отправлять сообщения сама. Как сделать её тестируемой?

Проверка: пустой апдейт

Хендлер разыменовывает update.Message без проверки. Какой апдейт уронит его?

Что вернёт чистый хендлер

Прочитайте код и выберите, что он выведет:

func handleEcho(u tgbotapi.Update) string {
        if u.Message == nil {
                return "нет сообщения"
        }
        return "эхо: " + u.Message.Text
}

// в тесте:
upd := tgbotapi.Update{Message: &tgbotapi.Message{
        Chat: &tgbotapi.Chat{ID: 7},
        Text: "привет",
}}
fmt.Println(handleEcho(upd))

Соберите фейковый отправитель

Впишите интерфейс, приём вызова и возвращаемую ошибку.

type Sender interface { (c tgbotapi.Chattable) (tgbotapi.Message, error) } func (f *FakeSender) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) { f.Sent = (f.Sent, c) return tgbotapi.Message{}, f. }

Соберите table-driven тест

Расставьте строки теста по порядку: от таблицы до проверки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: тесты бота

Термин

Чистый хендлер

нажмите, чтобы перевернуть
Определение

Функция вида handleX(u Update) string: без сети и побочных эффектов, отправка — снаружи. Тестируется прямым вызовом и сравнением строк.

1 / 5

Связи темы: урок про тестирование в go-advanced — механика testing, t.Run и таблиц, на которой стоят тесты бота; урок про webhook — там бота тестируют ещё и через httptest, как обычный HTTP-сервер; урок про состояние диалога — FSM-карта, которую стоит покрыть переходами. Тесты в жизни бота: урок про деплой — прогон перед выкладкой на VPS, итоговый проект — трекер привычек, у которого команды и состояния тестируются именно из этого урока. Полный маршрут — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Логика хендлера — чистая функция Update на входе, ответ на выходе: отправка остаётся в цикле апдейтов и не участвует в тесте.
  • Зависимости, включая отправку, передаются аргументами: интерфейс Sender закрывается BotAPI в проде и FakeSender в тесте.
  • Update — структура с полями-указателями: пустой апдейт и CallbackQuery без Message обязаны быть кейсами таблицы.
  • Table-driven тесты растут с каждым багфиксом: новая строка таблицы дешевле возвращённой ошибки.
  • Фейковый отправитель моделирует то, что ломается: упорные ошибки, таймауты, 429 — не только happy path.
  • Интеграционный тест с токеном тестового бота проверяет транспорт отдельно — перед деплоем, а не в каждой сборке.

Домашнее задание

Возьмите любой хендлер своего бота и разделите на чистую логику и транспорт: функция принимает Update и зависимости, возвращает ответ, цикл апдейтов только отправляет. Напишите Sender-интерфейс с FakeSender, соберите table-driven тест минимум с пятью кейсами — включая пустой Update, команду без аргументов и сообщение, которое не команда. Затем добавьте кейс, где FakeSender возвращает ошибку, и проверьте, что логика не теряет данные. Запустите go test ./... локально и добейтесь, чтобы прогон всего набора занимал доли секунды — тогда тесты будут запускаться при каждой правке.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Тестирование бота: хендлеры без Telegram)

Вопросы и ответы по уроку

Можно ли тестировать бота через реальный Telegram?

Можно и полезно — тестовым ботом из BotFather и отдельным тестовым чатом, но это интеграционный слой: медленный, зависимый от сети и лимитов. Юнит-слой на чистых функциях и фейке покрывает 90 процентов логики за миллисекунды, и интеграция нужна поверх него, а не вместо.

Как тестировать состояние диалога?

Хранилище состояний — обычная карта с методами, передаваемая явно: тест выставляет состояние, вызывает логику с апдейтом и проверяет и ответ, и новый шаг. Полезны кейсы перехода назад: пользователь прислал не то в середине анкеты — диалог обязан не сломаться и подсказать.

Что делать с кодом, который вызвал панику на проде?

Воспроизвести его тестом до фикса: собрать апдейт, на котором паникует хендлер, добавить строку в таблицу и убедиться, что тест красный. Затем починить хендлер и наблюдать зелёный прогон. Панику в горутине при этом всё равно стоит перехватывать recover-ом — тест снижает вероятность, а не исключает внешние сюрпризы.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99