---
title: "Тестирование TUI-приложений"
description: "teatest bubbletea и чистые тесты Update/View: table-driven проверки переходов, фиктивное время, моки команд и тестируемые подмодели — тесты tui на Go."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/22-testirovanie-tui/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 18
---

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

*Курс «Bubble Tea: TUI-приложения в терминале», урок 22 из 25. ~18 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/22-testirovanie-tui/*

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

Правка цветной рамки в 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 не обязан возвращать строку
- ✅ Любая косметическая правка рамки краснит тест, и его в итоге удаляют
- Строку View нельзя получить в тесте
- teatest запрещает сравнение строк

Точное равенство превращает каждый коммит про отступы в правку теста — поддержка дорожает, и рано или поздно набор тестов выбрасывают целиком. Contains по смысловым подстрокам ловит регрессии содержания и переживает смену рамок.

### Проверка статуса во 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 и дождаться финала.

> **Короткий таймаут — flaky тест.** WithDuration в двести миллисекунд зелёный на вашем ноутбуке и красный в CI на загруженном рантаймере. Правило: таймаут WaitFor щедрый — секунды, а не миллисекунды; сам ассерт быстрый, потому что условие проверяется многократно с интервалом. Нестабильный тест хуже отсутствующего: ему не верят и его скипают.

### Снимок разошёлся с golden

```go
// Тест снимка: FinalOutput сравнивается с golden-файлом
// из прошлого прогона. Сегодня вы осознанно добавили
// строку в футер, файл не обновляли.

if err := golden.Equal(t, final); err != nil {
        t.Fatal(err)
}

// Что произойдёт при go test?
```

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

- ✅ Тест упадёт: вывод не совпал с зафиксированным снимком
- Тест пройдёт: снимок перезаписывается автоматически
- Программа не соберётся из-за несоответствия типов
- WaitFor зациклится и съест таймаут

Golden-тест падает при любом расхождении с зафиксированным кадром — в этом его смысл: незваные изменения видны в diff. Если расхождение намеренное, снимок перегенерируют флагом -update, конвенция пакетов Charm, и коммитят вместе с правкой View.

### Фиктивное время

Часы из предыдущего урока тестируются без единой секунды ожидания, и причина — чистота 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, источник вывода и поиск подстроки.

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

Подсказка: Ждёт WaitFor по tm.Output(), а байты ищет bytes.Contains.

Правильные ответы в пропусках: `WaitFor`, `Output`, `Contains`

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

Остался последний барьер — команды, которые ходят в сеть. Чистый тест вызывает 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?

- ✅ Так тест подставляет фейк и проверяет вызов без сети
- Прямой вызов фабрик в Update запрещён компилятором
- Поле-функция работает быстрее замыкания
- tea.Cmd нельзя вернуть из ветки switch

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

### Соберите тело теста отправки

Расставьте строки тела теста: модель, подмена команды, нажатие Enter, проверка флага, сообщение об ошибке, закрытие условия.

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

1. `m := newFormModel()`
2. `m.submit = fakeSubmit`
3. `got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})`
4. `if !got.(formModel).submitting {`
5. `  t.Fatal("форма не перешла в отправку")`
6. `}`

### Что дальше

Тесты — зеркало архитектуры: где их не получается написать, там слоя намешаны. [Следующий урок про структуру проекта](/courses/bubbletea/lessons/23-struktura-proekta/) разложит код по пакетам так, чтобы домен тестировался без tea, экраны — без корня, а корень — без сети. Моки команд отсюда переезжают туда без правок, как и подмена загрузки из [урока про HTTP](/courses/bubbletea/lessons/19-http-dannye/). Если писали десктопные приложения на Fyne, сравните подходы: там тесты виджетов требуют test.NewApp и рантайма интерфейса — [тестирование fyne](/courses/fyne/lessons/24-testirovanie/) — а здесь терминал и вовсе не нужен, и это честное преимущество Elm-архитектуры. Обзор курса — на странице [Bubble Tea](/courses/bubbletea/). Прогон — локальный go test ./..., ни терминала, ни песочницы не требуется. Практика: закройте форму табличными тестами на все ветки — Tab, Shift+Tab с краёв, двойной Enter, пустой логин — и убедитесь, что ни один не спит.

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

- **Чистый тест Update** — Собрать tea.KeyMsg руками, вызвать m.Update, привести got.(formModel) и сравнить поля. Миллисекунды, без терминала, идеален для CI.
- **teatest.NewTestModel** — Запускает настоящую tea.Program в фоне: WithInitialTermSize задаёт экран, tm.Send отправляет сообщения, tm.Output читает вывод, tm.Quit завершает.
- **teatest.WaitFor** — Читает вывод программы, пока условие не станет истиной или не истечёт WithDuration. Таймаут щедрый — секунды, иначе flaky в CI.
- **Golden-снимки** — FinalOutput сравнивается с зафиксированным кадром. Расхождение падает тестом; осознанная правка View перегенерирует снимок флагом -update.
- **Мок команды** — Фабрика команды — поле-функция модели: прод ставит HTTP, тест — фейк с готовым сообщением. Update проверяется без сети.
- **Фиктивное время** — tickMsg(time.Date(...)) отправляется руками; дедлайн — timeoutMsg с нужным gen. Часы и таймауты тестируются без ожидания.

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

- 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 рисует в отладочном режиме и который тест читает напрямую.
