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

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

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

Урок 22 из 25 ~18 мин +50 XP

Тестирование TUI-приложений

Тесты tui на Go пишутся без терминала: модель — это данные, Update — чистый переход, и проверки tui go поднимаются на двух уровнях — прямые вызовы Update и View через обычный testing и пакет teatest bubbletea, который запускает настоящую программу в фоне. Разберём табличные тесты переходов, фиктивное время вместо настоящих таймеров, подмену сетевых команд моками и снимки вывода через golden-файлы.

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

Правка цветной рамки в 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, сдвинула индекс и вернула команду. Граница проходит там же, где и граница ответственности в восьмом уроке про подмодели.

Табличный тест навигации

GO
func 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

GO
func 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

GO
func 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 и дождаться финала.

Снимок разошёлся с 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(): состояние — интервал, табличный тест на три режима, терминал не нужен. Вот почему время в сообщении из урока двадцать один — не украшение: без него фиктивное время было бы невозможно, а тесты часов — только спящими.

Тест часов без ожидания

GO
func 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, источник вывода и поиск подстроки.

teatest.(t, tm.(), func(bts []byte) bool { return bytes.(bts, []byte("Готово")) })

Моки команд и подмодели#

Остался последний барьер — команды, которые ходят в сеть. Чистый тест вызывает Update, ветка Enter честно возвращает submitFormCmd, и запрос уходит из теста наружу. Лечение — зависимость вместо прямого вызова: у модели поле submit func(login, pass string) tea.Cmd, продакшен присваивает ему настоящую фабрику при старте в main, тест — подмену, которая возвращает замыкание с готовым сообщением submitDoneMsg. Зависимость и проверяется: фейковая функция ставит флаг called, тест утверждает, что Enter её дёрнул, и при желании — что аргументы дошли из Value полей: сохраните login и pass в замыкании фейка и сравните с ожидаемыми. Тот же ход работает для загрузки из девятнадцатого урока: фабрику fetchItemsCmd несут полем и подменяют фиктивным ответом. И про масштаб: подмодели из восьмого урока тестируются отдельно от корня — экран списка получает свои сообщения без роутинга, корень проверяет только пересылку. Если Update невозможно протестировать без сети и терминала, это диагноз: IO попал не в команду, а в Update.

Подмена команды отправки

GO
type 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, пустой логин — и убедитесь, что ни один не спит.

Карточки: словарь тестирования

Термин

Чистый тест Update

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

Собрать tea.KeyMsg руками, вызвать m.Update, привести got.(formModel) и сравнить поля. Миллисекунды, без терминала, идеален для CI.

1 / 6

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

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

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
go test ./internal/tui — TUI под тестом
counter (модель под тестом): 0
✓ пройдено: 0
✗ упало: 0
t — зелёный тест, f — красный, r — сброс, q — выход

Клавиатура — обработано в 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 локально и добейтесь зелёного прогона без слипов.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Тестирование TUI-приложений)

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

teatest падает по таймауту, хотя всё работает руками. Куда смотреть?

Первым делом таймаут: WaitFor с коротким WithDuration проигрывает гонку на медленной машине — поднимите до пары секунд. Второе — размер экрана: WithoutInitialTermSize слишком мал, и строка ушла за край или обрезалась. Третье — команда потеряна: если Update не вернул команду, сообщение никогда не приедет, и WaitFor честно ждёт вечно.

Нужно ли тестировать View целиком через golden на каждый экран?

Дорого: каждое косметическое изменение требует перегенерации снимков, и на живом интерфейсе их набирается десятки. Я оставляю golden на самые ответственные экраны — итоговую панель и формы, где раскладка — часть контракта, — а остальное покрываю Contains-проверками статуса и цифр. Баланс смещается в сторону снимков, когда раскладку начинают ломать регрессиями.

Как проверить порядок сообщений — например, что refresh пришёл после tick?

Чистыми тестами порядок не проверить — там один вызов. Это работа teatest: отправьте последовательность через Send и ловите промежуточные состояния через WaitFor по очереди. Если порядок критичен, заведите в модели журнал последних событий — срез коротких строк, который View рисует в отладочном режиме и который тест читает напрямую.

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

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

Сравните с альтернативами

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

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

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

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler