Тестування користувацького інтерфейсу традиційно вважається однією з найскладніших задач у розробці десктопних додатків. UI-тести часто бувають повільними, нестабільними (flaky), вимагають складного налаштування та не можуть виконуватися на серверах без графічного середовища. Avalonia пропонує революційне рішення цієї проблеми — Headless Testing, технологію, яка дозволяє рендерити повноцінний користувацький інтерфейс у пам'яті, без створення реальних вікон, без залежності від GPU та без потреби у графічному середовищі операційної системи.
Уявіть собі можливість написати unit-тест, який створює вікно вашого додатку, знаходить кнопку, клікає на неї, вводить текст у текстове поле, перевіряє зміну стану ViewModel та навіть робить скріншот результату — і все це виконується за мілісекунди, без відкриття жодного реального вікна на екрані. Саме це і пропонує Avalonia Headless Testing. Ця технологія змінює правила гри у тестуванні UI, роблячи його таким же швидким, надійним та зручним, як тестування звичайної бізнес-логіки.
У цій статті ми детально розглянемо, як працює Avalonia Headless Testing, чому ця технологія є унікальною у світі .NET десктопної розробки, як налаштувати headless-тести у вашому проєкті, як симулювати користувацькі взаємодії (кліки, натискання клавіш, введення тексту), як використовувати headless-скріншоти для візуальної регресії, та порівняємо цей підхід з традиційними методами UI-тестування у WPF.
Перш ніж заглибитися у технічні деталі, важливо зрозуміти, чому Avalonia Headless Testing є революційною технологією та чим він відрізняється від традиційних підходів до UI-тестування у .NET екосистемі.
У світі WPF та інших UI-фреймворків існує кілька підходів до тестування користувацького інтерфейсу, кожен з яких має свої обмеження:
1. UI Automation (Microsoft Accessibility API)
WPF підтримує UI Automation — технологію Microsoft, яка дозволяє програмно взаємодіяти з елементами інтерфейсу через accessibility API. Цей підхід використовується інструментами на кшталт Coded UI Tests, TestStack.White, FlaUI та іншими.
Проблеми UI Automation:
2. Тестування через ViewModel (без UI)
Багато розробників обирають підхід "тестуємо тільки ViewModel, UI не чіпаємо". Цей підхід ми розглянули у попередній статті — він працює чудово для бізнес-логіки, але має обмеження:
3. Manual Testing
Найпоширеніший підхід — ручне тестування. Розробник або QA-інженер відкриває додаток та перевіряє функціональність вручну. Це працює, але:
Avalonia Headless Testing вирішує всі ці проблеми радикально іншим підходом: рендеринг UI відбувається повністю у пам'яті, без створення реальних вікон та без залежності від GPU.
Як це працює технічно:
Переваги цього підходу:
Давайте порівняємо Avalonia Headless Testing з традиційним WPF UI Automation підходом:
| Характеристика | Avalonia Headless | WPF 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-тестування таким же зручним та надійним, як тестування звичайної бізнес-логіки.
Тепер, коли ми розуміємо переваги 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. Це дозволяє запускати їх окремо та мати різні налаштування.
Створіть новий 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 включає все необхідне:
[AvaloniaTest] для позначення headless-тестівAvalonia.Headless.XUnit є найпопулярнішим варіантом, існують також пакети для інших test frameworks:Avalonia.Headless.NUnit — для NUnitAvalonia.Headless — базовий пакет без інтеграції з конкретним framework (для власних налаштувань)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] робить кілька важливих речей:
Важливо розуміти, що window.Show() у headless-режимі не відкриває реального вікна на екрані — воно лише ініціалізує вікно, виконує layout та рендерить UI у пам'ять.
Іноді вашому додатку потрібна спеціальна ініціалізація 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 надає кілька способів знаходження контролів у дереві візуальних елементів.
Найпростіший спосіб — використовувати 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.
Для більш складних сценаріїв можна використовувати методи обходу візуального дерева:
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() симулює натискання клавіші на 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);
}
Спеціальні клавіші також підтримуються:
[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():window.KeyTextInput("Hello World");
KeyPressQwerty().Одна з найпотужніших можливостей Avalonia Headless — це здатність робити скріншоти UI у пам'яті. Це дозволяє реалізувати Visual Regression Testing — автоматичне виявлення візуальних змін у UI.
Метод 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:
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 автоматично:
Тепер, коли ми знаємо основи headless testing, розглянемо більш складні сценарії, які часто зустрічаються у реальних додатках.
Одна з найбільших переваг 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-тестах), але й:
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.
Перевірка валідації через 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);
}
// Пропустити час вперед без реального очікування
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);
}
Перевірка відображення колекцій у 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);
}
Після розгляду технічних аспектів headless testing, давайте обговоримо практичні поради для ефективного використання цієї технології.
1. Розділяйте unit-тести та headless-тести
MyApp.Tests/ # Unit-тести для ViewModels, Services
MyApp.HeadlessTests/ # Headless UI-тести
Це дозволяє:
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 робить тести:
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 тести (UI integration tests):
Unit-тести (ViewModel/Service tests):
Золоте правило: Якщо тест не потребує рендерингу UI — пишіть unit-тест. Якщо потрібно перевірити UI — пишіть headless-тест.
Хоча 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));
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
Не потрібні:
Тести просто працюють!
Хоча Avalonia Headless Testing є потужною технологією, вона має деякі обмеження, про які важливо знати.
1. Реальний GPU rendering
Headless рендеринг відбувається у пам'яті, без використання GPU. Якщо ваш додаток використовує складні GPU-ефекти (custom shaders, 3D graphics), headless може рендерити їх інакше або не рендерити взагалі.
2. Platform-specific behavior
Headless використовує віртуальну платформу. Деякі platform-specific речі можуть працювати інакше:
3. Реальні timing issues
Headless виконується синхронно, тому не виявить реальні race conditions, які можуть виникнути у production через асинхронність UI thread.
4. Accessibility
Headless не тестує accessibility (screen readers, keyboard navigation для людей з обмеженими можливостями). Для цього потрібні реальні accessibility tools.
Manual testing — для:
UI Automation — для:
Unit tests — для:
Headless testing — це не silver bullet, а потужний інструмент у вашому арсеналі, який доповнює інші підходи.
Закріпимо знання практичними вправами різного рівня складності.
Завдання: Створіть простий Avalonia додаток з кнопкою та TextBlock. Напишіть headless-тест, який перевіряє, що клік на кнопку оновлює текст у TextBlock.
Вимоги:
Підказка:
[AvaloniaTest]
public void ClickButton_ShouldUpdateTextBlock()
{
var window = new MainWindow();
window.Show();
var button = window.FindControl<Button>("ClickButton");
var textBlock = window.FindControl<TextBlock>("StatusTextBlock");
// TODO: клікнути кнопку та перевірити текст
}
Критерії успіху:
Завдання: Створіть форму реєстрації з полями Username, Email, Password та кнопкою Register. Напишіть headless-тест, який симулює заповнення форми через клавіатуру (включаючи Tab для переходу між полями) та перевіряє валідацію.
Вимоги:
Підказка:
[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, перевірити стан кнопки
}
Критерії успіху:
Завдання: Створіть компонент з анімацією (наприклад, кнопка з hover-ефектом або progress bar). Напишіть headless-тест, який робить скріншоти у різних станах та порівнює їх для виявлення візуальних змін.
Вимоги:
Підказка:
[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);
}
}
Критерії успіху:
Бонус: Інтегруйте з бібліотекою 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-тестування таким же зручним та надійним, як тестування бізнес-логіки. Це особливо цінно для:
Avalonia Headless Testing — це приклад того, як правильна архітектура фреймворку (абстракція над платформою, відокремлення рендерингу від ОС) дозволяє реалізувати функціональність, яка була б неможлива у монолітних фреймворках на кшталт WPF. Це одна з причин, чому Avalonia стає все популярнішим вибором для кросплатформної десктопної розробки у .NET екосистемі.
Наступна стаття: Кросплатформна розробка з Avalonia — навчимося створювати додатки, що працюють на Windows, Linux, macOS, Mobile та WebAssembly.
Тестування ViewModels
Unit-тести для MVVM. xUnit, NSubstitute для mock-об'єктів. Тестування Properties, Commands, Validation, Messenger. Arrange-Act-Assert pattern.
Кросплатформна розробка з Avalonia
Створення додатків, що працюють на Windows, Linux, macOS, Mobile та WebAssembly: структура проєкту, platform-specific код, native API та deployment