Правка цветной рамки в View — и внезапно перестала работать клавиша Tab: классическая регрессия интерфейса, которую находят только тыканьем по клавишам после каждого коммита. Ручная проверка TUI медленная и всегда неполная: сегодня проверили Tab, завтра забыли про двойной Enter, послезавтра Shift+Tab сломался на третьем поле, и узнали об этом от пользователя. Модель Bubble Tea устроена так, что почти всё приложение — чистые функции над данными, и это редкий подарок: тесты tui go пишутся обычным пакетом testing, без запуска терминала и без эмуляции ввода. Второй уровень — пакет teatest из репозитория Bubble Tea: он прогоняет настоящую программу в фоновом рантайме с фиксированным размером экрана и позволяет дождаться нужной строки в выводе. Здесь разберём оба уровня по порядку: сначала прямые проверки Update и View, затем живой рантайм teatest, фиктивное время для часов и таймеров и моки вместо сетевых команд.
Чистые тесты Update и View#
Схема Model-Update-View делает приложение тестируемым по построению: Update — переход из состояния в состояние по сообщению, и его можно вызвать руками, собрав сообщение как обычное значение. tea.KeyMsg{Type: tea.KeyTab} — не эмуляция клавиатуры, а тот же тип, которым фреймворк сообщает о нажатии; нет никакой разницы, откуда он пришёл. Тест собирает модель конструктором, зовёт Update, приводит результат к своему типу и сравнивает поля. View — тоже просто строка: присвоили статус, вызвали View, проверили содержимое. Такой тест проходит за миллисекунды, не открывает терминал и не зависит от шрифтов и ширины окна — его можно гонять в CI без единой настройки. Главная дисциплина — проверять свои решения, а не чужой код: то, что Tab двигает курсор textinput, отвечает библиотека bubbles; ваше обязательство — что ветка вызвала Blur, сдвинула индекс и вернула команду. Граница проходит там же, где и граница ответственности в восьмом уроке про подмодели.
Табличный тест навигации
GOfunc TestFocusNavigation(t *testing.T) {
tests := []struct {
name string
key tea.KeyMsg
want int
}{
{"Tab сдвигает вперёд",
tea.KeyMsg{Type: tea.KeyTab}, 1},
{"Shift+Tab с первого уходит на последнее",
tea.KeyMsg{Type: tea.KeyShiftTab}, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newFormModel()
got, _ := m.Update(tt.key)
if got.(formModel).focus != tt.want {
t.Errorf("focus = %d, хотим %d",
got.(formModel).focus, tt.want)
}
})
}
}Классическая table-driven форма из урока про тестирование Go: кейсы в срезе, t.Run даёт имя в отчёте. Update возвращает tea.Model, поэтому приведение got.(formModel) обязательно — компилятор не даст сравнить интерфейс с числом.
Что проверять, а что нет#
Переходы состояний — самое ценное: флаги, индексы, статусы, границы цикла фокуса. Именно они ломаются тихо и именно их дёшево проверить. View проверяется иначе: не полным равенством строки, а поиском подстрок. Причина практическая: рамки, отступы и цвета из Lip Gloss меняются от правки к правке, и тест, сравнивающий весь экран целиком, краснеет при каждом косметическом коммите — через неделю его удаляют, и это худший финал для набора тестов. Contains по ключевым словам — «логин не может быть пустым», номер выбранной задачи, отформатированное время — стабилен и всё ещё ловит исчезнувший с экрана статус. Отдельно про чужой код: не тестируйте textinput и list — у Charm свои тесты; ваш тест ловит то, что вы забыли переслать сообщение или потеряли команду. И держите тесты на неудобные места: двойной Enter, Shift+Tab с первого поля, пустая валидация — баги живут там, где рука не доходит при ручной проверке. Прогон с -race сверху добавит ловлю гонок бесплатно: команды живут в горутинах фреймворка, и модель, тронутая из чужой горутины, краснеет в тесте раньше, чем в проде.
Проверка: хрупкий тест
Тест сравнивает View с точной строкой, включая рамку и отступы. Почему это плохая идея?
Проверка статуса во View
GOfunc TestErrorStatus(t *testing.T) {
m := newFormModel()
m.status = "логин не может быть пустым"
view := m.View()
if !strings.Contains(view, "логин не может быть пустым") {
t.Error("статус не попал на экран")
}
}Статус присвоен руками — Update не нужен, когда проверяется только отрисовка. View у value-модели вызывается напрямую: это метод над данными, а не над терминалом. strings.Contains отсекает рамки и отступы от сути.
teatest: модель в живом рантайме#
Чистые тесты не отвечают на вопросы про программу целиком: дошёл ли Batch из двух команд до рантайма, отработает ли цепочка тиков, не зациклится ли Update при определённом порядке сообщений. Для этого у Bubble Tea есть собственный пакет teatest — он импортируется как github.com/charmbracelet/bubbletea/teatest. teatest.NewTestModel запускает настоящую tea.Program в фоне, с фиксированным размером терминала через WithInitialTermSize, и возвращает обёртку TestModel. Ей отправляют сообщения через tm.Send — и клавиши, и свои типы; набор текста передаётся рунами: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("анна")} — то же сообщение, что приходит от настоящей клавиатуры, включая русские буквы. Читают вывод через tm.Output(); ждут появления подстроки функцией teatest.WaitFor с таймаутом teatest.WithDuration. Завершение — tm.Quit и tm.WaitFinished; для снимков всего кадра есть tm.FinalOutput. Разница с чистым тестом принципиальная: здесь работает настоящий цикл событий, команды возвращаются по-настоящему, порядок сообщений такой, каким он будет у пользователя. Цена — миллисекунды реального времени и осторожность с таймаутами: медленная машина не должна ронять тест.
Сквозной тест ветки Enter
GOfunc TestSubmitFlow(t *testing.T) {
tm := teatest.NewTestModel(newFormModel(),
teatest.WithInitialTermSize(60, 12))
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
teatest.WaitFor(t, tm.Output(),
func(bts []byte) bool {
return bytes.Contains(bts,
[]byte("логин не может быть пустым"))
},
teatest.WithDuration(2*time.Second),
)
tm.Quit()
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
}Нажатие Enter на пустой форме обязано привести к статусу валидации: WaitFor читает вывод, пока подстрока не появится или не истечёт таймаут. Импорты — bytes, testing, time, tea и teatest. Программа живёт в фоне, поэтому тест обязан её завершить Quit и дождаться финала.
WithDuration в двести миллисекунд зелёный на вашем ноутбуке и красный в CI на загруженном рантаймере. Правило: таймаут WaitFor щедрый — секунды, а не миллисекунды; сам ассерт быстрый, потому что условие проверяется многократно с интервалом. Нестабильный тест хуже отсутствующего: ему не верят и его скипают.
Снимок разошёлся с golden
Прочитайте код и выберите, что он выведет:
// Тест снимка: FinalOutput сравнивается с golden-файлом
// из прошлого прогона. Сегодня вы осознанно добавили
// строку в футер, файл не обновляли.
if err := golden.Equal(t, final); err != nil {
t.Fatal(err)
}
// Что произойдёт при go test?Фиктивное время#
Часы из предыдущего урока тестируются без единой секунды ожидания, и причина — чистота Update: tickMsg со временем это обычное сообщение, ничем не лучше клавиши. Тест собирает момент руками — time.Date(2025, 3, 1, 9, 41, 0, 0, time.UTC), отправляет в Update и проверяет, что View показывает 09:41:00. Никаких спящих тестов: то, что заняло бы секунду реального времени, здесь ноль миллисекунд и полный детерминизм. Дедлайны проверяются тем же ходом: отправьте timeoutMsg{gen: m.gen} руками и убедитесь, что экран ушёл в ошибку; отправьте timeoutMsg{gen: m.gen - 1} — и убедитесь, что данные устояли, метка принадлежности сработала. Деградацию частоты проверяет чистая функция interval(): состояние — интервал, табличный тест на три режима, терминал не нужен. Вот почему время в сообщении из урока двадцать один — не украшение: без него фиктивное время было бы невозможно, а тесты часов — только спящими.
Тест часов без ожидания
GOfunc TestClockRenders(t *testing.T) {
m := newDashModel()
got, _ := m.Update(tickMsg(
time.Date(2025, 3, 1, 9, 41, 0, 0, time.UTC)))
view := got.(dashModel).View()
if !strings.Contains(view, "09:41:00") {
t.Errorf("часы показали %q", view)
}
}Сообщение тика собрано руками — настоящий tea.Tick не участвует, ждать нечего. Форматирование 15:04:05 отдаёт девять часов сорок одну минуту ровно. Такой тест мгновенен и повторяем в любой временной зоне.
Соберите ожидание вывода
Восстановите вызов: функция ожидания teatest, источник вывода и поиск подстроки.
Моки команд и подмодели#
Остался последний барьер — команды, которые ходят в сеть. Чистый тест вызывает Update, ветка Enter честно возвращает submitFormCmd, и запрос уходит из теста наружу. Лечение — зависимость вместо прямого вызова: у модели поле submit func(login, pass string) tea.Cmd, продакшен присваивает ему настоящую фабрику при старте в main, тест — подмену, которая возвращает замыкание с готовым сообщением submitDoneMsg. Зависимость и проверяется: фейковая функция ставит флаг called, тест утверждает, что Enter её дёрнул, и при желании — что аргументы дошли из Value полей: сохраните login и pass в замыкании фейка и сравните с ожидаемыми. Тот же ход работает для загрузки из девятнадцатого урока: фабрику fetchItemsCmd несут полем и подменяют фиктивным ответом. И про масштаб: подмодели из восьмого урока тестируются отдельно от корня — экран списка получает свои сообщения без роутинга, корень проверяет только пересылку. Если Update невозможно протестировать без сети и терминала, это диагноз: IO попал не в команду, а в Update.
Подмена команды отправки
GOtype formModel struct {
fields []textinput.Model
focus int
submitting bool
submit func(login, pass string) tea.Cmd
}
func TestSubmitCallsCommand(t *testing.T) {
m := newFormModel()
called := false
m.submit = func(login, pass string) tea.Cmd {
called = true
return func() tea.Msg { return submitDoneMsg{} }
}
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if !called || !got.(formModel).submitting {
t.Error("Enter не отправил форму")
}
}Поле-функция — точка подмены: прод ставит реальный HTTP, тест ставит фейк. Ветка Enter зовёт m.submit(...) не зная, кто там. Утверждение ловит оба обязательства: команда вызвана и флаг submitting выставлен.
Проверка: зачем поле-функция
Почему команду отправки делают полем submit func(...) tea.Cmd, а не вызывают submitFormCmd прямо в ветке Enter?
Соберите тело теста отправки
Расставьте строки тела теста: модель, подмена команды, нажатие Enter, проверка флага, сообщение об ошибке, закрытие условия.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Тесты — зеркало архитектуры: где их не получается написать, там слоя намешаны. Следующий урок про структуру проекта разложит код по пакетам так, чтобы домен тестировался без tea, экраны — без корня, а корень — без сети. Моки команд отсюда переезжают туда без правок, как и подмена загрузки из урока про HTTP. Если писали десктопные приложения на Fyne, сравните подходы: там тесты виджетов требуют test.NewApp и рантайма интерфейса — тестирование fyne — а здесь терминал и вовсе не нужен, и это честное преимущество Elm-архитектуры. Обзор курса — на странице Bubble Tea. Прогон — локальный go test ./..., ни терминала, ни песочницы не требуется. Практика: закройте форму табличными тестами на все ветки — Tab, Shift+Tab с краёв, двойной Enter, пустой логин — и убедитесь, что ни один не спит.
Карточки: словарь тестирования
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: counter int = 0 · passed int = 0 · failed int = 0
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- Update и View — чистые функции над данными: тестируются пакетом testing без терминала и эмуляции ввода.
- Table-driven тесты закрывают переходы: клавиши собираются как tea.KeyMsg, границы цикла фокуса — обязательный кейс.
- View проверяется Contains по смысловым подстрокам — точное равенство с рамкой краснеет от каждой косметики.
- teatest поднимает настоящую программу в фоне: NewTestModel, Send, WaitFor по Output, Quit и WaitFinished.
- Время — тоже сообщение: tickMsg и timeoutMsg с фиктивными значениями делают тесты часов мгновенными.
- Сетевые команды выносятся в поля-функции: прод ставит HTTP, тест — мок; нетестируемый Update означает IO в Update.
Домашнее задание
Закройте форму из урока двадцать тестами: табличный тест навигации на Tab и Shift+Tab с обоих краёв, проверка двойного Enter на один вызов команды, тест валидации пустого логина и тест часов с фиктивным временем. Затем один сквозной тест через teatest с WaitFor на статус. Прогоните go test -race локально и добейтесь зелёного прогона без слипов.
Личные заметкиПусто
Вопросы и ответы по уроку
teatest падает по таймауту, хотя всё работает руками. Куда смотреть?
Первым делом таймаут: WaitFor с коротким WithDuration проигрывает гонку на медленной машине — поднимите до пары секунд. Второе — размер экрана: WithoutInitialTermSize слишком мал, и строка ушла за край или обрезалась. Третье — команда потеряна: если Update не вернул команду, сообщение никогда не приедет, и WaitFor честно ждёт вечно.
Нужно ли тестировать View целиком через golden на каждый экран?
Дорого: каждое косметическое изменение требует перегенерации снимков, и на живом интерфейсе их набирается десятки. Я оставляю golden на самые ответственные экраны — итоговую панель и формы, где раскладка — часть контракта, — а остальное покрываю Contains-проверками статуса и цифр. Баланс смещается в сторону снимков, когда раскладку начинают ломать регрессиями.
Как проверить порядок сообщений — например, что refresh пришёл после tick?
Чистыми тестами порядок не проверить — там один вызов. Это работа teatest: отправьте последовательность через Send и ловите промежуточные состояния через WaitFor по очереди. Если порядок критичен, заведите в модели журнал последних событий — срез коротких строк, который View рисует в отладочном режиме и который тест читает напрямую.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler