Сегодня настольные приложения разрабатываются иначе, чем, например, 10 лет назад. Новые проекты больше не могут себе позволить жёсткую привязку к Windows. Раньше команда разрабатывала проект и проверяла его в одной и той же операционной системе. Теперь всё чаще проект создаётся для работы сразу на нескольких ОС. Фреймворк Avalonia UI позволяет писать такой кроссплатформенный десктоп на .NET, сохраняя компактную кодовую базу без дублирования. Но чем больше целевых ОС, тем дороже становится ручная проверка перед релизом. Эта статья — о том, как выстроить пирамиду тестирования для Avalonia-приложения.
Приложение-пример
Для примера будем тестировать простой калькулятор ипотеки: слайдеры суммы, ставки и срока, таблица платежей и график. Это приложение без единой правки в коде работает на 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 на систему целиком. Чем выше уровень, тем медленнее тест и тем дороже его поддержка, поэтому таких тестов держим меньше.

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

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

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

Также тесты можно подключить к 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.

Тест-адаптер можно сделать и более высокоуровневым. Например, для окна логина LoginWindow — свой LoginWindowTestAdapter. Такие адаптеры прячут внутри всю сложность работы с конкретными контролами: знают их особенности, правильно дожидаются результата и так далее.
Запуск в CI на всех ОС
Юнит-тесты в 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.

На вопрос «готовы ли выпускаться на всех ОС?» перед каждым релизом отвечает пайплайн, а не ручной прогон по дистрибутивам. Если тема тестирования будет интересна, мы более подробно расскажем, как тестируем Delta Design.
Выводы
Если вы выпускаете софт на современном кроссплатформенном стеке под несколько операционных систем, то альтернатив автоматизированному тестированию, по сути, нет. Оно даёт более объективную картину состояния проекта и обходится гораздо дешевле ручного. На практике у нас сложился такой набор правил:
ModelиViewModelзакрываем юнит-тестами на xUnit,internal-типы открываем черезInternalsVisibleTo.Связку
View/ViewModelбез графики проверяемAvalonia.Headless, помня, что окна, меню, drag&drop и буфер обмена этим режимом не покрыть.Всё, что требует реального окна, — полноценными E2E, обязательно через адаптеры контролов, иначе тесты разваливаются на каждом изменении UI.
В CI под Linux GUI-тесты гоняем под
Xvfb, а окружения фиксируем вtestEnvironments.json, чтобы прогон повторялся локально один в один.
Всё это уменьшает объём ручной работы перед релизом, обеспечивая стабильность и качество выпусков.

