Сценарий, знакомый каждому, кто содержал бота: правка на одну строку в хендлере команды, и вечером — проверка руками. Открыть чат, набрать команду, посмотреть ответ, повторить для трёх граничных случаев, забыть четвёртый — и через неделю пользователь присылает скриншот паники. Ручная проверка не масштабируется принципиально: у бота двадцать команд, у каждой пять-десять веток, и ни один человек не прогонит это перед каждым коммитом. Хорошая новость в том, что бот — необычно удобный объект для тестов: вход — структура 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 и его фейк
GOtype 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 тест хендлера
GOfunc 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 раньше, чем её увидит пользователь, и любые новые ветки апдейтов добавляются строками этой таблицы.
История из практики: у бота были тесты на все команды — зелёные, стабильные, — а прод упал через неделю после релиза. Причина: в логику добавили ветку, которая при пустом ответе Telegram повторяла отправку бесконечно, и тесты этого не видели, потому что фейковый отправитель возвращал ошибку один раз, а настоящий Telegram возвращал её снова и снова. Мораль двойная. Первая: фейк обязан моделировать то, что ломается по-настоящему, — упорные ошибки, таймауты, 429 из урока про лимиты; фейк, который всегда успешен, проверяет только happy path. Вторая: транспорт — цикл апдейтов, обработку сигналов, восстановление после паники в горутине — тестируют отдельным слоем, интеграционным тестом с реальным токеном тестового бота из BotFather: логинится, шлёт пару команд, проверяет ответы. Такой тест медленный и выполняется перед деплоем из урока про деплой, а не в каждой сборке — два слоя друг друга не заменяют.
Что тестировать в боте, а что нет#
Распределение усилий подскажет цена ошибки. Тестировать безусловно стоит: разбор команд и аргументов, валидацию пользовательского ввода — в боте это первое место для инъекций и странностей, состояния диалога из урока про FSM — переходы по шагам и сброс при нехватке данных, расчёт срабатывания напоминаний, идемпотентность платежных веток. Тестировать дорого и почти бессмысленно: саму библиотеку tgbotapi — её проверяют авторы, сеть и Telegram — им не нужен ваш тест, чтобы работать так, как работает. Между этими полюсами живёт форматирование сообщений: HTML-разметка из урока про форматирование ломается на пользовательском вводе, и тест на экранирование ввода с угловыми скобками окупается одним инцидентом. Практический ориентир: всё, что можно сломать строкой от пользователя, тестируется таблицей кейсов; всё, что зависит от ответов Telegram, тестируется фейковым отправителем с управляемыми ошибками; всё остальное — на честном слове и код-ревью.
Слои тестов бота#
| Слой | Инструмент | Что ловит |
|---|---|---|
| Чистая логика | функция Update на входе, таблица кейсов | Неверные ответы команд, паники на nil |
| Отправка | Sender-интерфейс и фейк | Кому и что уходит, поведение при сбоях API |
| Состояния | карта FSM в тесте | Застрявшие диалоги, потерянные шаги |
| Интеграция | реальный токен тестового бота | Транспорт, таймауты, авторизацию и 429 |
Золотые фикстуры и скорость прогона#
Сколько тестов хватает — вопрос, на который у бота есть честный ответ по времени прогона: весь юнит-набор обязан выполняться за доли секунды, и как только прогон переваливает за секунды, тесты перестают запускаться при каждой правке, а дальше их никто не обновляет. Полезный приём из практики — золотые фикстуры: реальные апдейты из логов, странные и сложные, сохраняются в файлы тестовых данных и прогоняются через хендлеры. Пользователь прислал сообщение из тысячи эмодзи, перешлённое сообщение без автора, колбэк от кнопки из старой версии меню — каждый такой случай становится файлом и строкой таблицы, и богатство входа перестаёт зависеть от фантазии автора тестов. Отдельно ценны фикстуры обновлений из платёжных и вебхук-веток: их невозможно руками сочинить правильно с первого раза, а сохранить настоящий апдейт — минута работы. Обратное правило тоже работает: тест, который не поймал бы ни одного реального бага за полгода, удаляется без жалости — набор тестов бота должен быть живым, а не декоративным.
Пакет testing и go test требуют локального Go: песочница самоучителя поддерживает только fmt, strings, strconv, math, sort, errors и time, поэтому запуск тестов — команда go test ./... в терминале. Зато строить мок Update можно тренировать прямо в песочнице: соберите структуру Update в главном коде, передайте хендлеру и напечатайте ответ — логика теста и логика проверки совпадают один в один. Флаг go test -run с именем кейса запускает одну строку таблицы, а -v показывает имена t.Run в выводе.
Проверка: зависимость отправки
Логика должна отправлять сообщения сама. Как сделать её тестируемой?
Проверка: пустой апдейт
Хендлер разыменовывает 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))Соберите фейковый отправитель
Впишите интерфейс, приём вызова и возвращаемую ошибку.
Соберите table-driven тест
Расставьте строки теста по порядку: от таблицы до проверки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты бота
Связи темы: урок про тестирование в 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?
Можно и полезно — тестовым ботом из BotFather и отдельным тестовым чатом, но это интеграционный слой: медленный, зависимый от сети и лимитов. Юнит-слой на чистых функциях и фейке покрывает 90 процентов логики за миллисекунды, и интеграция нужна поверх него, а не вместо.
Как тестировать состояние диалога?
Хранилище состояний — обычная карта с методами, передаваемая явно: тест выставляет состояние, вызывает логику с апдейтом и проверяет и ответ, и новый шаг. Полезны кейсы перехода назад: пользователь прислал не то в середине анкеты — диалог обязан не сломаться и подсказать.
Что делать с кодом, который вызвал панику на проде?
Воспроизвести его тестом до фикса: собрать апдейт, на котором паникует хендлер, добавить строку в таблицу и убедиться, что тест красный. Затем починить хендлер и наблюдать зелёный прогон. Панику в горутине при этом всё равно стоит перехватывать recover-ом — тест снижает вероятность, а не исключает внешние сюрпризы.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99