Skip to main content
Testing Avalonia applications ensures reliability and maintainability. This guide covers testing strategies from unit tests to full UI automation.

Testing Architecture

Avalonia applications can be tested at multiple levels:
  1. Unit Tests - Test ViewModels and business logic
  2. Integration Tests - Test services and data layers
  3. UI Tests - Test visual components and interactions
  4. Headless Tests - Test rendering without a window
  5. 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
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

See Also

Build docs developers (and LLMs) love