| name | avalonia-test |
| description | Expert in Avalonia 11.x headless unit testing with xUnit (Avalonia.Headless.XUnit). Covers [AvaloniaFact]/[AvaloniaTheory] attributes, AvaloniaTestApplication setup, UseHeadless() initialization, simulating keyboard/mouse/drag-drop input, visual regression with Skia, ViewModel-only testing, and CI integration. Use this skill whenever the user needs to: write headless UI tests for Avalonia controls, simulate clicks/typing/drag-drop in tests, fix [AvaloniaFact] NullReferenceException, set up an Avalonia headless test project from scratch, use ForceRenderTimerTick correctly, run Avalonia tests on Linux CI without a display, or do visual regression comparison. Also activate for questions about AvaloniaHeadlessPlatform, window.Show()/Close() in tests, focus-before-input patterns, or CollectionBehavior parallelization issues.
|
Avalonia 11.x Headless 單元測試技能
核心原則
Avalonia UI 測試的核心挑戰:UI 控制項需在 Avalonia Dispatcher 執行緒上操作,而 xUnit 預設在執行緒池執行。Avalonia.Headless.XUnit 套件透過 [AvaloniaFact] 屬性自動解決這個問題。任何時候都不應用 [Fact] 取代 [AvaloniaFact] 測試 UI 控制項,否則會出現靜默失敗或 cross-thread 例外。
1. 專案設定(每個測試專案只做一次)
1.1 安裝 NuGet 套件
<PackageReference Include="Avalonia.Headless.XUnit" Version="11.*" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.*" />
1.2 建立 TestApp(必須含 Theme)
using Avalonia;
using Avalonia.Headless;
using Avalonia.Themes.Fluent;
public class TestApp : Application
{
public override void Initialize()
{
Styles.Add(new FluentTheme());
}
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<TestApp>()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = true,
UseCpuDisabledRenderLoop = true
});
}
1.3 設定 AssemblyInfo.cs(每個專案只定義一次)
using Avalonia.Headless.XUnit;
[assembly: AvaloniaTestApplication(typeof(TestApp))]
[assembly: CollectionBehavior(DisableTestParallelization = true)]
2. 測試屬性規則
| 情境 | 正確屬性 | 錯誤屬性 |
|---|
| 測試 Avalonia 控制項 / UI | [AvaloniaFact] | [Fact] |
| 參數化 UI 測試 | [AvaloniaTheory] | [Theory] |
| 純 ViewModel / 業務邏輯 | [Fact] | 不需要 AvaloniaFact |
3. 測試範本庫
3.1 TextBox 輸入模擬
[AvaloniaFact]
public async Task TextBox_鍵入文字後值更新()
{
var textBox = new TextBox { Width = 200, Height = 24 };
var window = new Window { Content = textBox };
window.Show();
textBox.Focus();
window.KeyTextInput("Hello Avalonia");
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.Equal("Hello Avalonia", textBox.Text);
window.Close();
}
3.2 Button 點擊模擬
[AvaloniaFact]
public void Button_點擊後執行_Command()
{
var executed = false;
var button = new Button
{
Content = "Click",
Width = 100, Height = 30,
Command = ReactiveCommand.Create(() => executed = true)
};
var window = new Window { Content = button };
window.Show();
var center = button.Bounds.Center;
window.MouseDown(center, MouseButton.Left);
window.MouseUp(center, MouseButton.Left);
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.True(executed);
window.Close();
}
3.3 鍵盤快捷鍵模擬
[AvaloniaFact]
public void TextBox_Ctrl_A_選取全部文字()
{
var textBox = new TextBox { Text = "Hello World" };
var window = new Window { Content = textBox };
window.Show();
textBox.Focus();
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
window.KeyReleaseQwerty(PhysicalKey.A, RawInputModifiers.None);
Assert.Equal("Hello World", textBox.SelectedText);
window.Close();
}
3.4 拖放模擬
[AvaloniaFact]
public void DropTarget_接收拖放資料()
{
var received = string.Empty;
var window = new Window { Content = dropTarget };
window.Show();
var data = new DataObject();
data.Set(DataFormats.Text, "dragged payload");
window.DragDrop(new Point(10, 20), RawDragEventType.DragEnter, data, DragDropEffects.Copy);
window.DragDrop(new Point(10, 20), RawDragEventType.Drop, data, DragDropEffects.Copy);
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.Equal("dragged payload", received);
window.Close();
}
3.5 非同步操作測試
[AvaloniaFact]
public async Task 非同步載入後_Label_顯示結果()
{
var vm = new MyViewModel();
var label = new TextBlock { [!TextBlock.TextProperty] = new Binding("Status") };
var window = new Window { DataContext = vm, Content = label };
window.Show();
await vm.LoadDataAsync();
await Dispatcher.UIThread.InvokeAsync(() => { });
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.Equal("載入完成", label.Text);
window.Close();
}
async Task WaitForConditionAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("條件未在超時內滿足");
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
await Task.Delay(10);
}
}
3.6 ViewModel 純 C# 測試(不需 Headless)
[Fact]
public async Task ViewModel_IncrementCommand_增加計數()
{
var vm = new CounterViewModel();
Assert.Equal(0, vm.Count);
await vm.IncrementCommand.Execute();
Assert.Equal(1, vm.Count);
}
4. 視覺迴歸測試(Pixel 比對)
需要時參考 references/visual-regression.md。
啟用 Skia 渲染需修改 BuildAvaloniaApp():
UseHeadlessDrawing = false
.UseSkia() 加入 pipeline
- 使用
window.CaptureRenderedFrame() 取得 WriteableBitmap
5. 常見錯誤與修正
| 症狀 | 原因 | 修正 |
|---|
InvalidOperationException: Call from invalid thread | 用 [Fact] 而非 [AvaloniaFact] | 改用 [AvaloniaFact] |
輸入後 TextBox.Text 仍為 null | 缺少 textBox.Focus() | 加入 textBox.Focus() |
| Binding 未更新就斷言 | 未呼叫 ForceRenderTimerTick() | 輸入後加 ForceRenderTimerTick() |
NullReferenceException on styles | TestApp 缺少 Theme | 在 Initialize() 加入 FluentTheme |
| 多個 test 互相干擾 | 並行執行爭用 Dispatcher | 加入 DisableTestParallelization = true |
Build 失敗: 重複 AvaloniaTestApplication | Assembly 多處定義 | 全專案只保留一個 |
6. CI 設定(GitHub Actions)
- name: 執行 Avalonia Headless 測試
run: dotnet test ./tests/MyApp.Tests --logger "trx;LogFileName=results.trx" --blame-hang-timeout 5m
詳細 CI 設定請參考 references/ci-setup.md。
7. 生成測試的工作流程
當使用者描述要測試的場景時:
- 判斷測試類型:是 UI 控制項測試?還是純 ViewModel 邏輯測試?
- 選擇屬性:UI →
[AvaloniaFact],純邏輯 → [Fact]
- 生成完整可編譯程式碼,包含:
- using 指令
- 控制項初始化與
window.Show()
- 正確的輸入模擬(Focus → Input → ForceRenderTimerTick)
- Assert
window.Close()
- 若是新專案,同時提供
TestApp.cs + AssemblyInfo.cs 設定範本
- 提醒陷阱:依據測試類型主動提示相關常見錯誤
參考文件
references/visual-regression.md — 視覺迴歸測試詳細指南
references/ci-setup.md — CI 環境整合詳細設定