Содержание урока · 11 разделов
GUI тестируется без монитора#
Скепсис про «GUI не тестируется» держится на путанице двух вещей: событий экрана и событий приложения. В Fyne клик — это вызов метода Tapped у виджета, нажатие клавиши — вызов TypedRune, и настоящий монитор нужен только чтобы превратить движение мыши в этот вызов. Тестовый пакет делает последний шаг сам: события подсовываются виджетам напрямую, и вся логика — обработчики из шестого урока, валидаторы из восемнадцатого, привязки из семнадцатого — выполняется в обычном go test без единого окна. Запрос «fyne тесты» — это три слоя проверок. Первый — чистая логика: хранилища задач и подсчёты тестируются как обычный Go, им интерфейс не нужен вовсе. Второй — поведение: после test.Tap по кнопке состояние изменилось, после test.Type в поле валидатор выдал ошибку. Третий — отрисовка: снимок разметки ловит случай, когда кнопка осталась кликабельной, но исчезла с экрана из-за ошибки раскладки. Правило проекта: логику держи вне виджетов — тогда первый слой покрывает большую часть кода, а UI-тесты добивают стыки. Главный запрет урока: в тестах не должно быть app.New() — он просит у операционной системы настоящий драйвер, и на машине без экрана сборка упадёт. Для всего этого есть test.NewApp, о нём дальше.
Первый тест: кнопка и счётчик
GOpackage main
import (
"fmt"
"testing"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
)
func TestCounterButton(t *testing.T) {
test.NewApp()
label := widget.NewLabel("0")
count := 0
btn := widget.NewButton("Плюс", func() {
count++
label.SetText(fmt.Sprint(count))
})
test.Tap(btn)
test.Tap(btn)
if count != 2 {
t.Fatalf("нажали дважды, счётчик равен %d", count)
}
if label.Text != "2" {
t.Fatalf("метка показывает %q", label.Text)
}
}Тест живёт в обычном _test.go файле и запускается go test. test.Tap вызывает у кнопки событие нажатия — обработчик исполняется синхронно, и утверждения проверяются сразу. Окно даже не создавалось: клик по виджету не требует холста.
Почему в тестах нужен test.NewApp(), а не обычный app.New()?
test.NewApp: изолированная среда#
Каждый тест начинается с test.NewApp() — и это не замена одной строчки, а смена среды. Приложение создаётся с тестовым драйвером: окна не открываются, рендер идёт на невидимый холст, а всё персистентное — настройки из девятнадцатого урока и хранилище из двадцать первого — живёт в памяти и умирает с тестом. Именно поэтому тесты с preferences не трогают твой настоящий файл настроек: тест изменил ключ, закончился, и мир чист. Из этого следует правило гигиены: один test.NewApp на одну тест-функцию. Общее состояние между тестами — источник самых загадочных сбоев: тест прошёл один, упал в паре с соседним, а по отдельности оба зелёные. Тестовое окно создаётся через test.NewWindow(содержимое): оно разворачивает виджеты на невидимом холсте, применяет раскладку и измеряет размеры — без него проверки, зависящие от MinSize и позиций, не имеют смысла. Окно нужно и для событий, требующих холста: прокрутка и перетаскивание идут через canvas. Для темы есть test.ApplyTheme — подменить тему на время теста и проверить, что тёмный вариант из пятнадцатого урока не сломал контраст. И тонкость порядка: создавай виджеты после test.NewApp — часть инициализации виджетов читает тему приложения, и без тестового приложения она падает или берёт мусор. Среда готова — теперь в неё можно пустить пользователя.
Пользователь, которого вызвали руками
GOfunc TestEchoForm(t *testing.T) {
test.NewApp()
label := widget.NewLabel("")
entry := widget.NewEntry()
entry.OnChanged = func(s string) {
label.SetText("Эхо: " + s)
}
test.Type(entry, "ок")
if label.Text != "Эхо: ок" {
t.Fatalf("метка показала %q", label.Text)
}
}test.Type даёт полю фокус и побуквенно вводит строку: на каждый символ срабатывает OnChanged из девятого урока, эхо-метка обновляется сама — через обработчик, а не через прямую запись. Тест проверяет связку «ввод — обработчик — интерфейс», а не только переменную.
Что окажется в поле после ввода
Прочитайте код и выберите, что он выведет:
field := widget.NewEntry()
field.SetPlaceHolder("задача")
test.Type(field, "ок")
test.Type(field, "!")
fmt.Println(field.Text)Симуляция пользователя: полный арсенал#
Пакет test покрывает все виды взаимодействий, которые ты учил в шестом уроке. Клик: test.Tap(obj) для левой кнопки по любому Tappable — кнопки, элементы списка из десятого урока; test.TapSecondary(obj) для правого — так открываются контекстные меню из четырнадцатого; test.TapAt(obj, pos) целится в конкретную точку виджета; test.DoubleTap(obj) для двойного нажатия. Ввод: test.Type(obj, строка) для Focusable-объектов — полей ввода и всего, что принимает клавиатуру; функция сама даёт фокус, поэтому порядок «сначала Type, потом проверки» работает без ритуалов. Прокрутка и жесты: test.Scroll(canvas, pos, dx, dy) крутит Scrollable-контейнеры, test.Drag(canvas, pos, dx, dy) тянет перетаскиваемые элементы — так проверяются списки с drag-реордером. Движение мыши без клика — test.MoveMouse. Важно понимать механику: это не эмуляция операционной системы с курсором и задержками, а прямой вызов событий на объекте. Отсюда скорость — сотни тестов за секунды — и две границы. Первая: реальную раскладку окна и попадание в координаты тест не проверяет — если кнопка перекрыта другим виджетом, test.Tap по ней всё равно сработает, и это ловит снимок разметки, а не клик. Вторая: асинхронное поведение из двадцатого урока — код, запущенный через fyne.Do, исполняется не мгновенно, и тест должен дождаться завершения, а не спать вслепую. В остальном арсенал покрывает всё, что делает палец.
Что делает test.Type перед вводом символов?
Снимки: золотая разметка#
Клик и ввод проверяют поведение, но не внешний вид: кнопка может потерять иконку, метка — съехать, контейнер — схлопнуться, и все тесты останутся зелёными. Для этого слоя есть снимки. test.AssertRendersToMarkup(t, «greeting.xml», w.Canvas()) разворачивает дерево объектов в текстовую разметку — структуру, типы виджетов, тексты, — и сравнивает с золотым файлом в каталоге testdata; файл не совпал — тест красный, и diff показывает ровно ту строку, которая изменилась. Сестринский test.AssertRendersToImage сравнивает целую картинку отрисовки, но у изображения есть врождённая болезнь: сглаживание и шрифты чуть-чуть отличаются между платформами, и снимок с ноутбука не сходится со снимком с CI. Поэтому бытовое правило — по умолчанию markup-снимок, image-снимок только когда проверяется именно пиксельная графика из седьмого урока. Быстрая проверка без каталога testdata: test.RenderToMarkup(canvas) возвращает строку разметки, и её можно сравнить в тесте напрямую или напечатать при отладке. Когда снимок красный, у тебя два честных исхода: регрессия — чини код, и осознанное изменение дизайна — обнови золотой файл и упомяни это в коммите. Не обновляй снимки машинально: золотой файл — это договор о том, как выглядит приложение, и его правка должна быть решением, а не привычкой. Меняешь тему из пятнадцатого урока — снимки честно покраснеют и заставят перечитать, что именно поменялось.
Снимок экрана приветствия
GOfunc TestGreetingMarkup(t *testing.T) {
test.NewApp()
w := test.NewWindow(
widget.NewCard("Добро пожаловать", "", widget.NewLabel("Привет, Гость")),
)
test.AssertRendersToMarkup(t, "greeting.xml", w.Canvas())
}Холст тестового окна разворачивается в разметку и сверяется с testdata/greeting.xml. Снимок ловит и структуру — карта внутри, метка в ней, — и тексты: замени заголовок карты, и diff укажет строку. Разметка текстовая, поэтому шрифты и сглаживание платформы ей безразличны.
Сломается ли снимок
Прочитайте код и выберите, что он выведет:
w := test.NewWindow(widget.NewLabel("Привет"))
old := test.RenderToMarkup(w.Canvas())
w.Content().(*widget.Label).SetText("Пока")
fmt.Println(old == test.RenderToMarkup(w.Canvas()))Моки и подмены#
Тестирование fyne — это ещё и умение подменять мир вокруг интерфейса. Первый мок уже встроен: test.NewApp даёт настройки и хранилище в памяти, поэтому код, читающий preferences из девятнадцатого урока, тестируется без диска — установи ключи в начале теста и проверь, как экран их подхватывает. Второй слой — собственные фейки для данных: если список из десятого урока питается из интерфейса Store, тест подсовывает реализацию с фиксированным срезом — и проверяет, что List показал ровно их и вызвал шаблон нужное число раз. Отсюда и архитектурная награда разделения: чем тоньше слой виджетов, тем меньше UI-тестов нужно. Третий инструмент — рендерер кастомных виджетов из восьмого урока: test.WidgetRenderer(wid) возвращает WidgetRenderer, и тест может проверить объекты напрямую — сколько их, каких типов, с какими цветами. Это точечная проверка внутренностей без снимков: полезно для своих контролов, где разметка длинная и шумная. Четвёртая подмена — время и темы: test.ApplyTheme подставляет тему, а test.NewTheme собирает тестовую; если твой виджет берёт цвета из темы, подмена доказывает, что хардкода нет. Правило отбора моков простое: подменяй границы — диск, сеть, время, — но не подменяй виджеты, которые тестируешь: тест, в котором кнопка — мок, проверяет только твой оптимизм. Фальшивое хранилище — да, фальшивая кнопка — нет.
Проверка рендерера своего виджета
GOfunc TestBadgeRenderer(t *testing.T) {
test.NewApp()
badge := NewBadge("новое")
r := test.WidgetRenderer(badge)
if len(r.Objects()) != 2 {
t.Fatalf("у рендера %d объекта, ожидали два", len(r.Objects()))
}
rect, ok := r.Objects()[0].(*canvas.Rectangle)
if !ok {
t.Fatal("первый объект должен быть фоном")
}
want := theme.Color(theme.ColorNamePrimary)
if rect.FillColor != want {
t.Fatal("фон не акцентный цвет темы")
}
}Тест открывает внутренности виджета из восьмого урока: два объекта, первый — прямоугольник-фон. Проверка цвета через theme.Color доказывает, что виджет берёт акцент из темы, а не хардкодит — подмена темы из пятнадцатого урока теперь автоматически подхватится.
Что уместно подменить моком в UI-тесте?
Каталог тестового пакета#
| API | Что делает | Когда звать |
|---|---|---|
| test.NewApp() | приложение с тестовым драйвером без экрана | первая строка каждой UI-тест-функции |
| test.NewWindow(content) | окно с раскладкой на невидимом холсте | когда нужны размеры, позиции, снимки |
| test.Tap(obj) | клик по Tappable | кнопки, пункты списков, действия |
| test.TapSecondary(obj) | правый клик по SecondaryTappable | контекстные меню |
| test.Type(obj, chars) | ввод строки посимвольно с автофокусом | Entry и другие Focusable |
| test.DoubleTap(obj) | двойной клик | элементы списков, открытие |
| test.Scroll(c, pos, dx, dy) | прокрутка Scrollable | длинные списки и формы |
| test.Drag(c, pos, dx, dy) | перетаскивание | реордер списков, слайдеры |
| test.RenderToMarkup(c) | строка разметки холста | быстрые сравнения и отладка |
| test.AssertRendersToMarkup | снимок разметки против золотого файла | основной снимок по умолчанию |
| test.AssertRendersToImage | снимок-картинка против золотого файла | проверка пиксельной графики |
| test.WidgetRenderer(wid) | рендерер виджета и его объекты | тесты кастомных виджетов |
| test.ApplyTheme(t, theme) | подмена темы на время теста | проверка тематических цветов |
| test.NewClipboard() | чистый буфер обмена для теста | проверка копирования и вставки |
Собери тест поля ввода
Восстанови четыре пропуска: тестовое приложение, окно, ввод текста и проверка эха.
Путь одного go test
Расставь по порядку, что происходит от запуска go test до зелёной строки отчёта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Тесты из этого урока — редкий случай, когда код курса можно запустить прямо в терминале: go test не требует экрана, песочница не нужна. Собери пакет по второму уроку, положи тесты рядом и запусти go test ./... — зелёный отчёт увидишь в консоли, а золотые файлы появятся в testdata.
Не создавай app.New() в тестах — падение на CI без дисплея. Не спи вслепую: time.Sleep вместо ожидания события из двадцатого урока даёт мигающие тесты. И не гоняй тесты параллельно на общем состоянии: один test.NewApp на тест-функцию, иначе соседи будут менять друг другу настройки.
Практика: тестируй логику отдельно#
Соберём пирамиду для реального приложения. Основание — чистые структуры: хранилище задач финального проекта тестируется без единого виджета — добавил, отметил, удалил, сохранил, перечитал; таблица тестов Go покрывает граничные случаи дешевле любых UI-симуляций. Средний слой — валидация и связки: валидатор из восемнадцатого урока проверяется прямым вызовом Validate, привязки из семнадцатого — чтением источника после Set. Вершина — UI-тесты: по одному на пользовательский сценарий, не на каждый виджет — «ввёл текст, нажал добавить, задача появилась в списке и в хранилище», «правый клик по задаче открывает меню удаления», «закрытие с несохранённым спрашивает подтверждение» из двадцать третьего урока. На эту вершину вешаются markup-снимки ключевых экранов — по одному на состояние: пустой список, список с задачами, экран настроек. Итоговая пропорция зрелого проекта: десятки тестов логики, несколько поведенческих UI-тестов, пара снимков на экран. Если всё наоборот — тысячи кликов и ни одной проверки хранилища — тесты будут медленными и хрупкими. Прогони готовый проект по этой пирамиде в двадцать пятом уроке: там каждая функция итогового приложения уже расчищена под тест именно так.
Что дальше: итоговый проект#
Теперь у курса есть система контроля качества: логика проверяется чистыми тестами, поведение — симулированным пользователем, отрисовка — снимками, а моки закрывают доступ к настоящему диску. Опора урока — третий урок с виджетами, восьмой с рендерерами, десятый со списками, девятнадцатый с хранением и двадцать третий с поведением окна, которое тоже теперь под тестом. Финальная тема — итоговый проект: менеджер задач, который собирает все двадцать четыре урока в одно приложение, от модели данных до темы, и живёт на диске между запусками. Полный маршрут — на странице курса Fyne.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneОжидание: 0
Экран собран: «Тестирование»
Коротко: что запомнить
- GUI тестируется в go test: события подсовываются виджетам напрямую, монитор не нужен, а app.New() в тестах запрещён.
- test.NewApp даёт изолированную среду: тестовый драйвер, настройки и хранилище в памяти, чистота между тест-функциями.
- test.Tap, TapSecondary, DoubleTap, Type, Scroll и Drag покрывают весь арсенал взаимодействий из урока про события.
- Markup-снимок — основной: текстовая разметка не зависит от шрифтов и платформы; image-снимок — только для пиксельной графики.
- Моками подменяют границы — диск, сеть, время; виджеты под тестом остаются настоящими.
- Пирамида: много чистых тестов логики, несколько UI-сценариев, пара снимков на экран.
- Красный золотой файл — это развилка: регрессия чинится кодом, осознанный редизайн обновляет файл в отдельном коммите.
Домашнее задание
Возьми любой экран из предыдущих уроков — например, форму входа или список — и накрой его тестами трёх слоёв. Первый: чистая логика — вынеси обработку ввода в структуру и напиши табличный тест на граничные случаи. Второй: поведение — test.NewApp, test.NewWindow, test.Type с валидной и невалидной строкой из восемнадцатого урока, test.Tap по кнопке отправки; проверь, что состояние хранилища изменилось ровно один раз и метка ошибки появилась при невалидном вводе. Третий: снимок — AssertRendersToMarkup для двух состояний экрана, пустого и заполненного; золотые файлы положи в testdata и посмотри их глазами. Отдельно проверь гигиену: каждый тест начинается с test.NewApp, в коде нет ни одного app.New, ни одного time.Sleep. Затем сломай что-нибудь нарочно — удали у кнопки обработчик или переименуй заголовок — и убедись, что конкретный тест краснеет с понятным diff, а не весь пакет сразу. В финале добавь тест рендерера, если у тебя есть кастомный виджет из восьмого урока: проверь число объектов и тип первого. Собери отчёт go test -v и сохрани его рядом с проектом — он станет базой, с которой будешь сравнивать проект после рефакторинга.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли для тестов Fyne настоящий экран или Xvfb?
Нет: test.NewApp использует тестовый драйвер, который рендерит на невидимый холст. Тесты идут в обычном go test на CI без дисплея; настоящий экран нужен только для ручной проверки и image-снимков, чувствительных к рендеру.
Почему markup-снимок предпочтительнее image-снимка?
Разметка текстовая: она фиксирует структуру виджетов, свойства и тексты, но не зависит от сглаживания, шрифтов и версии графической библиотеки. Image-снимок сравнивает пиксели и часто краснеет от различий между машинами, поэтому его берут точечно.
Как тестировать код, который запущен через fyne.Do из горутины?
Не спать вслепую, а синхронизировать событие: завершай фоновую работу отправкой в канал, который тест читает с таймаутом, либо проверяй состояние после явного ожидания готовности. Голый time.Sleep делает тесты медленными и мигающими.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки