---
title: "Тестирование fyne-приложений"
description: "Тестирование Fyne: test.NewApp без экрана, симуляция кликов и ввода, снимки разметки с золотыми файлами, WidgetRenderer-тесты и моки хранилища."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/24-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Fyne: десктопные и мобильные GUI на Go», урок 24 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fyne/lessons/24-testirovanie/*

Двадцать три урока строили интерфейс, и каждый раз проверка выглядела одинаково: собрать, запустить, подвигать мышкой. Так регрессии находят пользователи. Запрос «fyne тесты» — это пакет fyne.io/fyne/v2/test: test.NewApp поднимает приложение с тестовым драйвером без экрана, test.Tap и test.Type подсовывают виджетам клики и ввод настоящих событий, а AssertRendersToMarkup сверяет отрисовку с золотым файлом. Русский запрос «тестирование fyne» ведёт сюда же. Урок учится проверять три слоя: логику обработчиков, поведение виджетов под симулированным пользователем и структуру отрисовки — так что сломанный интерфейс останавливает сборку, а не доходит до монитора.

### GUI тестируется без монитора

Скепсис про «GUI не тестируется» держится на путанице двух вещей: событий экрана и событий приложения. В Fyne клик — это вызов метода Tapped у виджета, нажатие клавиши — вызов TypedRune, и настоящий монитор нужен только чтобы превратить движение мыши в этот вызов. Тестовый пакет делает последний шаг сам: события подсовываются виджетам напрямую, и вся логика — обработчики из шестого урока, валидаторы из восемнадцатого, привязки из семнадцатого — выполняется в обычном go test без единого окна. Запрос «fyne тесты» — это три слоя проверок. Первый — чистая логика: хранилища задач и подсчёты тестируются как обычный Go, им интерфейс не нужен вовсе. Второй — поведение: после test.Tap по кнопке состояние изменилось, после test.Type в поле валидатор выдал ошибку. Третий — отрисовка: снимок разметки ловит случай, когда кнопка осталась кликабельной, но исчезла с экрана из-за ошибки раскладки. Правило проекта: логику держи вне виджетов — тогда первый слой покрывает большую часть кода, а UI-тесты добивают стыки. Главный запрет урока: в тестах не должно быть app.New() — он просит у операционной системы настоящий драйвер, и на машине без экрана сборка упадёт. Для всего этого есть test.NewApp, о нём дальше.

### Первый тест: кнопка и счётчик

```go
package 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 работает быстрее за счёт кеша виджетов
- ✅ app.New() запрашивает настоящий драйвер и экран — на CI без дисплея это падение
- test.NewApp включает режим отладки с подробными логами
- Разницы нет, test.NewApp просто короче писать

app.New() пытается открыть реальный рендер: на машине разработчика он ещё сработает, на CI без дисплея упадёт. test.NewApp возвращает приложение с тестовым драйвером: никакого экрана, никакого GPU, настройки и хранилище — в памяти.

### test.NewApp: изолированная среда

Каждый тест начинается с test.NewApp() — и это не замена одной строчки, а смена среды. Приложение создаётся с тестовым драйвером: окна не открываются, рендер идёт на невидимый холст, а всё персистентное — настройки из девятнадцатого урока и хранилище из двадцать первого — живёт в памяти и умирает с тестом. Именно поэтому тесты с preferences не трогают твой настоящий файл настроек: тест изменил ключ, закончился, и мир чист. Из этого следует правило гигиены: один test.NewApp на одну тест-функцию. Общее состояние между тестами — источник самых загадочных сбоев: тест прошёл один, упал в паре с соседним, а по отдельности оба зелёные. Тестовое окно создаётся через test.NewWindow(содержимое): оно разворачивает виджеты на невидимом холсте, применяет раскладку и измеряет размеры — без него проверки, зависящие от MinSize и позиций, не имеют смысла. Окно нужно и для событий, требующих холста: прокрутка и перетаскивание идут через canvas. Для темы есть test.ApplyTheme — подменить тему на время теста и проверить, что тёмный вариант из пятнадцатого урока не сломал контраст. И тонкость порядка: создавай виджеты после test.NewApp — часть инициализации виджетов читает тему приложения, и без тестового приложения она падает или берёт мусор. Среда готова — теперь в неё можно пустить пользователя.

### Пользователь, которого вызвали руками

```go
func 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 из девятого урока, эхо-метка обновляется сама — через обработчик, а не через прямую запись. Тест проверяет связку «ввод — обработчик — интерфейс», а не только переменную.

### Что окажется в поле после ввода

```go
field := widget.NewEntry()
field.SetPlaceHolder("задача")

test.Type(field, "ок")
test.Type(field, "!")

fmt.Println(field.Text)
```

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

- ✅ «ок!» — Type дописывает к уже введённому тексту
- «!» — второй вызов Type затирает содержимое поля
- Пустая строка: без настоящего фокуса ввод в поле не попадёт
- Паника: Type нельзя вызывать дважды на одном поле

Type работает как настоящий пользователь: сначала даёт полю фокус, затем добавляет символы к текущему тексту. Два вызова дали «ок» и «ок!». Затирания нет — для полной замены текста есть field.SetText, который в тестах тоже легален.

### Симуляция пользователя: полный арсенал

Пакет 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 перед вводом символов?

- Ждёт настоящий фокус окна от операционной системы
- ✅ Сам даёт объекту фокус ввода, затем вводит строку посимвольно
- Ничего: фокус должен быть заранее вызван через field.FocusGained() вручную
- Открывает настоящую экранную клавиатуру

Type требует fyne.Focusable и самостоятельно вызывает для него фокус, после чего строки вводятся посимвольно через события клавиатуры. Поэтому тесту не нужны приготовления: Type по Entry сразу пишет текст, и срабатывают OnChanged и валидаторы.

### Снимки: золотая разметка

Клик и ввод проверяют поведение, но не внешний вид: кнопка может потерять иконку, метка — съехать, контейнер — схлопнуться, и все тесты останутся зелёными. Для этого слоя есть снимки. test.AssertRendersToMarkup(t, «greeting.xml», w.Canvas()) разворачивает дерево объектов в текстовую разметку — структуру, типы виджетов, тексты, — и сравнивает с золотым файлом в каталоге testdata; файл не совпал — тест красный, и diff показывает ровно ту строку, которая изменилась. Сестринский test.AssertRendersToImage сравнивает целую картинку отрисовки, но у изображения есть врождённая болезнь: сглаживание и шрифты чуть-чуть отличаются между платформами, и снимок с ноутбука не сходится со снимком с CI. Поэтому бытовое правило — по умолчанию markup-снимок, image-снимок только когда проверяется именно пиксельная графика из седьмого урока. Быстрая проверка без каталога testdata: test.RenderToMarkup(canvas) возвращает строку разметки, и её можно сравнить в тесте напрямую или напечатать при отладке. Когда снимок красный, у тебя два честных исхода: регрессия — чини код, и осознанное изменение дизайна — обнови золотой файл и упомяни это в коммите. Не обновляй снимки машинально: золотой файл — это договор о том, как выглядит приложение, и его правка должна быть решением, а не привычкой. Меняешь тему из пятнадцатого урока — снимки честно покраснеют и заставят перечитать, что именно поменялось.

### Снимок экрана приветствия

```go
func TestGreetingMarkup(t *testing.T) {
	test.NewApp()
	w := test.NewWindow(
		widget.NewCard("Добро пожаловать", "", widget.NewLabel("Привет, Гость")),
	)

	test.AssertRendersToMarkup(t, "greeting.xml", w.Canvas())
}
```

Холст тестового окна разворачивается в разметку и сверяется с testdata/greeting.xml. Снимок ловит и структуру — карта внутри, метка в ней, — и тексты: замени заголовок карты, и diff укажет строку. Разметка текстовая, поэтому шрифты и сглаживание платформы ей безразличны.

### Сломается ли снимок

```go
w := test.NewWindow(widget.NewLabel("Привет"))
old := test.RenderToMarkup(w.Canvas())

w.Content().(*widget.Label).SetText("Пока")

fmt.Println(old == test.RenderToMarkup(w.Canvas()))
```

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

- true — снимок фиксирует только структуру виджетов, но не тексты
- ✅ false — разметка включает текст метки, и он изменился
- true: SetText меняет только содержимое, разметка — это типы объектов
- false, но по другой причине: SetText уничтожает виджет

Разметка описывает дерево объектов вместе с их свойствами, и текст метки — свойство. SetText поменял разметку, сравнение дало false. Именно так снимок ловит контентные регрессии: тип объекта тот же, а то, что видит пользователь, — другое.

### Моки и подмены

Тестирование fyne — это ещё и умение подменять мир вокруг интерфейса. Первый мок уже встроен: test.NewApp даёт настройки и хранилище в памяти, поэтому код, читающий preferences из девятнадцатого урока, тестируется без диска — установи ключи в начале теста и проверь, как экран их подхватывает. Второй слой — собственные фейки для данных: если список из десятого урока питается из интерфейса Store, тест подсовывает реализацию с фиксированным срезом — и проверяет, что List показал ровно их и вызвал шаблон нужное число раз. Отсюда и архитектурная награда разделения: чем тоньше слой виджетов, тем меньше UI-тестов нужно. Третий инструмент — рендерер кастомных виджетов из восьмого урока: test.WidgetRenderer(wid) возвращает WidgetRenderer, и тест может проверить объекты напрямую — сколько их, каких типов, с какими цветами. Это точечная проверка внутренностей без снимков: полезно для своих контролов, где разметка длинная и шумная. Четвёртая подмена — время и темы: test.ApplyTheme подставляет тему, а test.NewTheme собирает тестовую; если твой виджет берёт цвета из темы, подмена доказывает, что хардкода нет. Правило отбора моков простое: подменяй границы — диск, сеть, время, — но не подменяй виджеты, которые тестируешь: тест, в котором кнопка — мок, проверяет только твой оптимизм. Фальшивое хранилище — да, фальшивая кнопка — нет.

### Проверка рендерера своего виджета

```go
func 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-тесте?

- Кнопку, по которой кликает test.Tap — так тест быстрее
- ✅ Хранилище данных и настройки: фейк в памяти вместо диска
- Весь виджет List, чтобы не проверять виртуализацию
- Сам тестовый драйвер — заменить его заглушкой

Моками заменяют границы с внешним миром: файлы, сеть, время. test.NewApp уже делает это для preferences и хранилища. Виджеты под тестом должны быть настоящими — тест фальшивой кнопки доказывает только работоспособность фальшивки.

### Каталог тестового пакета

| 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
func TestEcho(t *testing.T) {
	app := test.___()

	entry := widget.NewEntry()
	window := test.___(entry)
	test.___(entry, "привет")

	if entry.___ != "привет" {
		t.Fatal("текст не попал в поле")
	}
	_ = app
	_ = window
}
```

Подсказка: Приложение без экрана создаёт test.NewApp, окно — test.NewWindow с содержимым, ввод — test.Type, а текст поля лежит в поле Text структуры Entry.

Правильные ответы в пропусках: `NewApp()`, `NewWindow`, `Type`, `Text`

### Путь одного go test

Расставь по порядку, что происходит от запуска go test до зелёной строки отчёта.

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

1. `go test запускает тест-функцию в обычном Go-процессе`
2. `test.NewApp создаёт приложение с тестовым драйвером без экрана`
3. `test.NewWindow разворачивает содержимое на невидимом холсте`
4. `test.Tap и test.Type подсовывают виджетам события пользователя`
5. `обработчики меняют состояние, тест проверяет утверждения`
6. `AssertRendersToMarkup сверяет отрисовку с золотым файлом и тест завершается`

> **Честно о запуске кода.** Тесты из этого урока — редкий случай, когда код курса можно запустить прямо в терминале: go test не требует экрана, песочница не нужна. Собери пакет по второму уроку, положи тесты рядом и запусти go test ./... — зелёный отчёт увидишь в консоли, а золотые файлы появятся в testdata.

> **Три способа испортить тест.** Не создавай app.New() в тестах — падение на CI без дисплея. Не спи вслепую: time.Sleep вместо ожидания события из двадцатого урока даёт мигающие тесты. И не гоняй тесты параллельно на общем состоянии: один test.NewApp на тест-функцию, иначе соседи будут менять друг другу настройки.

### Практика: тестируй логику отдельно

Соберём пирамиду для реального приложения. Основание — чистые структуры: хранилище задач финального проекта тестируется без единого виджета — добавил, отметил, удалил, сохранил, перечитал; таблица тестов Go покрывает граничные случаи дешевле любых UI-симуляций. Средний слой — валидация и связки: валидатор из восемнадцатого урока проверяется прямым вызовом Validate, привязки из семнадцатого — чтением источника после Set. Вершина — UI-тесты: по одному на пользовательский сценарий, не на каждый виджет — «ввёл текст, нажал добавить, задача появилась в списке и в хранилище», «правый клик по задаче открывает меню удаления», «закрытие с несохранённым спрашивает подтверждение» из двадцать третьего урока. На эту вершину вешаются markup-снимки ключевых экранов — по одному на состояние: пустой список, список с задачами, экран настроек. Итоговая пропорция зрелого проекта: десятки тестов логики, несколько поведенческих UI-тестов, пара снимков на экран. Если всё наоборот — тысячи кликов и ни одной проверки хранилища — тесты будут медленными и хрупкими. Прогони готовый проект по этой пирамиде в двадцать пятом уроке: там каждая функция итогового приложения уже расчищена под тест именно так.

### Что дальше: итоговый проект

Теперь у курса есть система контроля качества: логика проверяется чистыми тестами, поведение — симулированным пользователем, отрисовка — снимками, а моки закрывают доступ к настоящему диску. Опора урока — [третий урок](/courses/fyne/lessons/03-vidzhety/) с виджетами, [восьмой](/courses/fyne/lessons/08-kastomnye-vidzhety/) с рендерерами, [десятый](/courses/fyne/lessons/10-spiski/) со списками, [девятнадцатый](/courses/fyne/lessons/19-preferences/) с хранением и [двадцать третий](/courses/fyne/lessons/23-desktop/) с поведением окна, которое тоже теперь под тестом. Финальная тема — итоговый проект: менеджер задач, который собирает все двадцать четыре урока в одно приложение, от модели данных до темы, и живёт на диске между запусками. Полный маршрут — [на странице курса Fyne](/courses/fyne/).

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

- **test.NewApp** — Приложение с тестовым драйвером: без экрана и GPU, настройки и хранилище — в памяти. Обязательная первая строка UI-теста вместо app.New.
- **test.NewWindow(content)** — Окно на невидимом холсте: применяет раскладку и размеры. Нужно для снимков, прокрутки и любых проверок геометрии.
- **test.Tap и test.Type** — Клик по Tappable и посимвольный ввод в Focusable с автофокусом. События идут напрямую виджету — синхронно и без курсора.
- **AssertRendersToMarkup** — Снимок структуры отрисовки против золотого файла из testdata: текстовый, платформонезависимый, с точным diff. По умолчанию вместо image-снимка.
- **test.WidgetRenderer** — Доступ к рендереру виджета и его объектам: проверка числа, типов и свойств внутренних объектов кастомного контрола.
- **Мок границы** — Подмена внешнего мира в тесте: хранилище, диск, время. Виджеты под проверкой всегда настоящие — подменяются только окружение.

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

- 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 делает тесты медленными и мигающими.
