Интуитивно любой разработчик понимает: чтобы выпуски проходили спокойно, нужно написать автоматизированные тесты. Чем больше тестов, тем спокойнее проходят выпуски. Или нет?)) В этой статье я поделюсь нашим опытом построения пирамиды тестирования. Надеюсь, что она понравится читателям и станет первой из цикла статей об автоматизированном тестировании. Нам есть что рассказать! Читать далее
Сегодня настольные приложения разрабатываются иначе, чем, например, 10 лет назад. Новые проекты больше не могут себе позволить жёсткую привязку к Windows. Раньше команда разрабатывала проект и проверяла его в одной и той же операционной системе. Теперь всё чаще проект создаётся для работы сразу на нескольких ОС. Фреймворк Avalonia UI позволяет писать такой кроссплатформенный десктоп на .NET, сохраняя компактную кодовую базу без дублирования. Но чем больше целевых ОС, тем дороже становится ручная проверка перед релизом. Эта статья — о том, как выстроить пирамиду тестирования для Avalonia-приложения.
Приложение-примерДля примера будем тестировать простой калькулятор ипотеки: слайдеры суммы, ставки и срока, таблица платежей и график. Это приложение без единой правки в коде работает на Windows, Ubuntu и RED OS. На самом деле список ОС, на которых оно способно работать, шире, но для простоты и определённости будем считать, что нам нужно гарантированно поддерживать эти три.

Sample Mortgage Calculator на Windows, Ubuntu и RED OS
Сборка под конкретную ОС — штатным dotnet publish с указанием рантайма, в один самодостаточный файл:
dotnet publish . -r win-x64 -c Release --sc /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true
dotnet publish . -r linux-x64 -c Release --sc /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true
А можно ли нам выпускаться?Проект собран под все платформы. Но перед тем как выкладывать бинарники в публичный доступ, нужно ответить на вопрос: а можно ли это выпускать? Подходы к ответу разные. Можно держать штат тестировщиков и ждать их вердикта. Мы же больше полагаемся на автоматизированное тестирование — оно отвечает на этот вопрос быстрее, дешевле и объективнее.
Пирамида тестированияАвтоматизированные тесты принято классифицировать по трём уровням: в основании — юнит-тесты на отдельные функции и классы, выше — интеграционные тесты на взаимодействие модулей, на вершине — E2E на систему целиком. Чем выше уровень, тем медленнее тест и тем дороже его поддержка, поэтому таких тестов держим меньше.

Пирамида тестирования: юнит, интеграционные, E2E
Для мультиплатформенного Avalonia-приложения к этому добавляется своя специфика на каждом уровне: юнит-тесты платформонезависимы и гоняются где угодно, а вот интеграционные и E2E упираются в графическое окружение целевой ОС — окна, дисплей и рабочий стол. Дальше пройдёмся по уровням снизу вверх и посмотрим, чем и что проверять.
Что чем проверяемПриложение построено по MVVM, и для тестирования это удобно: Model и ViewModel не зависят от UI, их закрываем юнит-тестами. View завязана на реальные контролы Avalonia — её проверяем уровнем выше, интеграционными и E2E-тестами.

MVVM-паттерн: связи между Model, ViewModel и View
Юнит-тестыНа этом уровне работают все стандартные подходы к тестированию из книг — отдельно посоветую Кента Бека, «Экстремальное программирование. Разработка через тестирование».
Юнит-тесты заметно проще писать, когда приложение построено по паттерну MVVM: он разделяет логику и представление, и, как следствие, слои Model и ViewModel отлично покрываются юнит-тестами.
Ниже показаны два стандартных варианта встраивания тестов в MVVM.

Unit-тесты на уровне Model
Unit-тесты могут взаимодействовать только с уровнем Model.

Unit-тесты ViewModel вместо View
Также тесты можно подключить к ViewModel вместо View: тогда получится проверить связку Model и ViewModel.
Тестируемые типы у нас чаще internal, чем public, — открывать их наружу только ради тестов не хочется. Открываем сборку для проекта с тестами атрибутом:
[assembly: InternalsVisibleTo("UnitTests")]
Дальше — обычный xUnit. Пример: при изменении ставки график платежей должен пересчитаться заново.
[Fact]
public void AmortizationSchedule_RecalculatedWhenInterestRateChanges()
{
var vm = new MortgageCalculatorViewModel();
var originalSchedule = vm.AmortizationSchedule;
vm.AnnualInterestRate = 5.0m;
Assert.NotSame(originalSchedule, vm.AmortizationSchedule);
Assert.Equal(vm.LoanTermYears * 12, vm.AmortizationSchedule.Count);
}
Интеграционные тесты: Avalonia.HeadlessУровнем выше — Avalonia.Headless: режим запуска приложения без отрисовки графики. Тест поднимает реальное окно в памяти и работает со связкой View ↔ ViewModel, но графику при этом никто не рисует.
Важно понимать ограничение этого режима: настоящих окон и меню он не создаёт. Всё, что завязано на взаимодействие с ОС, — работа с окнами, системное меню, drag&drop, буфер обмена — через Avalonia.Headless проверить нельзя. Это инструмент для интеграционных тестов, но не для E2E.
Пример: включаем в приложении скидку и проверяем, что ставка пересчиталась.
[AvaloniaFact]
public void Test_Visitor()
{
MainWindow window = CreateAndShowWindow();
MortgageCalculatorView view = window.Content as MortgageCalculatorView;
view.isVisitorCheckBox.IsChecked = true;
Assert.Equal("13%", view.annualInterestRateLabel.Content);
}
E2E-тесты: где начинаются проблемыВершина пирамиды — E2E-тесты. Они используют реальные окна, взаимодействуют с приложением как пользователь и должны запускаться на всех поддерживаемых операционных системах.
Независимость тестовБывало у вас такое, что результат прогона зависит от порядка запуска тестов? В любой книге про автоматизированное тестирование написано, что тесты нужно писать независимыми друг от друга. Для E2E это означает, что приложение нужно запускать заново на каждый тест и закрывать после него. У этого идеального подхода есть цена — долгое время прогона. Иногда правилом независимости имеет смысл сознательно пожертвовать и поднимать один экземпляр приложения на всю сессию тестирования — ради скорости. Тогда нужно следить, чтобы каждый тест сам приводил приложение в известное состояние перед началом, иначе тесты начнут влиять друг на друга. Я наблюдал в одной компании такую картину: хороший, большой набор E2E-тестов, но для его запуска требовалось больше 50 часов машинного времени. Когда меня попросили посмотреть, почему они так долго работают, выяснилось, что 90% времени тесты стартовали и закрывали тестируемое приложение. Чтобы принимать решения о выпусках в разумный срок, компания была вынуждена арендовать небольшой кластер. Идеализм может стоить дорого.
Хрупкость тестовНеправильно написанный E2E-тест очень легко сломать. Классический пример — запись событий мыши в экранных координатах. Стоит со временем поменять дизайн приложения, и такие «пиксельные» тесты приходится писать заново. Переписать их, как правило, невозможно: по одним координатам не понять, что именно проверялось. Поэтому со временем подобные тесты чаще всего просто выкидывают. Эту же мысль мы разовьём ниже, в разделе про адаптеры: тест должен обращаться к элементам по смыслу, а не по положению на экране.
Тесты, которые сравнивают картинкиВпрочем, есть категория тестов, которая прекрасно работает на уровне пикселей. Это скриншотные тесты. Они призваны «краснеть», когда что-то поменялось в UI. Такой тест рендерит интерфейс и сравнивает результат с эталонным изображением. Когда тест падает, мы просто глазами сравниваем актуальную картинку с эталоном: если изменение ожидаемое — обновляем эталон, если нет — разбираемся в причине. Несколько раз такие тесты ловили изменения в рендеринге самой Avalonia — то есть срабатывали не на нашей ошибке, а на изменившемся поведении фреймворка после обновления.
Тест-адаптеры: решение проблемы хрупкости E2E-тестовE2E-тесту не нужно, и даже вредно, знать весь доступный API UI-контрола. Поэтому мы избегаем обращений к контролу напрямую из теста и работаем через адаптеры элементов UI. Адаптер (например, TextEditorAdapter поверх TextEditor) выставляет наружу узкий стабильный интерфейс — ровно то, что нужно тесту, и ничего лишнего. Приложение и тесты перестают быть жёстко связаны, а сами тесты становятся короче и переживают рефакторинг UI.

Адаптеры UI между E2E-тестами и контролами приложения
Тест-адаптер можно сделать и более высокоуровневым. Например, для окна логина LoginWindow — свой LoginWindowTestAdapter. Такие адаптеры прячут внутри всю сложность работы с конкретными контролами: знают их особенности, правильно дожидаются результата и так далее.
Юнит-тесты в CI (у нас GitLab) запускаются в Docker-образах с нужным .NET — тут ничего особенного. Вся специфика — в визуальных и E2E-тестах под Linux.
Проблема в том, что GUI-приложению нужен дисплей, а в CI-раннере под Linux ни экрана, ни рабочего стола нет. Поднимаем виртуальный X-сервер Xvfb — он эмулирует экран в памяти, и приложение отрисовывается «в никуда», но полноценно:
Xvfb :0 -screen 0 1920x1080x24
Чтобы разработчик мог воспроизвести падение из CI у себя, не поднимая всё вручную, окружения описаны в testEnvironments.json — тесты запускаются в том же Docker-образе, что и в пайплайне:
{
"version": "1",
"environments": [
{
"name": "ubuntu-jammy-net8",
"type": "docker",
"dockerImage": "ubuntu-jammy-net8:latest"
}
]
}
РезультатыПростейший E2E-подход можно посмотреть в нашем демо-приложении: тест запускает приложение и по очереди открывает все демо-модули. Исходники — в controls-demo, а его прогон в GitHub Actions описан в workflow test.yml.
Полноценная пирамида тестирования реализована в наших САПР. Вот так выглядит один прогон пайплайна: 10 380 тестов, ноль падений, ноль ошибок, ~3 часа 13 минут. В разбивке по заданиям CI видно все целевые платформы — юнит-тесты (около 6000), E2E- и функциональные тесты, тесты инсталляторов на Astra Linux, ALT Linux, Ubuntu, RED OS, Windows 10 и Windows 11.

Результат прогона в GitLab: 10 380 тестов, проверка на Windows и трёх Linux-дистрибутивах
На вопрос «готовы ли выпускаться на всех ОС?» перед каждым релизом отвечает пайплайн, а не ручной прогон по дистрибутивам. Если тема тестирования будет интересна, мы более подробно расскажем, как тестируем Delta Design.
ВыводыЕсли вы выпускаете софт на современном кроссплатформенном стеке под несколько операционных систем, то альтернатив автоматизированному тестированию, по сути, нет. Оно даёт более объективную картину состояния проекта и обходится гораздо дешевле ручного. На практике у нас сложился такой набор правил:
Model и ViewModel закрываем юнит-тестами на xUnit, internal-типы открываем через InternalsVisibleTo.
Связку View/ViewModel без графики проверяем Avalonia.Headless, помня, что окна, меню, drag&drop и буфер обмена этим режимом не покрыть.
Всё, что требует реального окна, — полноценными E2E, обязательно через адаптеры контролов, иначе тесты разваливаются на каждом изменении UI.
В CI под Linux GUI-тесты гоняем под Xvfb, а окружения фиксируем в testEnvironments.json, чтобы прогон повторялся локально один в один.
Всё это уменьшает объём ручной работы перед релизом, обеспечивая стабильность и качество выпусков.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Памятка backend-разработчику: как сохранить психику, отношения и себя | 0 | 8.88 | 01-08-2026 |
| 2 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 3 | Домашняя кластер-лаба с капелькой колхоза | 0 | 11.5 | 14-04-2026 |
| 4 | Мобильная разработка за неделю #636 (22 — 28 июня) | 5 | 7 | 28-06-2026 |
| 5 | Spring Security: аутентификация через REST | 0 | 5 | 07-07-2026 |
| 6 | In-memory база врёт: 5 расхождений с продовой БД | 0 | 7 | 07-07-2026 |
| 7 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 8 | Мы вас видим | 0 | 5 | 22-06-2026 |
| 9 | Организовал весь пентест-арсенал в одном месте: всё под рукой, офлайн и на русском | 5 | 7 | 28-06-2026 |
| 10 | Позер vs. Тру, или Как мы провели Endpoint Hack Zone | 0 | 6.48 | 19-08-2026 |