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. 测试项目
<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,你的样式、字体、资源全部不在,所有窗口都会因为"找不到资源"而以错误的理由失败:
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. 第一个测试
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. 两个必备小工具
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,一个进程一个:
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 之后的所有断言都落在没人观察的任务上。
结果:测试通过,但根本没跑。症状是"删掉实现代码也照样绿"。
// 错误:能编译,能"通过",什么都没测
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
/// <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();
}
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 键盘
// 按下 + 抬起:只产生 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。三个坑:
KeyPress不会往TextBox里打字。字符来自 TextInput 事件;测快捷键用KeyPress,测输入内容用KeyTextInput。- 修饰键类型不同:
KeyPress要RawInputModifiers,而KeyGesture用KeyModifiers。写个转换函数,别到处手写: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; } PhysicalKey传None通常没问题,因为KeyBinding和KeyEventArgs.Key都按逻辑键匹配。但如果代码按PhysicalKey/KeySymbol判断(区分不同布局下的同一个键),就要传真实值,例如PhysicalKey.A配"a"。
5.2 文本输入与焦点
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,再用键盘操作,比模拟方向键稳。 - 断言焦点:
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 鼠标
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);
// 双击:两次完整 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),一步跳太远会停在半路。稳妥做法是多步小位移:
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() → 在弹出层的视觉树里找控件"。
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。
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:表单输入 + 校验
[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:命令面板(键盘全流程)
[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 提交
[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:拖动列头改列宽(带因果校验)
[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:树节点双击,验证没有副作用
[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 之前)
[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:模态对话框
[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
[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:
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();
一套可用的视觉回归流程:
- 每个场景一个
Func<Window>,灯/暗主题各渲染一次,文件名带主题后缀; - 每帧与提交在仓库里的基线图比较,超出阈值就失败并写出
*.diff.png(基线去饱和、差异像素标成品红); - 缺基线只报
NEW不失败——新场景在本地(没有 Linux 容器)也加得进去,否则大家会习惯性跳过检查; - 基线按 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:
- 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 的用法可以浓缩成四句话:
UseHeadless+ 程序集级AvaloniaTestApplication,用真App的样式和资源;- 任何输入之前先
Show(),任何断言之前先Settle(); - 键盘用
KeyPress,打字用KeyTextInput,鼠标用MouseDown/Move/Up+TranslatePoint换算坐标; - 异步用
WaitUntilAsync,截图用 Skia +CaptureRenderedFrame+ 按 OS 分基线。
最后再强调一次自检:故意改坏一条断言,看它变红。一个不会红的 UI 测试,比没有测试更危险——它会让你以为已经覆盖了。
文中输入封装、
Task<Task>陷阱、拖动列头与"双击不产生副作用"的例子,来自一个真实 Avalonia 桌面项目的 headless 测试套件实践整理。
浙公网安备 33010602011771号