21. Headless and testing

Goal

Why this matters

Prerequisites

1. Packages and setup

Add packages to your test project:

xUnit setup (AssemblyInfo.cs)

using Avalonia;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;

[assembly: AvaloniaTestApplication(typeof(TestApp))]

public sealed class TestApp : Application
{
    public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
        .UseHeadless(new AvaloniaHeadlessPlatformOptions
        {
            UseHeadlessDrawing = true, // set false + UseSkia for frame capture
            UseCpuDisabledRenderLoop = true
        })
        .AfterSetup(_ => Dispatcher.UIThread.VerifyAccess());
}

UseHeadlessDrawing = true skips Skia (fast). For pixel tests, set false and call .UseSkia().

NUnit setup

Use [AvaloniaTestApp] attribute (from Avalonia.Headless.NUnit) and the provided AvaloniaTestFixture base.

2. Writing a simple headless test

public class TextBoxTests
{
    [AvaloniaFact]
    public async Task TextBox_Receives_Typed_Text()
    {
        var textBox = new TextBox { Width = 200, Height = 24 };
        var window = new Window { Content = textBox };
        window.Show();

        // Focus on UI thread
        await Dispatcher.UIThread.InvokeAsync(() => textBox.Focus());

        window.KeyTextInput("Avalonia");
        AvaloniaHeadlessPlatform.ForceRenderTimerTick();

        Assert.Equal("Avalonia", textBox.Text);
    }
}

Helpers from Avalonia.Headless add extension methods to TopLevel/Window (KeyTextInput, KeyPress, MouseDown, etc.). Always call ForceRenderTimerTick() after inputs to flush layout/bindings.

3. Simulating pointer input

[ AvaloniaFact ]
public async Task Button_Click_Executes_Command()
{
    var commandExecuted = false;
    var button = new Button
    {
        Width = 100,
        Height = 30,
        Content = "Click me",
        Command = ReactiveCommand.Create(() => commandExecuted = true)
    };

    var window = new Window { Content = button };
    window.Show();

    await Dispatcher.UIThread.InvokeAsync(() => button.Focus());
    window.MouseDown(button.Bounds.Center, MouseButton.Left);
    window.MouseUp(button.Bounds.Center, MouseButton.Left);
    AvaloniaHeadlessPlatform.ForceRenderTimerTick();

    Assert.True(commandExecuted);
}

Bounds.Center obtains center point from Control.Bounds. For container-based coordinates, offset appropriately.

4. Frame capture & visual regression

Configure Skia rendering in test app builder:

public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
    .UseSkia()
    .UseHeadless(new AvaloniaHeadlessPlatformOptions
    {
        UseHeadlessDrawing = false,
        UseCpuDisabledRenderLoop = true
    });

Capture frames:

[ AvaloniaFact ]
public void Border_Renders_Correct_Size()
{
    var border = new Border
    {
        Width = 200,
        Height = 100,
        Background = Brushes.Red
    };

    var window = new Window { Content = border };
    window.Show();
    AvaloniaHeadlessPlatform.ForceRenderTimerTick();

    using var frame = window.GetLastRenderedFrame();
    Assert.Equal(200, frame.Size.Width);
    Assert.Equal(100, frame.Size.Height);

    // Optional: save to disk for debugging
    // frame.Save("border.png");
}

Compare pixels to baseline image using e.g., ImageMagick or custom diff with tolerance. Keep baselines per theme/resolution to avoid false positives.

If you need Avalonia to drive the render loop before reading pixels, call CaptureRenderedFrame() instead of GetLastRenderedFrame()—it schedules a composition pass and forces a render tick. This mirrors what desktop renderers do when they flush the CompositionTarget, keeping the snapshot pipeline close to production.

5. Organizing tests

6. Custom fixtures and automation hooks

7. Advanced headless scenarios

7.1 VNC mode

For debugging, you can run headless with a VNC server and observe the UI.

AppBuilder.Configure<App>()
    .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseVnc = true, UseSkia = true })
    .StartWithClassicDesktopLifetime(args);

Connect with a VNC client to view frames and interact.

7.2 Simulating time & timers

Use AvaloniaHeadlessPlatform.ForceRenderTimerTick() to advance timers. For DispatcherTimer or animations, call it repeatedly.

7.3 File system in tests

For file-based assertions, use in-memory streams or temp directories. Avoid writing to the repo path; tests should be self-cleaning.

8. Testing async flows

async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
    var deadline = DateTime.UtcNow + timeout;
    while (!condition())
    {
        if (DateTime.UtcNow > deadline)
            throw new TimeoutException("Condition not met");
        AvaloniaHeadlessPlatform.ForceRenderTimerTick();
        await Task.Delay(10);
    }
}

9. CI integration

10. Practice exercises

  1. Write a headless test that types into a TextBox, presses Enter, and asserts a command executed.
  2. Simulate a drag-and-drop using DragDrop helpers and confirm target list received data.
  3. Capture a frame of an entire form and compare to a baseline image stored under tests/BaselineImages.
  4. Create a test fixture that launches the app's main view, navigates to a secondary page, and verifies a label text.
  5. Add headless tests to CI and configure the pipeline to upload snapshot diffs for failing cases.
  6. Write an automation-focused test that inspects AutomationPeer patterns (Invoke/Value) to validate accessibility contracts alongside visual assertions.

Look under the hood (source bookmarks)

Check yourself

What's next