---
title: "Тестирование бота: хендлеры без Telegram"
description: "Юнит-тесты телеграм-бота на Go: хендлер как чистая функция от Update, интерфейс-отправитель для мока BotAPI, table-driven тесты и мок Update без сети."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/23-testy-bota/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 18
---

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

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 23 из 25. ~18 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/23-testy-bota/*

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

Сценарий, знакомый каждому, кто содержал бота: правка на одну строку в хендлере команды, и вечером — проверка руками. Открыть чат, набрать команду, посмотреть ответ, повторить для трёх граничных случаев, забыть четвёртый — и через неделю пользователь присылает скриншот паники. Ручная проверка не масштабируется принципиально: у бота двадцать команд, у каждой пять-десять веток, и ни один человек не прогонит это перед каждым коммитом. Хорошая новость в том, что бот — необычно удобный объект для тестов: вход — структура 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 раньше, чем её увидит пользователь, и любые новые ветки апдейтов добавляются строками этой таблицы.

> **Живое место: тесты есть, бот падает.** История из практики: у бота были тесты на все команды — зелёные, стабильные, — а прод упал через неделю после релиза. Причина: в логику добавили ветку, которая при пустом ответе 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 в выводе.

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

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

- Вызывать реальный BotAPI и фильтровать ошибки в тестах
- ✅ Принимать интерфейс Sender с методом Send и подставлять фейк в тесте
- Отключать сеть флагом в тестовом окружении
- Перенести отправку в отдельный процесс без тестов

Зависимость прячется за интерфейс: tgbotapi.BotAPI удовлетворяет Sender из коробки, а тест подставляет FakeSender, который копит вызовы и возвращает управляемую ошибку. Отключение сети не проверяет логику, а ломает тесты по внешним причинам.

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

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

- Сообщение с пустым текстом
- Команда без аргументов
- ✅ CallbackQuery от inline-кнопки: поле Message равно nil
- Сообщение от самого бота

Message — указатель, и в апдейте от CallbackQuery он равен nil: нажатие инлайн-кнопки паникует хендлер, рассчитанный только на сообщения. Проверка на nil в логике и кейс с пустым Update в таблице ловят это до продакшена.

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

```go
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))
```

**Что выведет программа?**

- ✅ эхо: привет
- нет сообщения
- паника: nil pointer dereference
- эхо: (пустая строка)

Message заполнена настоящей структурой с текстом, проверка на nil проходит, и хендлер возвращает эхо с тем же текстом. Паника была бы при u.Message.Text без проверки на nil или при апдейте без Message — оба случая закрываются таблицей кейсов.

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

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

```go
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.___
}
```

Подсказка: Интерфейс повторяет метод библиотеки Send, фейк дописывает вызов через append, а управляемый сбой хранится в поле Err.

Правильные ответы в пропусках: `Send`, `append`, `Err`

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

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

Правильный порядок строк:

1. `tests := []struct{ name string; upd tgbotapi.Update; want string }{...}`
2. `for _, tt := range tests {`
3. `t.Run(tt.name, func(t *testing.T) {`
4. `got := handleAdd(tt.upd, store)`
5. `if got != tt.want {`
6. `t.Errorf("got %q, want %q", got, tt.want)`

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

- **Чистый хендлер** — Функция вида handleX(u Update) string: без сети и побочных эффектов, отправка — снаружи. Тестируется прямым вызовом и сравнением строк.
- **Sender** — Интерфейс с методом Send(c Chattable) (Message, error). BotAPI подходит как есть, тест подставляет FakeSender со срезом вызовов и полем Err.
- **Мок Update** — Ручная сборка структуры: Message и Chat — указатели, их заполняют настоящими значениями. Пустой Update — обязательный кейс против паники на nil.
- **Table-driven** — Срез кейсов с name, входом и want, цикл с t.Run. Каждая ошибка с продакшена превращается в новую строку таблицы и не возвращается.
- **Интеграционный слой** — Реальный токен тестового бота из BotFather: проверяет транспорт, таймауты и 429. Медленный — запускается перед деплоем, а не в каждой сборке.

Связи темы: [урок про тестирование в go-advanced](/courses/go-advanced/lessons/16-testirovanie/) — механика testing, t.Run и таблиц, на которой стоят тесты бота; [урок про webhook](/courses/telegram-bot/lessons/18-webhook-bot/) — там бота тестируют ещё и через httptest, как обычный HTTP-сервер; [урок про состояние диалога](/courses/telegram-bot/lessons/10-sostoyanie-dialoga/) — FSM-карта, которую стоит покрыть переходами. Тесты в жизни бота: [урок про деплой](/courses/telegram-bot/lessons/24-deploy-bota/) — прогон перед выкладкой на VPS, [итоговый проект](/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/) — трекер привычек, у которого команды и состояния тестируются именно из этого урока. Полный маршрут — [на странице курса](/courses/telegram-bot/).

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

- Логика хендлера — чистая функция 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-ом — тест снижает вероятность, а не исключает внешние сюрпризы.
