Avalonia-Headless-UI测试实战

Avalonia Headless 实战:不开显示器,也能测点击、打字和快捷键

用 Avalonia 的 headless 平台在 CI 里跑真实窗口:真实控件树、真实布局、真实输入管线,只把"屏幕"和"鼠标键盘"换成内存实现。本文从最小工程讲到键鼠输入细节,附可直接照抄的例子与踩坑清单。

一、Headless 到底"无"了什么

它并没有"模拟 UI",只是换掉了三个平台后端:

真实平台 Headless 平台
窗口系统(Win32/Cocoa/X11) 内存里的窗口实现,Show() 不创建原生窗口
渲染后端(D3D/Metal/GL) 默认一个不绘制的桩;需要像素时切 Skia 渲染到位图
输入源(真实鼠标键盘) 扩展方法把 RawKeyEventArgs / RawPointerEventArgs 直接注入输入管线

其余都是真的:Application、样式与资源、视觉/逻辑树、布局、绑定、Dispatcher、命令、路由事件、焦点、ItemsControl 虚拟化------和桌面端同一套代码。

能测 :按 Ctrl+K 命令面板是否真的打开;面板里输入 "server" 回车是否执行了高亮项;双击表格单元格是否进入编辑;拖列头是否真的变宽;切标签后网格是否重新绑定;换主题后资源是否解析正确;画面与基线截图是否一致。

测不了:原生窗口管理(多屏、系统 DPI 缩放)、IME 候选词、托盘、原生文件对话框、GPU 渲染差异、性能基准(headless 绘制是桩,计时无意义)。

判断标准一句话:这行代码是否依赖操作系统 。依赖的抽成接口(IFilePicker、IDialogService)注入假实现;不依赖的全部可以 headless 测。

二、最小工程(xUnit)

1. 测试项目

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <!-- 被测 App 打开了 trim/AOT 分析器时要关掉:
         Avalonia.Headless 没做 AOT 标注,测试宿主也从不 AOT 发布 -->
    <IsAotCompatible>false</IsAotCompatible>
    <EnableTrimAnalyzer>false</EnableTrimAnalyzer>
    <EnableAotAnalyzer>false</EnableAotAnalyzer>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.0.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <!-- 版本与 Avalonia 主包一致;Avalonia 11.x 与 12.x 均可 -->
    <PackageReference Include="Avalonia.Headless.XUnit" Version="11.2.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\MyApp\MyApp.csproj" />
  </ItemGroup>
</Project>

2. 告诉 session 用哪个 AppBuilder

必须是程序集级 ,否则 headless 会建一个空 Application,你的样式、字体、资源全部不在,所有窗口都会因为"找不到资源"而以错误的理由失败:

csharp 复制代码
using Avalonia;
using Avalonia.Headless;

[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]

public static class TestAppBuilder
{
    // 只配置,不启动:不要在这里 StartWithClassicDesktopLifetime
    public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
        .UseHeadless(new AvaloniaHeadlessPlatformOptions());
}

AvaloniaHeadlessPlatformOptions.UseHeadlessDrawing 默认 true(不真正绘制,最快)。要做截图测试时改成 false 并加 .UseSkia(),见第七节。

3. 第一个测试

csharp 复制代码
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Xunit;

public class LoginWindowTests
{
    [AvaloniaFact]   // 关键:在 UI 线程上跑,并保证 headless 已初始化
    public void 空密码提交时显示错误提示()
    {
        var window = new LoginWindow { DataContext = new LoginViewModel() };
        window.Show();
        Settle();

        var userBox = window.FindControl<TextBox>("UserBox")!;
        userBox.Focus();
        Settle();
        window.KeyTextInput("alice");
        Settle();

        Click(window, window.FindControl<Button>("SubmitButton")!);

        Assert.True(window.FindControl<TextBlock>("ErrorText")!.IsVisible);
    }
}

4. 两个必备小工具

csharp 复制代码
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;

public static class Headless
{
    /// <summary>排空 Dispatcher 队列,并强制一个渲染帧。</summary>
    public static void Settle(int passes = 2)
    {
        for (var i = 0; i < passes; i++)
        {
            Dispatcher.UIThread.RunJobs();
            AvaloniaHeadlessPlatform.ForceRenderTimerTick();
            Dispatcher.UIThread.RunJobs();
        }
    }

    /// <summary>点击控件中心(窗口坐标系)。</summary>
    public static void Click(TopLevel window, Control control, MouseButton button = MouseButton.Left)
    {
        var point = control.TranslatePoint(
            new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value;

        window.MouseDown(point, button, RawInputModifiers.None);
        window.MouseUp(point, button, RawInputModifiers.None);
        Settle();
    }
}

三、非 xUnit 框架:自己管一个 session

官方只提供 Avalonia.Headless.XUnit 与 Avalonia.Headless.NUnit 两套 attribute。用 TUnit、MSTest 或自研 runner 时,直接持有 HeadlessUnitTestSession,一个进程一个:

csharp 复制代码
public static class Ui
{
    private static readonly Lazy<HeadlessUnitTestSession> Holder =
        new(() => HeadlessUnitTestSession.GetOrStartForAssembly(typeof(Ui).Assembly));

    /// <summary>在 UI 线程上跑测试体,并等它真正跑完。</summary>
    public static Task Run(Func<Task> body) =>
        Holder.Value.Dispatch<object?>(
            async () =>
            {
                await body();
                return null;
            },
            CancellationToken.None);

    [After(TestSession)]   // TUnit 钩子;xUnit 用 IDisposable fixture,NUnit 用 OneTimeTearDown
    public static void Shutdown()
    {
        if (Holder.IsValueCreated)
        {
            Holder.Value.Dispose();
        }
    }
}

会让你"全绿假通过"的坑

Dispatch 有一个 Dispatch<T>(Func<T>) 重载。把 async () => { ... } 直接传进去,编译器推断 T = Task,你拿到的是 Task<Task>:外层任务在 body 返回它的 Task 那一刻 就完成了,dispatcher 停止泵消息,第一个 await 之后的所有断言都落在没人观察的任务上。

结果:测试通过,但根本没跑。症状是"删掉实现代码也照样绿"。

csharp 复制代码
// 错误:能编译,能"通过",什么都没测
await Session.Dispatch(async () => { await SomethingAsync(); Assert.True(false); });

// 正确:外面等的是内层任务
await Session.Dispatch<object?>(
    async () => { await SomethingAsync(); Assert.True(false); return null; },
    CancellationToken.None);

唯一可靠的防法:只暴露 Func<Task> 一个重载,内部转成 Dispatch<object?>,让错误不可表达。

顺手做个自检

写完 UI 测试,故意把某条断言改错,确认它真的红。headless 测试最常见的失败模式不是"红不了",而是"永远绿"。这比任何覆盖率数字都值。

四、为什么每一步都要 Settle

KeyPress 只是把事件塞进输入管线,后面整条链是异步的:

复制代码
输入事件 → 路由到焦点元素 → 命令/事件处理 → 改 ViewModel
        → 绑定更新 → InvalidateMeasure → 布局排队 → 渲染排队

Show()、Focus()、属性赋值、集合变更、Execute() 都会往 Dispatcher 队列投递任务,所以"发完输入立刻断言"读到的多半是旧状态。

Settle 干三件事:RunJobs() 排空队列,ForceRenderTimerTick() 驱动渲染定时器(动画、DispatcherTimer、光标闪烁都挂在上面),再 RunJobs() 一遍排掉这一帧产生的后续任务。注意:不 Show() 就没有视觉树,输入无处路由。

等异步操作,别用 Thread.Sleep

csharp 复制代码
/// <summary>按"泵的次数"等条件成立,适合纯 UI 链条。</summary>
public static bool SettleUntil(Func<bool> condition, int maxPasses = 200)
{
    for (var i = 0; i < maxPasses; i++)
    {
        if (condition()) return true;
        Settle(passes: 1);
    }
    return condition();
}

/// <summary>按"时间"等条件成立,适合真正跨线程的异步命令。</summary>
public static async Task<bool> WaitUntilAsync(Func<bool> condition, int seconds = 30)
{
    var deadline = DateTime.UtcNow.AddSeconds(seconds);
    while (!condition() && DateTime.UtcNow < deadline)
    {
        await Task.Delay(10);   // 让出 UI 线程,dispatcher 继续泵
        Settle(passes: 1);
    }
    return condition();
}
csharp 复制代码
vm.RefreshCommand.Execute(null);
Assert.True(await WaitUntilAsync(() => vm.Rows.Count > 0, seconds: 10), "刷新 10 秒内没有返回数据");

await Task.Delay 会让出 UI 线程,后续代码作为续体回到 dispatcher;Thread.Sleep 把 UI 线程堵死,绑定、布局、await 续体全都动不了,最后超时或死锁。

五、交互与输入全解

所有输入 API 都是 Avalonia.Headless 下 HeadlessWindowExtensions 的扩展方法,第一个参数是 TopLevel(通常是 Window)。坐标是窗口客户区的 DIP,不是屏幕坐标。

5.1 键盘

csharp 复制代码
// 按下 + 抬起:只产生 KeyDown / KeyUp,不产生任何字符
window.KeyPress(Key.F5, RawInputModifiers.None, PhysicalKey.None, null);

// 组合键
window.KeyPress(Key.K, RawInputModifiers.Control, PhysicalKey.None, null);
window.KeyPress(Key.Enter, RawInputModifiers.Control | RawInputModifiers.Shift,
                PhysicalKey.None, null);

// 只按下 / 只抬起(测长按、修饰键状态)
window.KeyRelease(Key.LeftShift, RawInputModifiers.None, PhysicalKey.ShiftLeft, null);

四个参数是:逻辑键、RawInputModifiers、PhysicalKey、KeySymbol。三个坑:

  1. KeyPress 不会往 TextBox 里打字 。字符来自 TextInput 事件;测快捷键用 KeyPress,测输入内容用 KeyTextInput。

  2. 修饰键类型不同 :KeyPress 要 RawInputModifiers,而 KeyGesture 用 KeyModifiers。写个转换函数,别到处手写:

    csharp 复制代码
    static RawInputModifiers ToRaw(KeyModifiers m)
    {
        var raw = RawInputModifiers.None;
        if (m.HasFlag(KeyModifiers.Control)) raw |= RawInputModifiers.Control;
        if (m.HasFlag(KeyModifiers.Shift))   raw |= RawInputModifiers.Shift;
        if (m.HasFlag(KeyModifiers.Alt))     raw |= RawInputModifiers.Alt;
        if (m.HasFlag(KeyModifiers.Meta))    raw |= RawInputModifiers.Meta;
        return raw;
    }
  3. PhysicalKey 传 None 通常没问题 ,因为 KeyBinding 和 KeyEventArgs.Key 都按逻辑键匹配。但如果代码按 PhysicalKey / KeySymbol 判断(区分不同布局下的同一个键),就要传真实值,例如 PhysicalKey.A 配 "a"。

5.2 文本输入与焦点

csharp 复制代码
var box = window.FindControl<TextBox>("SearchBox")!;
box.Focus();
Settle();                                  // 焦点变更也是排队的

window.KeyTextInput("server activity");    // 一次 TextInput 事件,整串文本
Settle();
Assert.Equal("server activity", box.Text);
  • 文本发给当前焦点元素 。不 Focus() 就可能发到别处,表现为"打进去的字不见了"。

  • 中文、emoji 直接传字符串,不走 IME 组合过程:可重复,但 IME 本身测不到。

  • 需要精确控制光标/选区时,直接设 CaretIndex / SelectionStart / SelectionEnd,再用键盘操作,比模拟方向键稳。

  • 断言焦点:

    csharp 复制代码
    var focused = TopLevel.GetTopLevel(box)!.FocusManager!.GetFocusedElement();
    Assert.Same(box, focused);
  • Tab 导航可直接发 window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null);,用来验证 IsTabStop / TabIndex 配置。

5.3 鼠标

csharp 复制代码
var p = control.TranslatePoint(
    new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value;

window.MouseDown(p, MouseButton.Left, RawInputModifiers.None);
window.MouseUp(p, MouseButton.Left, RawInputModifiers.None);
window.MouseMove(p.WithX(p.X + 40), RawInputModifiers.None);
csharp 复制代码
// 双击:两次完整 down/up
void DoubleClick(TopLevel w, Point p)
{
    for (var i = 0; i < 2; i++)
    {
        w.MouseDown(p, MouseButton.Left, RawInputModifiers.None);
        w.MouseUp(p, MouseButton.Left, RawInputModifiers.None);
    }
    Settle();
}

// 右键
window.MouseDown(p, MouseButton.Right, RawInputModifiers.None);
window.MouseUp(p, MouseButton.Right, RawInputModifiers.None);

// 按住 Ctrl 点击(多选)
window.MouseDown(p, MouseButton.Left, RawInputModifiers.Control);
window.MouseUp(p, MouseButton.Left, RawInputModifiers.Control);

// 滚轮:新版有 MouseWheel 扩展;没有就设置 ScrollViewer.Offset,或自造 RawPointerWheelEventArgs
window.MouseWheel(p, new Vector(0, -3), RawInputModifiers.None);

坐标必须用 TranslatePoint(..., window) 换算。控件的 Bounds 是相对父容器的,直接当窗口坐标用会点偏,窗口一改尺寸就飘。

5.4 拖拽(拖列宽、拖动排序)

就是 down → move → up。但某些控件每一步都会 clamp(例如 DataGridColumnHeader 把每步钳到 MaxWidth),一步跳太远会停在半路。稳妥做法是多步小位移:

csharp 复制代码
void Drag(TopLevel window, Control grip, double dx, int steps = 4)
{
    var start = grip.TranslatePoint(
        new Point(grip.Bounds.Width - 2, grip.Bounds.Height / 2), window)!.Value;

    window.MouseDown(start, MouseButton.Left, RawInputModifiers.None);
    Settle();

    for (var i = 1; i <= steps; i++)
    {
        window.MouseMove(start.WithX(start.X + dx * i / steps), RawInputModifiers.None);
        Settle(passes: 1);
    }

    window.MouseUp(start.WithX(start.X + dx), MouseButton.Left, RawInputModifiers.None);
    Settle();
}

跨应用文件拖放(DragDrop.DoDragDrop)在 headless 里没有真实通道:把接收端逻辑抽成命令/IDropHandler 直接调用,别试图模拟系统级 DnD。

5.5 弹出层、上下文菜单、提示

Popup、Flyout、ContextMenu 在 headless 里照常工作,但有两个前提:宿主窗口已 Show(),且弹出层在排版后才会定位。所以顺序是"打开 → Settle() → 在弹出层的视觉树里找控件"。

csharp 复制代码
var menu = button.ContextMenu!;
menu.Open(button);          // 比右键更稳定;右键事件要不要发由测试目的决定
Settle();

var item = menu.Items.OfType<MenuItem>().First(i => (string?)i.Header == "Delete");
item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));   // 或 item.Command!.Execute(item.CommandParameter)
Settle();

ToolTip 不需要模拟悬停:ToolTip.SetIsOpen(control, true) 后断言 ToolTip.GetTip(control);如果提示是异步加载的,用 WaitUntilAsync。

5.6 快捷键:从命令目录解析,不要硬编码

直接写 KeyPress(Key.K, RawInputModifiers.Control, ...) 有两个问题:快捷键改了测试还绿;换了 macOS 之后同一个功能是 Cmd+K。

csharp 复制代码
public static void Press(TopLevel target, CommandId id)
{
    var gesture = CommandBindings.GestureFor(id)
        ?? throw new InvalidOperationException($"{id} 没有配置快捷键");

    target.KeyPress(gesture.Key, ToRaw(gesture.KeyModifiers), PhysicalKey.None, null);
    Settle();
}

命令目录是快捷键的唯一声明处,UI 的 KeyBinding、帮助面板、测试都从它取,改一处全部跟着变。这也是"测试能不能长期活着"的关键设计。

六、八个能直接抄的例子

以下 window、vm 为真实窗口与视图模型,Settle/Click/Press 用前面的工具方法。

例 1:表单输入 + 校验

csharp 复制代码
[AvaloniaFact]
public void 用户名含空格时提交被拒绝()
{
    var (window, vm) = OpenLogin();
    var box = window.FindControl<TextBox>("UserBox")!;

    box.Focus();
    Settle();
    window.KeyTextInput("bad name");
    Settle();
    Press(window, Key.Enter);        // 或 Click(提交按钮)

    Assert.False(vm.CanSubmit);
    Assert.Equal("用户名不能包含空格", window.FindControl<TextBlock>("ErrorText")!.Text);
}

例 2:命令面板(键盘全流程)

csharp 复制代码
[AvaloniaFact]
public async Task CtrlK_输入_回车_打开服务活动窗口()
{
    var (window, vm) = OpenShell();
    Press(window, CommandId.CommandPalette);   // 从目录解析,不硬编码 Ctrl+K
    Assert.True(vm.Palette.IsOpen);

    window.KeyTextInput("server activity");
    Settle();
    Press(window, Key.Enter);

    Assert.True(await WaitUntilAsync(() => vm.ServerActivityWindow is not null, seconds: 10));
}

这个例子覆盖了"快捷键 → 命令 → 文本过滤 → 回车执行高亮项 → 异步开窗"整条链,是 headless 最值钱的一类测试。

例 3:双击单元格进入编辑,Enter 提交

csharp 复制代码
[AvaloniaFact]
public void 双击单元格进入编辑并按回车提交()
{
    var (window, vm) = OpenGridWithRow();
    var grid = window.GetVisualDescendants().OfType<DataGrid>().First();
    var cell = grid.GetVisualDescendants().OfType<DataGridCell>().First(c => c.Column == grid.Columns[1]);
    var p = cell.TranslatePoint(new Point(20, 10), window)!.Value;

    DoubleClick(window, p);

    var editor = grid.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
    Assert.NotNull(editor);
    window.KeyTextInput("42");
    Settle();
    Press(window, Key.Enter);

    Assert.Equal("42", vm.ActiveTab.Rows[0][1]?.ToString());
    window.Close();
}

双击偶尔不中,是 headless 里最常见的不稳定点。保底方案是走控件 API:grid.SelectedIndex = 0; grid.CurrentColumn = grid.Columns[1]; grid.BeginEdit();,或者直接发 F2------测试目的若是"编辑链路",不必执着于双击手势;若目的就是双击本身,那就保留双击并断言"确实只发生了一次编辑"。

例 4:拖动列头改列宽(带因果校验)

csharp 复制代码
[AvaloniaFact]
public void 拖动列头右边缘后列变宽且解除宽度上限()
{
    var (window, _) = OpenGridWithRow();
    var grid = window.GetVisualDescendants().OfType<DataGrid>().First();
    var header = window.GetVisualDescendants().OfType<DataGridColumnHeader>().First();

    var before = grid.Columns[0].ActualWidth;
    Drag(window, header, dx: 60);

    Assert.True(grid.Columns[0].ActualWidth > before);
    window.Close();
}

关键是先断言 before 落在预期值上(比如正好等于自动宽度上限),否则"变宽了"可能只是本来就很宽,测了个寂寞。

例 5:树节点双击,验证没有副作用

csharp 复制代码
[AvaloniaFact]
public void 双击表节点打开浏览_但不展开节点()
{
    var (window, vm) = OpenSchemaTree();
    var item = window.GetVisualDescendants().OfType<TreeViewItem>()
        .First(i => i.DataContext is TableNode { Name: "customers" });
    var header = item.GetVisualDescendants().OfType<Control>().First(c => c.Name == "PART_Header");
    var p = header.TranslatePoint(new Point(header.Bounds.Width / 3, header.Bounds.Height / 2), window)!.Value;

    var tabsBefore = vm.Tabs.Count;
    DoubleClick(window, p);

    Assert.Equal(tabsBefore + 1, vm.Tabs.Count);
    Assert.False(item.IsExpanded);      // 双击不该顺带展开
    window.Close();
}

这类"手势不该带副作用"的断言,纯 ViewModel 测试永远测不出来。

例 6:主题切换(必须在 Show 之前)

csharp 复制代码
[AvaloniaFact]
public void 深色主题下侧栏使用深色资源()
{
    Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;

    var window = new MainWindow();   // 主题在构建/挂载时解析,先设再生
    window.Show();
    Settle();

    var sidebar = window.FindControl<Border>("Sidebar")!;
    Assert.Equal(ThemeVariant.Dark, sidebar.ActualThemeVariant);
}

顺序反了会得到"部分面板是浅色"的诡异结果,因为有些资源在视觉树 attach 时就已经解析完了。

例 7:模态对话框

csharp 复制代码
[AvaloniaFact]
public async Task 确认框点否_不删除数据()
{
    var (window, vm) = OpenShell();
    window.Show();
    Settle();

    var dialog = new ConfirmWindow();
    var result = dialog.ShowDialog<bool>(window);   // owner 必须已 Show
    Settle();

    dialog.FindControl<Button>("NoButton")!.Command!.Execute(null);
    Settle();

    Assert.False(await result);
    window.Close();
}

ShowDialog 在 headless 里就是"另一个窗口",可以正常断言。若被测代码自己 new 出对话框,说明它不可测:改成注入 IConfirmService,实现里再 ShowDialog,测试里替换成假实现。

例 8:直接断言控件而不是 ViewModel

csharp 复制代码
[AvaloniaFact]
public void 搜索结果列表使用虚拟化_只创建可见项()
{
    var (window, _) = OpenSearchWith(1000);
    var list = window.GetVisualDescendants().OfType<ListBox>().First();

    Assert.IsType<VirtualizingStackPanel>(list.Presenter?.Panel);
    Assert.InRange(list.GetRealizedContainers().Count(), 1, 50);   // 1000 条只实例化可见的几十个
}

顺带说:不要用"ListBoxItem 的视觉后代数量"来判断虚拟化,容器可能已回收但视觉树还没清理;GetRealizedContainers() 才是容器层面的真实答案。

这验证的是 XAML 配置(模板、面板、绑定),纯 ViewModel 测试覆盖不到,而漏掉它会在大数据量下变成卡顿。

七、截图与视觉回归

断言控件状态之外,headless 还能出真实像素------只要把绘制后端换成 Skia:

csharp 复制代码
static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
    .UseSkia()                                                       // 真渲染
    .UseHeadless(new AvaloniaHeadlessPlatformOptions
    {
        UseHeadlessDrawing = false,                                  // 关掉空绘制桩
    });

BuildAvaloniaApp().SetupWithoutStarting();   // 截图工具/CI 里这样起步

var window = new MainWindow { Width = 1280, Height = 800 };
window.Show();
Dispatcher.UIThread.RunJobs();
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Dispatcher.UIThread.RunJobs();               // 布局 + 渲染都落地后再抓帧

using var frame = window.CaptureRenderedFrame();
frame?.Save("main.light.png", new PngBitmapEncoderOptions());   // 11.x 可直接 Save(path)
window.Close();

一套可用的视觉回归流程:

  1. 每个场景一个 Func<Window>,灯/暗主题各渲染一次,文件名带主题后缀;
  2. 每帧与提交在仓库里的基线图比较,超出阈值就失败并写出 *.diff.png(基线去饱和、差异像素标成品红);
  3. 缺基线只报 NEW 不失败------新场景在本地(没有 Linux 容器)也加得进去,否则大家会习惯性跳过检查;
  4. 基线按 OS 分别提交,CI 在哪个系统跑就用哪个系统的基线。

阈值要有数据支撑,别拍脑袋。实测经验(同一份代码、同一台机器):

  • 同系统两次渲染:0 个像素不同;
  • Windows 与 Linux 渲染同一帧:差 0.6%--6%,全部来自字形栅格化;
  • 因此阈值定在 0.1%、单通道容差 8:远高于噪声,又远低于任何真实改动(挪一行文字就是几千像素)。

确定性还得手动补齐:

  • 日期选择器会画"今天"------把时钟固定住;
  • 动画/光标闪烁------ForceRenderTimerTick 能驱动,但不要依赖它推进真实时间;必要时在测试环境禁用动画;
  • 随机数、进程 ID、绝对路径、机器名------能出现在界面上的都要固定;
  • 窗口尺寸、字体(自带字体,别依赖系统装了 Cascadia/Consolas);
  • Linux 上 Skia 依赖 libfontconfig1,CI 里先 apt-get install -y libfontconfig1。

人工评审基线变更是这个流程的意义:批准一张图,就是签字确认"应用现在长这样"。不看图直接 approve,等于把门敞开。

八、工程化清单

  • 隔离应用数据 :测试进程绝不能读写开发者的 %AppData%(否则可能自动连上真实数据库、读到真实历史记录)。在构建 App 之前把数据根目录指到临时目录,或者用环境变量/IsolatedAppData 之类的开关。

  • 一个进程一个 session ,Dispose 放在测试会话结束的钩子里;不要在每条测试里 Start/Stop。

  • UI 测试尽量串行 。共享一个 UI 线程时 Dispatch 本身会排队,但 Application.Current、主题、语言、剪贴板都是全局状态;标了并行的测试互改主题必然互相打架。

  • 每个测试自己关窗 :window.Close() + Settle()。不关会留下事件订阅和静态监听,后面的测试开始"串味"。

  • 不要把 headless 测试写成 ViewModel 测试 。vm.FooCommand.Execute(null); Assert(...) 用普通单测就够了;在这里要断言的是输入路由 / 绑定 / 模板 / 焦点这些只有真控件树才有的东西。

  • CI :

    yaml 复制代码
    - run: sudo apt-get update && sudo apt-get install -y libfontconfig1   # 截图测试需要
    - run: dotnet test tests/MyApp.UiTests --logger "console;verbosity=normal"
  • 命名 :UI_动作_预期,例如 CtrlK_输入_回车_打开服务活动窗口。失败时看名字就知道哪条用户路径断了。

九、十二个坑

# 现象 原因 / 解法
1 输入毫无反应 窗口没 Show(),没有视觉树;先 Show 再发输入
2 断言总是旧值 没 Settle,布局/绑定还在队列里
3 测试永远绿 async lambda 传给 Dispatch<T> 变成 Task<Task>;只暴露 Func<Task> 重载
4 偶尔超时 用了 Thread.Sleep / .Result / .Wait() 堵住 UI 线程;改 await WaitUntilAsync
5 文本没进输入框 没 Focus(),或用了 KeyPress 而非 KeyTextInput
6 点了没命中 用了控件自身 Bounds 当窗口坐标;用 TranslatePoint(..., window)
7 拖拽只动一半 单步大位移被控件逐步 clamp;拆成多步 MouseMove
8 主题只改了一半 RequestedThemeVariant 设在 Show() 之后;构建窗口前设置
9 双击偶尔失效 headless 双击判定不稳定;保底用 F2 / BeginEdit(),或单独测双击语义
10 截图本地过、CI 红 基线跨 OS 混用;按 OS 存基线,容差 0.1%
11 测试互相干扰 并行改全局主题/语言/剪贴板;UI 测试串行 + 每例关窗
12 测试读到本机配置 没隔离 AppData,自动连了上次的数据库;启动前重定向数据目录

十、什么时候别用 headless

  • 要验证原生集成:文件对话框、托盘、系统通知、单实例、窗口拖拽吸附------这些必须在真机或专门的原生 UI 自动化里测。
  • 要验证输入法:候选词、上屏、组合输入,headless 直接绕过了 IME。
  • 要测性能:headless 的绘制是桩,测出来只反映布局和绑定开销。
  • 只想测业务规则:直接普通单测 + ViewModel,别为一个纯函数拉起一整套 UI。

换句话说:headless 处在"纯逻辑单测"和"真机 UI 自动化"之间,专门覆盖中间那层最容易腐坏、又最难手测的东西------按键到命令、点击到编辑、切换到重新绑定、模板到布局。

十一、小结

Avalonia headless 的用法可以浓缩成四句话:

  1. UseHeadless + 程序集级 AvaloniaTestApplication,用真 App 的样式和资源;
  2. 任何输入之前先 Show(),任何断言之前先 Settle();
  3. 键盘用 KeyPress,打字用 KeyTextInput,鼠标用 MouseDown/Move/Up + TranslatePoint 换算坐标;
  4. 异步用 WaitUntilAsync,截图用 Skia + CaptureRenderedFrame + 按 OS 分基线。

最后再强调一次自检:故意改坏一条断言,看它变红。一个不会红的 UI 测试,比没有测试更危险------它会让你以为已经覆盖了。

文中输入封装、Task<Task> 陷阱、拖动列头与"双击不产生副作用"的例子,来自一个真实 Avalonia 桌面项目的 headless 测试套件实践整理。