Desktop UI

Avalonia Headless Testing — тестування UI без вікон

Революційний підхід до тестування користувацького інтерфейсу: рендеринг UI в пам'яті, симуляція взаємодій та візуальна регресія без реальних вікон та GPU

Avalonia Headless Testing — тестування UI без вікон

Тестування користувацького інтерфейсу традиційно вважається однією з найскладніших задач у розробці десктопних додатків. UI-тести часто бувають повільними, нестабільними (flaky), вимагають складного налаштування та не можуть виконуватися на серверах без графічного середовища. Avalonia пропонує революційне рішення цієї проблеми — Headless Testing, технологію, яка дозволяє рендерити повноцінний користувацький інтерфейс у пам'яті, без створення реальних вікон, без залежності від GPU та без потреби у графічному середовищі операційної системи.

Уявіть собі можливість написати unit-тест, який створює вікно вашого додатку, знаходить кнопку, клікає на неї, вводить текст у текстове поле, перевіряє зміну стану ViewModel та навіть робить скріншот результату — і все це виконується за мілісекунди, без відкриття жодного реального вікна на екрані. Саме це і пропонує Avalonia Headless Testing. Ця технологія змінює правила гри у тестуванні UI, роблячи його таким же швидким, надійним та зручним, як тестування звичайної бізнес-логіки.

У цій статті ми детально розглянемо, як працює Avalonia Headless Testing, чому ця технологія є унікальною у світі .NET десктопної розробки, як налаштувати headless-тести у вашому проєкті, як симулювати користувацькі взаємодії (кліки, натискання клавіш, введення тексту), як використовувати headless-скріншоти для візуальної регресії, та порівняємо цей підхід з традиційними методами UI-тестування у WPF.

Словник термінів
  • Headless Testing — тестування UI без створення реальних вікон, з рендерингом у пам'яті
  • Flaky Test — нестабільний тест, який іноді проходить, а іноді падає без змін у коді
  • Visual Regression Testing — тестування, яке порівнює скріншоти UI для виявлення візуальних змін
  • UI Automation — технологія Microsoft для автоматизації взаємодії з UI через accessibility API
  • Render Target — ціль рендерингу (екран, текстура, пам'ять)
  • Frame Buffer — буфер кадру, область пам'яті для зберігання зображення

Чому Avalonia Headless унікальний?

Перш ніж заглибитися у технічні деталі, важливо зрозуміти, чому Avalonia Headless Testing є революційною технологією та чим він відрізняється від традиційних підходів до UI-тестування у .NET екосистемі.

Традиційні підходи до UI-тестування

У світі WPF та інших UI-фреймворків існує кілька підходів до тестування користувацького інтерфейсу, кожен з яких має свої обмеження:

1. UI Automation (Microsoft Accessibility API)

WPF підтримує UI Automation — технологію Microsoft, яка дозволяє програмно взаємодіяти з елементами інтерфейсу через accessibility API. Цей підхід використовується інструментами на кшталт Coded UI Tests, TestStack.White, FlaUI та іншими.

Проблеми UI Automation:

  • Повільність: Кожна взаємодія вимагає реального рендерингу вікна, обробки повідомлень Windows, оновлення екрану
  • Нестабільність: Тести залежать від timing issues — елемент може бути ще не готовий, анімація може не завершитися, вікно може бути перекрите іншим
  • Складність налаштування: Потрібне реальне графічне середовище, на CI/CD серверах потрібні спеціальні налаштування (віртуальний дисплей, RDP сесія)
  • Залежність від ОС: UI Automation працює по-різному на різних версіях Windows, може не працювати на Linux/macOS
  • Flaky tests: Тести часто падають через race conditions, timing issues, зміни у фокусі вікон

2. Тестування через ViewModel (без UI)

Багато розробників обирають підхід "тестуємо тільки ViewModel, UI не чіпаємо". Цей підхід ми розглянули у попередній статті — він працює чудово для бізнес-логіки, але має обмеження:

  • Не перевіряє, чи правильно працює Data Binding
  • Не виявляє помилки у XAML (typo у PropertyPath, неправильний Converter)
  • Не перевіряє візуальний стан (чи видимий елемент, чи правильний колір, чи правильний layout)
  • Не тестує складні UI-взаємодії (drag-n-drop, multi-selection, keyboard navigation)

3. Manual Testing

Найпоширеніший підхід — ручне тестування. Розробник або QA-інженер відкриває додаток та перевіряє функціональність вручну. Це працює, але:

  • Повільно та дорого
  • Не масштабується
  • Не підходить для regression testing
  • Людський фактор — можна пропустити баг

Революція Avalonia Headless

Avalonia Headless Testing вирішує всі ці проблеми радикально іншим підходом: рендеринг UI відбувається повністю у пам'яті, без створення реальних вікон та без залежності від GPU.

Як це працює технічно:

  1. Headless Render Target: Avalonia рендерить UI не на екран, а у спеціальний буфер у пам'яті (frame buffer)
  2. Віртуальна платформа: Замість реальної платформи (Win32, X11, Cocoa) використовується віртуальна платформа, яка емулює всі необхідні API
  3. Синхронне виконання: Всі операції виконуються синхронно — немає race conditions, немає timing issues
  4. Повний контроль: Тест має повний контроль над lifecycle вікна, input events, rendering pipeline

Переваги цього підходу:

Швидкість Headless-тести виконуються у 10-100 разів швидше за UI Automation тести. Створення вікна, рендеринг та взаємодія займають мілісекунди, а не секунди.
Стабільність Немає flaky tests. Всі операції синхронні, немає race conditions, немає залежності від timing. Тест або проходить, або падає — стабільно.
Простота налаштування Не потрібне графічне середовище. Тести працюють на CI/CD серверах без додаткових налаштувань. Один NuGet пакет — і все готово.
Кросплатформність Тести працюють однаково на Windows, Linux, macOS. Немає платформо-специфічних quirks.
Повне покриття Тестується не тільки ViewModel, але й Data Binding, XAML, Styles, Triggers, Animations, Layout — весь UI stack.
Візуальна регресія Можливість робити скріншоти у пам'яті та порівнювати їх для виявлення візуальних змін.

Порівняння з WPF UI Automation

Давайте порівняємо Avalonia Headless Testing з традиційним WPF UI Automation підходом:

ХарактеристикаAvalonia HeadlessWPF UI Automation
Швидкість виконання10-50ms на тест1-5s на тест
СтабільністьДетерміновані, без flaky testsЧасті flaky tests через timing
Налаштування1 NuGet пакетСкладне (графічне середовище, спеціальні налаштування CI)
CI/CD підтримкаПрацює out-of-the-boxПотрібен віртуальний дисплей або RDP
КросплатформністьWindows, Linux, macOS однаковоТільки Windows, різні версії по-різному
Залежність від GPUНемаєПотрібен GPU або software rendering
Паралельне виконанняЛегко (кожен тест ізольований)Складно (конфлікти вікон, фокусу)
Візуальна регресіяВбудована (CaptureRenderedFrame)Потрібні сторонні інструменти
DebuggingЛегко (звичайний unit-тест)Складно (потрібно бачити вікно)
Підтримка анімаційПовний контроль (можна пропустити час)Потрібно чекати реального часу

Як бачимо, Avalonia Headless Testing має переваги практично у всіх аспектах. Це не просто "ще один спосіб тестування UI" — це принципово інший підхід, який робить UI-тестування таким же зручним та надійним, як тестування звичайної бізнес-логіки.

Чому WPF не має headless testing?WPF тісно інтегрований з Windows та DirectX. Рендеринг у WPF відбувається через DirectX, який вимагає реального GPU або software rendering через WARP. Архітектура WPF не передбачала можливості рендерингу у пам'ять без залучення графічного стеку ОС.Avalonia, будучи кросплатформним фреймворком, з самого початку проєктувався з абстракцією над платформою. Рендеринг у Avalonia відбувається через абстрактний Render Target, який може бути екраном, текстурою або буфером у пам'яті. Ця архітектурна відмінність і дозволила реалізувати headless testing.

Налаштування Avalonia.Headless

Тепер, коли ми розуміємо переваги headless testing, давайте налаштуємо його у нашому проєкті. Процес надзвичайно простий — потрібен лише один NuGet пакет.

Структура проєкту

Типова структура Avalonia-проєкту з headless-тестами виглядає так:

MyAvaloniaApp/
├── MyAvaloniaApp/              # Основний проєкт
│   ├── Views/
│   ├── ViewModels/
│   └── MyAvaloniaApp.csproj
├── MyAvaloniaApp.Tests/        # Unit-тести для ViewModels
│   └── MyAvaloniaApp.Tests.csproj
└── MyAvaloniaApp.HeadlessTests/  # Headless UI-тести
    └── MyAvaloniaApp.HeadlessTests.csproj

Рекомендується створювати окремий проєкт для headless-тестів, щоб відокремити їх від звичайних unit-тестів ViewModels. Це дозволяє запускати їх окремо та мати різні налаштування.

Встановлення NuGet пакетів

Створіть новий xUnit тестовий проєкт та встановіть необхідні пакети:

dotnet new xunit -n MyAvaloniaApp.HeadlessTests
cd MyAvaloniaApp.HeadlessTests
dotnet add package Avalonia.Headless.XUnit
dotnet add reference ../MyAvaloniaApp/MyAvaloniaApp.csproj

Пакет Avalonia.Headless.XUnit включає все необхідне:

  • Headless платформу для Avalonia
  • Інтеграцію з xUnit
  • Атрибут [AvaloniaTest] для позначення headless-тестів
  • Extension methods для симуляції взаємодій
Альтернативи для інших test frameworksХоча Avalonia.Headless.XUnit є найпопулярнішим варіантом, існують також пакети для інших test frameworks:
  • Avalonia.Headless.NUnit — для NUnit
  • Avalonia.Headless — базовий пакет без інтеграції з конкретним framework (для власних налаштувань)
У цій статті ми використовуємо xUnit, але концепції застосовні до будь-якого framework.

Базова структура headless-тесту

Headless-тест виглядає як звичайний xUnit тест, але з атрибутом [AvaloniaTest] замість [Fact]:

using Avalonia.Headless.XUnit;
using Xunit;

namespace MyAvaloniaApp.HeadlessTests;

public class MainWindowTests
{
    [AvaloniaTest]
    public void MainWindow_ShouldLoad()
    {
        // Arrange: створюємо вікно
        var window = new MainWindow();
        
        // Act: показуємо вікно (headless)
        window.Show();
        
        // Assert: перевіряємо, що вікно створилось
        Assert.NotNull(window);
        Assert.True(window.IsVisible);
    }
}

Атрибут [AvaloniaTest] робить кілька важливих речей:

  1. Ініціалізує Avalonia Application: Створює headless Avalonia application з віртуальною платформою
  2. Налаштовує Dispatcher: Забезпечує правильну роботу Avalonia Dispatcher для обробки подій
  3. Cleanup після тесту: Автоматично очищує ресурси після завершення тесту

Важливо розуміти, що window.Show() у headless-режимі не відкриває реального вікна на екрані — воно лише ініціалізує вікно, виконує layout та рендерить UI у пам'ять.

Ініціалізація Application у тестах

Іноді вашому додатку потрібна спеціальна ініціалізація Application (наприклад, реєстрація сервісів у DI-контейнері, налаштування ресурсів). Для цього можна створити базовий клас для тестів:

using Avalonia;
using Avalonia.Headless;
using Microsoft.Extensions.DependencyInjection;

namespace MyAvaloniaApp.HeadlessTests;

public class AppTestBase
{
    protected static void InitializeApp()
    {
        // Ініціалізуємо Avalonia Application один раз для всіх тестів
        if (Application.Current == null)
        {
            AppBuilder.Configure<App>()
                .UseHeadless(new AvaloniaHeadlessPlatformOptions())
                .SetupWithoutStarting();
        }
    }
    
    protected IServiceProvider Services => 
        ((App)Application.Current!).Services;
}

public class MainWindowTests : AppTestBase
{
    public MainWindowTests()
    {
        InitializeApp();
    }
    
    [AvaloniaTest]
    public void MainWindow_WithDI_ShouldResolveServices()
    {
        // Отримуємо сервіс з DI
        var userService = Services.GetRequiredService<IUserService>();
        
        // Створюємо вікно з ViewModel, який використовує сервіс
        var viewModel = new MainViewModel(userService);
        var window = new MainWindow { DataContext = viewModel };
        
        window.Show();
        
        Assert.NotNull(window.DataContext);
    }
}

Цей підхід дозволяє повторно використовувати ініціалізацію Application у всіх тестах та мати доступ до DI-контейнера.

Знаходження контролів та взаємодія

Після створення вікна нам потрібно знайти конкретні контроли та взаємодіяти з ними. Avalonia надає кілька способів знаходження контролів у дереві візуальних елементів.

Пошук контролів за Name

Найпростіший спосіб — використовувати x:Name у XAML та метод FindControl<T>():

<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="MyAvaloniaApp.Views.MainWindow">
    <StackPanel>
        <TextBox x:Name="UsernameTextBox" Watermark="Username" />
        <Button x:Name="LoginButton" Content="Login" />
        <TextBlock x:Name="StatusTextBlock" />
    </StackPanel>
</Window>
[AvaloniaTest]
public void LoginButton_Click_ShouldUpdateStatus()
{
    // Arrange
    var window = new MainWindow();
    window.Show();
    
    // Знаходимо контроли за Name
    var usernameTextBox = window.FindControl<TextBox>("UsernameTextBox");
    var loginButton = window.FindControl<Button>("LoginButton");
    var statusTextBlock = window.FindControl<TextBlock>("StatusTextBlock");
    
    Assert.NotNull(usernameTextBox);
    Assert.NotNull(loginButton);
    Assert.NotNull(statusTextBlock);
    
    // Act: вводимо username
    usernameTextBox.Text = "john_doe";
    
    // Симулюємо клік на кнопку
    loginButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
    
    // Assert: перевіряємо, що статус оновився
    Assert.Equal("Welcome, john_doe!", statusTextBlock.Text);
}

Метод FindControl<T>(string name) шукає контрол з вказаним x:Name у дереві візуальних елементів. Якщо контрол не знайдено, повертається null.

Пошук контролів через Visual Tree

Для більш складних сценаріїв можна використовувати методи обходу візуального дерева:

using Avalonia.VisualTree;

[AvaloniaTest]
public void Window_ShouldContainAllButtons()
{
    var window = new MainWindow();
    window.Show();
    
    // Знаходимо всі кнопки у вікні
    var buttons = window.GetVisualDescendants()
        .OfType<Button>()
        .ToList();
    
    Assert.Equal(3, buttons.Count);
    Assert.Contains(buttons, b => b.Content?.ToString() == "Login");
    Assert.Contains(buttons, b => b.Content?.ToString() == "Cancel");
    Assert.Contains(buttons, b => b.Content?.ToString() == "Help");
}

Extension methods для роботи з Visual Tree:

  • GetVisualChildren() — отримати прямих нащадків
  • GetVisualDescendants() — отримати всіх нащадків (рекурсивно)
  • GetVisualParent() — отримати батьківський елемент
  • GetVisualAncestors() — отримати всіх предків

Симуляція кліків

Avalonia Headless надає кілька способів симуляції кліків:

Спосіб 1: Через RoutedEvent (найпростіший)

button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));

Цей спосіб безпосередньо викликає Click event, без симуляції pointer events.

Спосіб 2: Через Command (для MVVM)

// Якщо кнопка прив'язана до Command
if (button.Command?.CanExecute(button.CommandParameter) == true)
{
    button.Command.Execute(button.CommandParameter);
}

Цей спосіб корисний, коли ви хочете протестувати саме Command, а не UI event.

Спосіб 3: Через Pointer Events (найреалістичніший)

using Avalonia.Input;

// Симулюємо повну послідовність pointer events
button.RaiseEvent(new PointerPressedEventArgs(
    button,
    new Pointer(0, PointerType.Mouse, true),
    button,
    new Point(10, 10),
    0,
    new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonPressed),
    KeyModifiers.None));

button.RaiseEvent(new PointerReleasedEventArgs(
    button,
    new Pointer(0, PointerType.Mouse, true),
    button,
    new Point(10, 10),
    0,
    new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonReleased),
    KeyModifiers.None,
    MouseButton.Left));

Цей спосіб найбільш реалістичний, але й найбільш verbose. Використовуйте його, коли потрібно протестувати складну pointer-логіку (drag-n-drop, multi-touch).

Симуляція клавіатурного вводу

Avalonia Headless надає зручні extension methods для симуляції натискання клавіш.

KeyPressQwerty — симуляція друку тексту

Метод KeyPressQwerty() симулює натискання клавіші на QWERTY клавіатурі:

[AvaloniaTest]
public void TextBox_KeyPress_ShouldUpdateText()
{
    var window = new MainWindow();
    window.Show();
    
    var textBox = window.FindControl<TextBox>("UsernameTextBox");
    textBox.Focus();
    
    // Симулюємо введення тексту по одній клавіші
    window.KeyPressQwerty(Key.H, RawInputModifiers.None);
    window.KeyPressQwerty(Key.E, RawInputModifiers.None);
    window.KeyPressQwerty(Key.L, RawInputModifiers.None);
    window.KeyPressQwerty(Key.L, RawInputModifiers.None);
    window.KeyPressQwerty(Key.O, RawInputModifiers.None);
    
    Assert.Equal("hello", textBox.Text);
}

Метод KeyPressQwerty() приймає два параметри:

  • Key key — клавіша (з enum Avalonia.Input.Key)
  • RawInputModifiers modifiers — модифікатори (Shift, Ctrl, Alt)

Модифікатори клавіш

Для симуляції комбінацій клавіш використовуйте RawInputModifiers:

[AvaloniaTest]
public void TextBox_CtrlA_ShouldSelectAll()
{
    var window = new MainWindow();
    window.Show();
    
    var textBox = window.FindControl<TextBox>("UsernameTextBox");
    textBox.Text = "Hello World";
    textBox.Focus();
    
    // Ctrl+A — виділити все
    window.KeyPressQwerty(Key.A, RawInputModifiers.Control);
    
    Assert.Equal("Hello World", textBox.SelectedText);
    Assert.Equal(0, textBox.SelectionStart);
    Assert.Equal(11, textBox.SelectionEnd);
}

[AvaloniaTest]
public void TextBox_ShiftKey_ShouldTypeUppercase()
{
    var window = new MainWindow();
    window.Show();
    
    var textBox = window.FindControl<TextBox>("UsernameTextBox");
    textBox.Focus();
    
    // Shift+H — велика літера
    window.KeyPressQwerty(Key.H, RawInputModifiers.Shift);
    
    Assert.Equal("H", textBox.Text);
}

Симуляція Enter, Tab, Escape

Спеціальні клавіші також підтримуються:

[AvaloniaTest]
public void LoginForm_EnterKey_ShouldSubmit()
{
    var window = new MainWindow();
    window.Show();
    
    var usernameTextBox = window.FindControl<TextBox>("UsernameTextBox");
    var passwordTextBox = window.FindControl<TextBox>("PasswordTextBox");
    
    usernameTextBox.Focus();
    
    // Вводимо username
    foreach (var ch in "john_doe")
    {
        window.KeyPressQwerty(CharToKey(ch), RawInputModifiers.None);
    }
    
    // Tab — перехід до наступного поля
    window.KeyPressQwerty(Key.Tab, RawInputModifiers.None);
    
    Assert.True(passwordTextBox.IsFocused);
    
    // Вводимо password
    foreach (var ch in "secret123")
    {
        window.KeyPressQwerty(CharToKey(ch), RawInputModifiers.None);
    }
    
    // Enter — submit форми
    window.KeyPressQwerty(Key.Enter, RawInputModifiers.None);
    
    // Перевіряємо, що форма відправилась
    var viewModel = (LoginViewModel)window.DataContext;
    Assert.True(viewModel.IsLoggedIn);
}

private static Key CharToKey(char ch)
{
    // Спрощена конвертація char → Key
    return ch switch
    {
        'a' or 'A' => Key.A,
        'b' or 'B' => Key.B,
        // ... інші літери
        '_' => Key.OemMinus, // Underscore (Shift+Minus)
        _ => Key.None
    };
}
Альтернатива: KeyTextInput для текстуДля введення довгих текстів існує більш зручний метод KeyTextInput():
window.KeyTextInput("Hello World");
Цей метод симулює введення цілого рядка тексту, без необхідності конвертувати кожен символ у Key. Однак він не симулює окремі KeyDown/KeyUp events, тому якщо ваша логіка залежить від цих events, використовуйте KeyPressQwerty().

Headless Screenshots — візуальна регресія

Одна з найпотужніших можливостей Avalonia Headless — це здатність робити скріншоти UI у пам'яті. Це дозволяє реалізувати Visual Regression Testing — автоматичне виявлення візуальних змін у UI.

CaptureRenderedFrame — скріншот у пам'яті

Метод CaptureRenderedFrame() рендерить поточний стан вікна у bitmap та повертає його як масив пікселів:

using Avalonia.Headless;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;

[AvaloniaTest]
public void MainWindow_Screenshot_ShouldMatchBaseline()
{
    // Arrange
    var window = new MainWindow();
    window.Show();
    
    // Act: робимо скріншот
    var screenshot = window.CaptureRenderedFrame();
    
    // Assert: перевіряємо розмір
    Assert.NotNull(screenshot);
    Assert.True(screenshot.Width > 0);
    Assert.True(screenshot.Height > 0);
    
    // Зберігаємо скріншот для візуального порівняння
    SaveScreenshot(screenshot, "MainWindow_Initial.png");
}

private void SaveScreenshot(IImage screenshot, string filename)
{
    var pixels = screenshot.GetPixels();
    using var image = Image.LoadPixelData<Bgra32>(
        pixels, 
        screenshot.Width, 
        screenshot.Height);
    
    image.SaveAsPng(filename);
}

Метод CaptureRenderedFrame() повертає об'єкт IImage, який містить:

  • Width, Height — розміри зображення
  • GetPixels() — масив пікселів у форматі BGRA32

Порівняння скріншотів

Для реальної візуальної регресії потрібно порівнювати скріншоти з baseline (еталонним зображенням):

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

[AvaloniaTest]
public void Button_Hover_ShouldChangeAppearance()
{
    var window = new MainWindow();
    window.Show();
    
    var button = window.FindControl<Button>("LoginButton");
    
    // Скріншот у нормальному стані
    var normalScreenshot = window.CaptureRenderedFrame();
    
    // Симулюємо hover
    button.RaiseEvent(new PointerEventArgs(
        InputElement.PointerEnteredEvent,
        button,
        new Pointer(0, PointerType.Mouse, true),
        button,
        new Point(10, 10),
        0,
        new PointerPointProperties(),
        KeyModifiers.None));
    
    // Скріншот у hover стані
    var hoverScreenshot = window.CaptureRenderedFrame();
    
    // Порівнюємо скріншоти
    var difference = CompareImages(normalScreenshot, hoverScreenshot);
    
    // Очікуємо, що зображення відрізняються (hover змінює вигляд)
    Assert.True(difference > 0.01, "Button appearance should change on hover");
}

private double CompareImages(IImage img1, IImage img2)
{
    if (img1.Width != img2.Width || img1.Height != img2.Height)
        return 1.0; // Повністю різні
    
    var pixels1 = img1.GetPixels();
    var pixels2 = img2.GetPixels();
    
    int differentPixels = 0;
    for (int i = 0; i < pixels1.Length; i++)
    {
        if (pixels1[i] != pixels2[i])
            differentPixels++;
    }
    
    return (double)differentPixels / pixels1.Length;
}

Snapshot Testing з Verify

Для більш зручного snapshot testing можна використовувати бібліотеку Verify:

dotnet add package Verify.Xunit
dotnet add package Verify.ImageSharp
using VerifyXunit;

[UsesVerify]
public class VisualRegressionTests
{
    [AvaloniaTest]
    public Task MainWindow_ShouldMatchSnapshot()
    {
        var window = new MainWindow();
        window.Show();
        
        var screenshot = window.CaptureRenderedFrame();
        var image = ConvertToImage(screenshot);
        
        // Verify автоматично збереже baseline при першому запуску
        // та порівнюватиме з ним при наступних запусках
        return Verifier.Verify(image);
    }
    
    private Image<Bgra32> ConvertToImage(IImage screenshot)
    {
        var pixels = screenshot.GetPixels();
        return Image.LoadPixelData<Bgra32>(
            pixels, 
            screenshot.Width, 
            screenshot.Height);
    }
}

Бібліотека Verify автоматично:

  • Зберігає baseline скріншоти при першому запуску тесту
  • Порівнює нові скріншоти з baseline при наступних запусках
  • Показує diff у зручному форматі при виявленні відмінностей
  • Дозволяє approve нові baseline через IDE або CLI
Обережно з pixel-perfect порівняннямPixel-perfect порівняння скріншотів може бути занадто чутливим — навіть мінімальні зміни у рендерингу (anti-aliasing, subpixel positioning) можуть призвести до false positives.Рекомендації:
  • Використовуйте threshold для порівняння (наприклад, 1% різниці допустимо)
  • Порівнюйте тільки критичні частини UI, а не весь екран
  • Використовуйте perceptual diff algorithms (наприклад, SSIM) замість простого порівняння пікселів
  • Зберігайте baseline скріншоти у version control для reproducibility

Тестування складних сценаріїв

Тепер, коли ми знаємо основи headless testing, розглянемо більш складні сценарії, які часто зустрічаються у реальних додатках.

Тестування Data Binding

Одна з найбільших переваг headless testing — можливість перевірити, чи правильно працює Data Binding:

[AvaloniaTest]
public void DataBinding_PropertyChange_ShouldUpdateUI()
{
    // Arrange
    var viewModel = new UserProfileViewModel
    {
        Username = "john_doe",
        Email = "john@example.com"
    };
    
    var window = new UserProfileWindow
    {
        DataContext = viewModel
    };
    window.Show();
    
    var usernameTextBlock = window.FindControl<TextBlock>("UsernameTextBlock");
    var emailTextBlock = window.FindControl<TextBlock>("EmailTextBlock");
    
    // Assert initial state
    Assert.Equal("john_doe", usernameTextBlock.Text);
    Assert.Equal("john@example.com", emailTextBlock.Text);
    
    // Act: змінюємо ViewModel
    viewModel.Username = "jane_smith";
    viewModel.Email = "jane@example.com";
    
    // Assert: UI оновився через Data Binding
    Assert.Equal("jane_smith", usernameTextBlock.Text);
    Assert.Equal("jane@example.com", emailTextBlock.Text);
}

Цей тест перевіряє не тільки ViewModel (як у звичайних unit-тестах), але й:

  • Чи правильно налаштований Data Binding у XAML
  • Чи спрацьовує PropertyChanged notification
  • Чи оновлюється UI при зміні даних

Тестування Converters

Headless testing дозволяє перевірити, чи правильно працюють Value Converters:

// XAML:
// <TextBlock Text="{Binding IsActive, Converter={StaticResource BoolToStatusConverter}}" />

[AvaloniaTest]
public void BoolToStatusConverter_ShouldDisplayCorrectText()
{
    var viewModel = new UserViewModel { IsActive = true };
    var window = new UserWindow { DataContext = viewModel };
    window.Show();
    
    var statusTextBlock = window.FindControl<TextBlock>("StatusTextBlock");
    
    // Converter повинен перетворити true → "Active"
    Assert.Equal("Active", statusTextBlock.Text);
    
    // Змінюємо значення
    viewModel.IsActive = false;
    
    // Converter повинен перетворити false → "Inactive"
    Assert.Equal("Inactive", statusTextBlock.Text);
}

Без headless testing ви б тестували Converter окремо, але не перевіряли б, чи він правильно підключений у XAML.

Тестування Validation

Перевірка валідації через INotifyDataErrorInfo:

[AvaloniaTest]
public void Validation_InvalidEmail_ShouldShowError()
{
    var viewModel = new RegistrationViewModel();
    var window = new RegistrationWindow { DataContext = viewModel };
    window.Show();
    
    var emailTextBox = window.FindControl<TextBox>("EmailTextBox");
    var errorTextBlock = window.FindControl<TextBlock>("EmailErrorTextBlock");
    
    // Вводимо невалідний email
    emailTextBox.Text = "invalid-email";
    
    // Перевіряємо, що з'явилась помилка валідації
    Assert.True(viewModel.HasErrors);
    Assert.Contains("Invalid email format", viewModel.GetErrors("Email").Cast<string>());
    
    // Перевіряємо, що помилка відображається у UI
    Assert.True(errorTextBlock.IsVisible);
    Assert.Contains("Invalid email", errorTextBlock.Text);
    
    // Виправляємо email
    emailTextBox.Text = "valid@example.com";
    
    // Помилка зникла
    Assert.False(viewModel.HasErrors);
    Assert.False(errorTextBlock.IsVisible);
}

Тестування асинхронних операцій

Headless testing чудово працює з async/await:

[AvaloniaTest]
public async Task LoadButton_Click_ShouldLoadDataAsync()
{
    // Arrange: mock сервіс
    var mockUserService = Substitute.For<IUserService>();
    mockUserService.GetUsersAsync()
        .Returns(Task.FromResult<IEnumerable<User>>(new[]
        {
            new User { Id = 1, Name = "John" },
            new User { Id = 2, Name = "Jane" }
        }));
    
    var viewModel = new UsersViewModel(mockUserService);
    var window = new UsersWindow { DataContext = viewModel };
    window.Show();
    
    var loadButton = window.FindControl<Button>("LoadButton");
    var listBox = window.FindControl<ListBox>("UsersListBox");
    var loadingIndicator = window.FindControl<ProgressBar>("LoadingIndicator");
    
    // Act: клікаємо кнопку завантаження
    loadButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
    
    // Під час завантаження показується індикатор
    Assert.True(loadingIndicator.IsVisible);
    Assert.False(loadButton.IsEnabled);
    
    // Чекаємо завершення асинхронної операції
    await Task.Delay(100); // Даємо час на виконання
    
    // Assert: дані завантажились
    Assert.False(loadingIndicator.IsVisible);
    Assert.True(loadButton.IsEnabled);
    Assert.Equal(2, listBox.ItemCount);
}
Контроль часу у headless тестахAvalonia Headless дозволяє контролювати час для тестування анімацій та таймерів:
// Пропустити час вперед без реального очікування
window.Dispatcher.RunJobs(); // Виконати всі pending jobs
Це дозволяє тестувати анімації та таймери миттєво, без реального очікування.

Тестування навігації

Якщо ваш додаток використовує навігацію між сторінками/вікнами:

[AvaloniaTest]
public void NavigateToSettings_ShouldOpenSettingsPage()
{
    var window = new MainWindow();
    window.Show();
    
    var settingsButton = window.FindControl<Button>("SettingsButton");
    
    // Клікаємо кнопку Settings
    settingsButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
    
    // Перевіряємо, що відкрилась сторінка Settings
    var contentControl = window.FindControl<ContentControl>("MainContent");
    Assert.IsType<SettingsView>(contentControl.Content);
}

Тестування ItemsControl та колекцій

Перевірка відображення колекцій у ListBox, DataGrid тощо:

[AvaloniaTest]
public void ListBox_ItemsSource_ShouldDisplayAllItems()
{
    var viewModel = new ProductsViewModel
    {
        Products = new ObservableCollection<Product>
        {
            new Product { Id = 1, Name = "Laptop", Price = 999.99m },
            new Product { Id = 2, Name = "Mouse", Price = 29.99m },
            new Product { Id = 3, Name = "Keyboard", Price = 79.99m }
        }
    };
    
    var window = new ProductsWindow { DataContext = viewModel };
    window.Show();
    
    var listBox = window.FindControl<ListBox>("ProductsListBox");
    
    // Перевіряємо кількість елементів
    Assert.Equal(3, listBox.ItemCount);
    
    // Знаходимо ListBoxItem через Visual Tree
    var listBoxItems = listBox.GetVisualDescendants()
        .OfType<ListBoxItem>()
        .ToList();
    
    Assert.Equal(3, listBoxItems.Count);
    
    // Перевіряємо вміст першого елемента
    var firstItem = listBoxItems[0];
    var nameTextBlock = firstItem.GetVisualDescendants()
        .OfType<TextBlock>()
        .FirstOrDefault(tb => tb.Name == "ProductName");
    
    Assert.Equal("Laptop", nameTextBlock?.Text);
    
    // Додаємо новий продукт
    viewModel.Products.Add(new Product { Id = 4, Name = "Monitor", Price = 299.99m });
    
    // Перевіряємо, що ListBox оновився
    Assert.Equal(4, listBox.ItemCount);
}

Практичні поради та best practices

Після розгляду технічних аспектів headless testing, давайте обговоримо практичні поради для ефективного використання цієї технології.

Організація тестів

1. Розділяйте unit-тести та headless-тести

MyApp.Tests/              # Unit-тести для ViewModels, Services
MyApp.HeadlessTests/      # Headless UI-тести

Це дозволяє:

  • Запускати їх окремо (unit-тести швидші)
  • Мати різні налаштування CI/CD
  • Чітко розділяти відповідальність

2. Використовуйте Page Object Pattern

Замість прямого доступу до контролів у кожному тесті, створіть Page Objects:

public class LoginWindowPageObject
{
    private readonly LoginWindow _window;
    
    public LoginWindowPageObject(LoginWindow window)
    {
        _window = window;
    }
    
    public TextBox UsernameTextBox => 
        _window.FindControl<TextBox>("UsernameTextBox");
    
    public TextBox PasswordTextBox => 
        _window.FindControl<TextBox>("PasswordTextBox");
    
    public Button LoginButton => 
        _window.FindControl<Button>("LoginButton");
    
    public TextBlock ErrorTextBlock => 
        _window.FindControl<TextBlock>("ErrorTextBlock");
    
    // Helper methods
    public void EnterCredentials(string username, string password)
    {
        UsernameTextBox.Text = username;
        PasswordTextBox.Text = password;
    }
    
    public void ClickLogin()
    {
        LoginButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
    }
    
    public bool IsErrorVisible => ErrorTextBlock.IsVisible;
    public string ErrorMessage => ErrorTextBlock.Text ?? string.Empty;
}

// Використання у тестах
[AvaloniaTest]
public void Login_InvalidCredentials_ShouldShowError()
{
    var window = new LoginWindow();
    window.Show();
    
    var page = new LoginWindowPageObject(window);
    
    page.EnterCredentials("invalid", "wrong");
    page.ClickLogin();
    
    Assert.True(page.IsErrorVisible);
    Assert.Contains("Invalid credentials", page.ErrorMessage);
}

Page Object Pattern робить тести:

  • Більш читабельними
  • Легшими у підтримці (зміни у UI вимагають змін тільки у Page Object)
  • Більш reusable

3. Створюйте Test Fixtures для складної ініціалізації

public class AppTestFixture : IDisposable
{
    public IServiceProvider Services { get; }
    
    public AppTestFixture()
    {
        // Ініціалізація Application
        AppBuilder.Configure<App>()
            .UseHeadless(new AvaloniaHeadlessPlatformOptions())
            .SetupWithoutStarting();
        
        Services = ((App)Application.Current!).Services;
    }
    
    public T GetService<T>() where T : notnull => 
        Services.GetRequiredService<T>();
    
    public void Dispose()
    {
        // Cleanup
    }
}

public class LoginTests : IClassFixture<AppTestFixture>
{
    private readonly AppTestFixture _fixture;
    
    public LoginTests(AppTestFixture fixture)
    {
        _fixture = fixture;
    }
    
    [AvaloniaTest]
    public void Test_WithDI()
    {
        var userService = _fixture.GetService<IUserService>();
        // ...
    }
}

Що тестувати headless, а що unit-тестами?

Headless тести (UI integration tests):

  • Data Binding працює правильно
  • Converters підключені та працюють
  • Validation відображається у UI
  • Навігація між сторінками
  • Складні UI-взаємодії (multi-selection, drag-n-drop)
  • Візуальний стан (видимість, enabled/disabled)
  • Layout (елементи на правильних позиціях)

Unit-тести (ViewModel/Service tests):

  • Бізнес-логіка
  • Commands (CanExecute, Execute)
  • Property validation
  • Async operations
  • Service interactions

Золоте правило: Якщо тест не потребує рендерингу UI — пишіть unit-тест. Якщо потрібно перевірити UI — пишіть headless-тест.

Performance considerations

Хоча headless-тести швидкі, вони все ж повільніші за unit-тести. Поради для оптимізації:

1. Мінімізуйте кількість Show() викликів

// Погано: створюємо вікно у кожному тесті
[AvaloniaTest]
public void Test1() { var w = new MainWindow(); w.Show(); /* ... */ }

[AvaloniaTest]
public void Test2() { var w = new MainWindow(); w.Show(); /* ... */ }

// Краще: використовуйте Test Fixture
public class MainWindowTests : IClassFixture<MainWindowFixture>
{
    private readonly MainWindow _window;
    
    public MainWindowTests(MainWindowFixture fixture)
    {
        _window = fixture.Window;
    }
    
    [AvaloniaTest]
    public void Test1() { /* використовуємо _window */ }
    
    [AvaloniaTest]
    public void Test2() { /* використовуємо _window */ }
}

2. Паралельне виконання

Headless-тести можна виконувати паралельно (на відміну від UI Automation):

// xUnit автоматично виконує тести з різних класів паралельно
[Collection("Sequential")] // Якщо потрібно sequential виконання
public class SequentialTests { }

3. Використовуйте mock objects для зовнішніх залежностей

// Погано: реальний HTTP запит у тесті
var service = new UserService(new HttpClient());

// Краще: mock
var mockService = Substitute.For<IUserService>();
mockService.GetUsersAsync().Returns(Task.FromResult(fakeUsers));

CI/CD інтеграція

Headless-тести чудово працюють на CI/CD серверах без додаткових налаштувань:

GitHub Actions:

name: Headless Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '8.0.x'
    
    - name: Restore dependencies
      run: dotnet restore
    
    - name: Build
      run: dotnet build --no-restore
    
    - name: Run Headless Tests
      run: dotnet test MyApp.HeadlessTests --no-build --verbosity normal

Не потрібні:

  • Віртуальний дисплей (Xvfb)
  • RDP сесія
  • Графічні драйвери
  • Спеціальні налаштування

Тести просто працюють!

Обмеження та застереження

Хоча Avalonia Headless Testing є потужною технологією, вона має деякі обмеження, про які важливо знати.

Що НЕ можна протестувати headless

1. Реальний GPU rendering

Headless рендеринг відбувається у пам'яті, без використання GPU. Якщо ваш додаток використовує складні GPU-ефекти (custom shaders, 3D graphics), headless може рендерити їх інакше або не рендерити взагалі.

2. Platform-specific behavior

Headless використовує віртуальну платформу. Деякі platform-specific речі можуть працювати інакше:

  • Native dialogs (FileOpenPicker, MessageBox)
  • System tray icons
  • Platform-specific gestures
  • Hardware acceleration

3. Реальні timing issues

Headless виконується синхронно, тому не виявить реальні race conditions, які можуть виникнути у production через асинхронність UI thread.

4. Accessibility

Headless не тестує accessibility (screen readers, keyboard navigation для людей з обмеженими можливостями). Для цього потрібні реальні accessibility tools.

Коли використовувати інші підходи

Manual testing — для:

  • Перевірки візуального дизайну (кольори, шрифти, spacing)
  • Accessibility testing
  • User experience testing
  • Platform-specific behavior

UI Automation — для:

  • Тестування реальної взаємодії з ОС
  • End-to-end тести у production-like середовищі
  • Тестування інтеграції з іншими додатками

Unit tests — для:

  • Бізнес-логіки
  • Алгоритмів
  • Сервісів без UI

Headless testing — це не silver bullet, а потужний інструмент у вашому арсеналі, який доповнює інші підходи.

Практичні завдання

Закріпимо знання практичними вправами різного рівня складності.

Рівень 1: Базовий headless тест

Завдання: Створіть простий Avalonia додаток з кнопкою та TextBlock. Напишіть headless-тест, який перевіряє, що клік на кнопку оновлює текст у TextBlock.

Вимоги:

  • Window з кнопкою "Click Me" та TextBlock
  • При кліку на кнопку TextBlock.Text змінюється на "Button was clicked!"
  • Headless-тест перевіряє цю поведінку

Підказка:

[AvaloniaTest]
public void ClickButton_ShouldUpdateTextBlock()
{
    var window = new MainWindow();
    window.Show();
    
    var button = window.FindControl<Button>("ClickButton");
    var textBlock = window.FindControl<TextBlock>("StatusTextBlock");
    
    // TODO: клікнути кнопку та перевірити текст
}

Критерії успіху:

  • Тест проходить при правильній реалізації
  • Тест падає, якщо змінити очікуваний текст
  • Тест виконується швидко (< 100ms)

Рівень 2: Форма з клавіатурним вводом

Завдання: Створіть форму реєстрації з полями Username, Email, Password та кнопкою Register. Напишіть headless-тест, який симулює заповнення форми через клавіатуру (включаючи Tab для переходу між полями) та перевіряє валідацію.

Вимоги:

  • Форма з трьома TextBox та кнопкою
  • Валідація:
    • Username: мінімум 3 символи
    • Email: повинен містити @
    • Password: мінімум 6 символів
  • При невалідних даних кнопка Register disabled
  • При валідних даних кнопка enabled

Підказка:

[AvaloniaTest]
public void RegistrationForm_ValidInput_ShouldEnableButton()
{
    var window = new RegistrationWindow();
    window.Show();
    
    var usernameTextBox = window.FindControl<TextBox>("UsernameTextBox");
    usernameTextBox.Focus();
    
    // Вводимо username через KeyPress
    window.KeyTextInput("john_doe");
    
    // Tab до наступного поля
    window.KeyPressQwerty(Key.Tab, RawInputModifiers.None);
    
    // TODO: ввести email та password, перевірити стан кнопки
}

Критерії успіху:

  • Тест перевіряє всі три поля
  • Тест використовує KeyPress для введення тексту
  • Тест перевіряє, що кнопка disabled при невалідних даних
  • Тест перевіряє, що кнопка enabled при валідних даних
  • Тест перевіряє відображення помилок валідації у UI

Рівень 3: Візуальна регресія з CaptureRenderedFrame

Завдання: Створіть компонент з анімацією (наприклад, кнопка з hover-ефектом або progress bar). Напишіть headless-тест, який робить скріншоти у різних станах та порівнює їх для виявлення візуальних змін.

Вимоги:

  • Компонент з візуальними станами (normal, hover, pressed, disabled)
  • Headless-тест робить скріншоти у кожному стані
  • Тест порівнює скріншоти з baseline
  • Тест зберігає diff-зображення при виявленні відмінностей

Підказка:

[AvaloniaTest]
public void Button_VisualStates_ShouldMatchBaseline()
{
    var window = new MainWindow();
    window.Show();
    
    var button = window.FindControl<Button>("ThemedButton");
    
    // Normal state
    var normalScreenshot = window.CaptureRenderedFrame();
    SaveAndCompare(normalScreenshot, "button_normal.png");
    
    // Hover state
    button.RaiseEvent(new PointerEventArgs(
        InputElement.PointerEnteredEvent,
        button,
        new Pointer(0, PointerType.Mouse, true),
        button,
        new Point(10, 10),
        0,
        new PointerPointProperties(),
        KeyModifiers.None));
    
    var hoverScreenshot = window.CaptureRenderedFrame();
    SaveAndCompare(hoverScreenshot, "button_hover.png");
    
    // TODO: додати pressed та disabled стани
}

private void SaveAndCompare(IImage screenshot, string filename)
{
    var baselinePath = Path.Combine("Baselines", filename);
    var currentPath = Path.Combine("Current", filename);
    
    SaveScreenshot(screenshot, currentPath);
    
    if (File.Exists(baselinePath))
    {
        var baseline = LoadScreenshot(baselinePath);
        var difference = CompareImages(screenshot, baseline);
        
        if (difference > 0.01) // 1% threshold
        {
            var diffPath = Path.Combine("Diffs", filename);
            SaveDiffImage(screenshot, baseline, diffPath);
            Assert.Fail($"Visual regression detected: {difference:P} difference");
        }
    }
    else
    {
        // Перший запуск — зберігаємо baseline
        SaveScreenshot(screenshot, baselinePath);
    }
}

Критерії успіху:

  • Тест робить скріншоти у всіх візуальних станах
  • Тест порівнює скріншоти з baseline
  • Тест виявляє візуальні зміни (спробуйте змінити колір кнопки)
  • Тест зберігає diff-зображення для аналізу
  • Baseline скріншоти зберігаються у version control

Бонус: Інтегруйте з бібліотекою Verify для автоматичного snapshot testing.


Резюме

Avalonia Headless Testing — це революційна технологія, яка змінює підхід до тестування користувацького інтерфейсу у .NET екосистемі. Основні переваги:

Швидкість: Headless-тести виконуються у 10-100 разів швидше за традиційні UI Automation тести завдяки рендерингу у пам'яті без створення реальних вікон.

Стабільність: Відсутність race conditions, timing issues та flaky tests. Всі операції виконуються синхронно та детерміновано.

Простота: Один NuGet пакет, атрибут [AvaloniaTest], і ви готові писати UI-тести. Не потрібне складне налаштування CI/CD, графічне середовище або спеціальні драйвери.

Повне покриття: Тестується не тільки ViewModel, але й Data Binding, XAML, Styles, Converters, Validation, Layout — весь UI stack.

Візуальна регресія: Вбудована можливість робити скріншоти у пам'яті через CaptureRenderedFrame() для автоматичного виявлення візуальних змін.

Кросплатформність: Тести працюють однаково на Windows, Linux, macOS без platform-specific quirks.

Headless testing не замінює інші види тестування (unit-тести, manual testing, accessibility testing), але є потужним доповненням, яке робить UI-тестування таким же зручним та надійним, як тестування бізнес-логіки. Це особливо цінно для:

  • Regression testing при рефакторингу UI
  • Перевірки Data Binding та Converters
  • Тестування складних UI-взаємодій
  • CI/CD pipelines без графічного середовища
  • Швидкого feedback loop під час розробки

Avalonia Headless Testing — це приклад того, як правильна архітектура фреймворку (абстракція над платформою, відокремлення рендерингу від ОС) дозволяє реалізувати функціональність, яка була б неможлива у монолітних фреймворках на кшталт WPF. Це одна з причин, чому Avalonia стає все популярнішим вибором для кросплатформної десктопної розробки у .NET екосистемі.

Словник ключових термінів
  • Headless Testing — тестування UI без створення реальних вікон, з рендерингом у пам'яті
  • Flaky Test — нестабільний тест, який іноді проходить, а іноді падає без змін у коді
  • Visual Regression Testing — автоматичне виявлення візуальних змін через порівняння скріншотів
  • Frame Buffer — буфер кадру, область пам'яті для зберігання зображення перед виведенням на екран
  • Render Target — ціль рендерингу (екран, текстура, пам'ять)
  • UI Automation — технологія Microsoft для автоматизації взаємодії з UI через accessibility API
  • Page Object Pattern — патерн проєктування для UI-тестів, який інкапсулює структуру сторінки
  • Snapshot Testing — тестування через порівняння поточного стану з збереженим snapshot
  • Race Condition — ситуація, коли результат залежить від порядку виконання асинхронних операцій
  • Baseline — еталонне зображення або стан для порівняння у regression testing

Додаткові ресурси

Avalonia Headless GitHub

Офіційний репозиторій Avalonia Headless з прикладами та документацією

Avalonia Headless Samples

Приклади headless-тестів для різних сценаріїв

Avalonia Testing Docs

Офіційна документація Avalonia про headless testing

Avalonia.Headless.XUnit

NuGet пакет для headless testing з xUnit

Verify Library

Бібліотека для snapshot testing, чудово працює з Avalonia Headless

xUnit Documentation

Документація xUnit test framework

Наступна стаття: Кросплатформна розробка з Avalonia — навчимося створювати додатки, що працюють на Windows, Linux, macOS, Mobile та WebAssembly.