Testing Architecture
Avalonia applications can be tested at multiple levels:- Unit Tests - Test ViewModels and business logic
- Integration Tests - Test services and data layers
- UI Tests - Test visual components and interactions
- Headless Tests - Test rendering without a window
- Accessibility Tests - Verify automation properties
Unit Testing ViewModels
Setting Up Tests
# Install testing packages
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package FluentAssertions
dotnet add package NSubstitute
Basic ViewModel Tests
using Xunit;
using FluentAssertions;
using NSubstitute;
public class MainViewModelTests
{
[Fact]
public void Constructor_InitializesProperties()
{
// Arrange & Act
var viewModel = new MainViewModel();
// Assert
viewModel.Title.Should().Be("Main Window");
viewModel.Items.Should().NotBeNull();
viewModel.Items.Should().BeEmpty();
}
[Fact]
public void AddItem_AddsToCollection()
{
// Arrange
var viewModel = new MainViewModel();
var item = new Item { Name = "Test" };
// Act
viewModel.AddItemCommand.Execute(item);
// Assert
viewModel.Items.Should().Contain(item);
viewModel.Items.Should().HaveCount(1);
}
[Fact]
public void RemoveItem_RemovesFromCollection()
{
// Arrange
var viewModel = new MainViewModel();
var item = new Item { Name = "Test" };
viewModel.Items.Add(item);
// Act
viewModel.RemoveItemCommand.Execute(item);
// Assert
viewModel.Items.Should().NotContain(item);
viewModel.Items.Should().BeEmpty();
}
}
Testing ReactiveUI ViewModels
using ReactiveUI;
using ReactiveUI.Testing;
using Microsoft.Reactive.Testing;
public class ReactiveViewModelTests
{
[Fact]
public void PropertyChanged_RaisesNotification()
{
// Arrange
var viewModel = new MainViewModel();
var propertyChanged = false;
viewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(MainViewModel.Title))
propertyChanged = true;
};
// Act
viewModel.Title = "New Title";
// Assert
propertyChanged.Should().BeTrue();
}
[Fact]
public void Command_CanExecute_UpdatesCorrectly()
{
// Arrange
var viewModel = new MainViewModel();
var canExecuteChanged = false;
viewModel.SubmitCommand.CanExecute
.Subscribe(canExecute => canExecuteChanged = true);
// Act
viewModel.IsValid = true;
// Assert
canExecuteChanged.Should().BeTrue();
viewModel.SubmitCommand.CanExecute(null).Should().BeTrue();
}
[Fact]
public void Search_Debounces_Correctly()
{
new TestScheduler().With(scheduler =>
{
// Arrange
var viewModel = new SearchViewModel(scheduler);
var searchExecuted = 0;
viewModel.SearchCommand
.Subscribe(_ => searchExecuted++);
// Act
viewModel.SearchText = "a";
scheduler.AdvanceByMs(100);
viewModel.SearchText = "ab";
scheduler.AdvanceByMs(100);
viewModel.SearchText = "abc";
scheduler.AdvanceByMs(500);
// Assert
searchExecuted.Should().Be(1); // Only executed once after debounce
});
}
}
Mocking Dependencies
public class ViewModelWithDependenciesTests
{
[Fact]
public async Task LoadData_CallsService()
{
// Arrange
var mockService = Substitute.For<IDataService>();
mockService.GetDataAsync()
.Returns(Task.FromResult(new[] { new Item(), new Item() }));
var viewModel = new MainViewModel(mockService);
// Act
await viewModel.LoadDataCommand.ExecuteAsync();
// Assert
await mockService.Received(1).GetDataAsync();
viewModel.Items.Should().HaveCount(2);
}
[Fact]
public async Task LoadData_HandlesError()
{
// Arrange
var mockService = Substitute.For<IDataService>();
mockService.GetDataAsync()
.Returns(Task.FromException<Item[]>(new Exception("Network error")));
var viewModel = new MainViewModel(mockService);
// Act
await viewModel.LoadDataCommand.ExecuteAsync();
// Assert
viewModel.ErrorMessage.Should().Contain("Network error");
viewModel.Items.Should().BeEmpty();
}
}
Testing Controls and Views
Headless Testing Setup
using Avalonia;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
[AvaloniaTestApplication(typeof(App))]
public class ControlTests
{
[AvaloniaFact]
public void Button_Click_RaisesEvent()
{
// Arrange
var button = new Button { Content = "Click Me" };
var clicked = false;
button.Click += (s, e) => clicked = true;
// Act
button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
// Assert
Assert.True(clicked);
}
[AvaloniaFact]
public void TextBox_Binding_UpdatesProperty()
{
// Arrange
var viewModel = new MainViewModel();
var textBox = new TextBox
{
[!TextBox.TextProperty] = new Binding(nameof(MainViewModel.UserName))
};
textBox.DataContext = viewModel;
// Act
textBox.Text = "John Doe";
// Assert
viewModel.UserName.Should().Be("John Doe");
}
}
Testing Layout
[AvaloniaFact]
public void Control_Measures_Correctly()
{
// Arrange
var control = new MyCustomControl
{
Width = 200,
Height = 100
};
// Act
control.Measure(Size.Infinity);
var desiredSize = control.DesiredSize;
// Assert
desiredSize.Width.Should().Be(200);
desiredSize.Height.Should().Be(100);
}
[AvaloniaFact]
public void Control_Arranges_Children()
{
// Arrange
var panel = new StackPanel();
var child1 = new Border { Height = 50 };
var child2 = new Border { Height = 75 };
panel.Children.Add(child1);
panel.Children.Add(child2);
// Act
panel.Measure(new Size(200, 200));
panel.Arrange(new Rect(0, 0, 200, 200));
// Assert
child1.Bounds.Should().Be(new Rect(0, 0, 200, 50));
child2.Bounds.Should().Be(new Rect(0, 50, 200, 75));
}
Testing Custom Controls
public class CustomControlTests
{
[AvaloniaFact]
public void CustomControl_InitializesCorrectly()
{
// Arrange & Act
var control = new CustomControl
{
Title = "Test",
Value = 42
};
// Assert
control.Title.Should().Be("Test");
control.Value.Should().Be(42);
}
[AvaloniaFact]
public void CustomControl_PropertyChanged_UpdatesUI()
{
// Arrange
var control = new CustomControl();
var root = new TestRoot { Child = control };
// Act
control.Value = 100;
root.LayoutManager.ExecuteLayoutPass();
// Assert
var textBlock = control.FindControl<TextBlock>("ValueDisplay");
textBlock?.Text.Should().Be("100");
}
[AvaloniaFact]
public void CustomControl_Renders_WithoutErrors()
{
// Arrange
var control = new CustomControl { Width = 200, Height = 100 };
var root = new TestRoot { Child = control };
// Act
Exception? renderException = null;
try
{
root.LayoutManager.ExecuteLayoutPass();
control.InvalidateVisual();
}
catch (Exception ex)
{
renderException = ex;
}
// Assert
renderException.Should().BeNull();
}
}
Testing Styles and Themes
[AvaloniaFact]
public void Style_Applies_ToControl()
{
// Arrange
var style = new Style(x => x.OfType<Button>())
{
Setters =
{
new Setter(Button.BackgroundProperty, Brushes.Red)
}
};
var button = new Button();
var root = new TestRoot
{
Styles = { style },
Child = button
};
// Act
root.LayoutManager.ExecuteLayoutPass();
// Assert
button.Background.Should().Be(Brushes.Red);
}
[AvaloniaFact]
public void PseudoClass_Applies_Correctly()
{
// Arrange
var style = new Style(x => x.OfType<Button>().Class(":pointerover"))
{
Setters =
{
new Setter(Button.BackgroundProperty, Brushes.Blue)
}
};
var button = new Button { Background = Brushes.Red };
var root = new TestRoot
{
Styles = { style },
Child = button
};
// Act
button.PseudoClasses.Add(":pointerover");
root.LayoutManager.ExecuteLayoutPass();
// Assert
button.Background.Should().Be(Brushes.Blue);
}
Testing Data Binding
[AvaloniaFact]
public void Binding_Updates_WhenPropertyChanges()
{
// Arrange
var viewModel = new TestViewModel();
var textBlock = new TextBlock
{
[!TextBlock.TextProperty] = new Binding(nameof(TestViewModel.Message))
};
textBlock.DataContext = viewModel;
var root = new TestRoot { Child = textBlock };
root.LayoutManager.ExecuteLayoutPass();
// Act
viewModel.Message = "Updated";
root.LayoutManager.ExecuteLayoutPass();
// Assert
textBlock.Text.Should().Be("Updated");
}
[AvaloniaFact]
public void TwoWayBinding_UpdatesViewModel()
{
// Arrange
var viewModel = new TestViewModel { Message = "Initial" };
var textBox = new TextBox
{
[!TextBox.TextProperty] = new Binding(
nameof(TestViewModel.Message),
BindingMode.TwoWay)
};
textBox.DataContext = viewModel;
var root = new TestRoot { Child = textBox };
root.LayoutManager.ExecuteLayoutPass();
// Act
textBox.Text = "Changed";
// Assert
viewModel.Message.Should().Be("Changed");
}
Testing Accessibility
using Avalonia.Automation;
using Avalonia.Automation.Peers;
[AvaloniaFact]
public void Control_Has_AccessibleName()
{
// Arrange
var button = new Button { Content = "Submit" };
AutomationProperties.SetName(button, "Submit Form");
// Act
var name = AutomationProperties.GetName(button);
// Assert
name.Should().Be("Submit Form");
}
[AvaloniaFact]
public void Control_Has_AutomationPeer()
{
// Arrange
var button = new Button();
var root = new TestRoot { Child = button };
// Act
var peer = button.GetValue(AutomationProperties.NameProperty);
// Assert
peer.Should().NotBeNull();
}
[AvaloniaFact]
public void TextBox_Is_Labeled()
{
// Arrange
var label = new TextBlock { Text = "Name:" };
var textBox = new TextBox();
AutomationProperties.SetLabeledBy(textBox, label);
// Act
var labeledBy = AutomationProperties.GetLabeledBy(textBox);
// Assert
labeledBy.Should().Be(label);
}
Integration Testing
Testing with Real Window
[AvaloniaFact]
public async Task Window_Opens_AndCloses()
{
// Arrange
var window = new MainWindow();
// Act
window.Show();
await Task.Delay(100); // Wait for window to render
window.Close();
// Assert
window.IsVisible.Should().BeFalse();
}
[AvaloniaFact]
public async Task Dialog_Returns_Result()
{
// Arrange
var parentWindow = new Window();
var dialog = new MyDialog();
// Act
var resultTask = dialog.ShowDialog<bool>(parentWindow);
await Task.Delay(50);
dialog.Close(true);
var result = await resultTask;
// Assert
result.Should().BeTrue();
}
Testing Navigation
[AvaloniaFact]
public void Navigation_Changes_Content()
{
// Arrange
var navigationService = new NavigationService();
var frame = new ContentControl();
navigationService.Initialize(frame);
// Act
navigationService.NavigateTo<HomeViewModel>();
// Assert
frame.Content.Should().BeOfType<HomeView>();
}
Visual Regression Testing
Screenshot Testing
using Avalonia.Media.Imaging;
[AvaloniaFact]
public void Control_Renders_Correctly()
{
// Arrange
var control = new MyControl { Width = 400, Height = 300 };
var root = new TestRoot { Child = control };
root.LayoutManager.ExecuteLayoutPass();
// Act
var bitmap = new RenderTargetBitmap(
new PixelSize(400, 300),
new Vector(96, 96));
using (var context = bitmap.CreateDrawingContext(null))
{
control.Render(context);
}
// Save for manual verification or comparison
bitmap.Save("test-output.png");
// Assert
// Compare with baseline image
var baseline = new Bitmap("baseline.png");
CompareImages(bitmap, baseline).Should().BeTrue();
}
private bool CompareImages(Bitmap actual, Bitmap expected)
{
// Implement pixel-by-pixel comparison
// or use a library like ImageSharp for fuzzy matching
return true;
}
Performance Testing
using System.Diagnostics;
[AvaloniaFact]
public void VirtualizedList_Performs_Well()
{
// Arrange
var items = Enumerable.Range(0, 10000)
.Select(i => new Item { Name = $"Item {i}" })
.ToList();
var listBox = new ListBox { ItemsSource = items };
var root = new TestRoot { Child = listBox };
// Act
var sw = Stopwatch.StartNew();
root.LayoutManager.ExecuteLayoutPass();
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(100);
}
[AvaloniaFact]
public void Layout_Completes_Quickly()
{
// Arrange
var grid = new Grid
{
RowDefinitions = RowDefinitions.Parse("*,*,*,*,*"),
ColumnDefinitions = ColumnDefinitions.Parse("*,*,*,*,*")
};
for (int row = 0; row < 5; row++)
{
for (int col = 0; col < 5; col++)
{
var button = new Button { Content = $"{row},{col}" };
Grid.SetRow(button, row);
Grid.SetColumn(button, col);
grid.Children.Add(button);
}
}
var root = new TestRoot { Child = grid };
// Act
var sw = Stopwatch.StartNew();
grid.Measure(new Size(500, 500));
grid.Arrange(new Rect(0, 0, 500, 500));
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(50);
}
Test Utilities
TestRoot Helper
public class TestRoot : Decorator, IStyleHost, ILayoutRoot
{
public Size ClientSize { get; set; } = new Size(1000, 1000);
public double LayoutScaling => 1;
public ILayoutManager LayoutManager { get; } = new LayoutManager();
public double RenderScaling => 1;
public TestRoot()
{
LayoutManager.Root = this;
}
protected override Size MeasureOverride(Size availableSize)
{
return ClientSize;
}
IStyleHost IStyleHost.StylingParent => null;
}
Custom Assertions
public static class AvaloniaAssertions
{
public static void ShouldBeVisible(this Control control)
{
control.IsVisible.Should().BeTrue();
control.Opacity.Should().BeGreaterThan(0);
}
public static void ShouldBeEnabled(this Control control)
{
control.IsEnabled.Should().BeTrue();
}
public static void ShouldContainControl<T>(this Panel panel)
where T : Control
{
panel.Children.OfType<T>().Should().NotBeEmpty();
}
}
CI/CD Integration
GitHub Actions Example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal --logger trx
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: '**/*.trx'
Best Practices
Testing recommendations:✓ Test ViewModels independently of Views
✓ Use headless testing for fast feedback
✓ Mock external dependencies
✓ Test both happy paths and error cases
✓ Verify accessibility properties
✓ Use meaningful test names
✓ Keep tests isolated and independent
✓ Test layout behavior with different sizes
✓ Use headless testing for fast feedback
✓ Mock external dependencies
✓ Test both happy paths and error cases
✓ Verify accessibility properties
✓ Use meaningful test names
✓ Keep tests isolated and independent
✓ Test layout behavior with different sizes
Avoid these testing pitfalls:✗ Testing implementation details
✗ Tight coupling between tests
✗ Not testing edge cases
✗ Ignoring async operations
✗ Skipping cleanup/disposal
✗ Testing too much in one test
✗ Not using proper test isolation
✗ Tight coupling between tests
✗ Not testing edge cases
✗ Ignoring async operations
✗ Skipping cleanup/disposal
✗ Testing too much in one test
✗ Not using proper test isolation
See Also
- Accessibility - Testing accessibility features
- Performance - Performance testing strategies