测试¶
Microsoft.UI.Reactor(Reactor)的渲染循环是确定且同步的。在单元测试中挂载的
Component 会渲染、运行副作用、接受状态更新、
重渲染并释放,走的是与 WinUI 宿主相同的代码路径 —— 只是最后少了一层
WinUI 树。这让单元层跑得很快(框架自己的 xUnit 运行只需数秒),
也让测试主体聚焦于组件行为,而不是
窗口外框。
Reactor 有三套测试套件,每个项目一套,另加会编译每个已发布示例的 文档流水线:
class Counter : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
return VStack(8,
TextBlock($"Count: {count}").FontSize(20).Bold(),
Button("Increment", () => setCount(count + 1))
).Padding(16);
}
}

参考¶
| 套件 | 项目 | 运行器 | 何时动用 |
|---|---|---|---|
| 单元 | tests/Reactor.Tests/ |
xUnit | Hook 语义、reducer 逻辑、修饰符链、协调算法、Yoga 布局、分析器规则。循环最快,没有 WinUI 窗口。 |
| 自测 | tests/Reactor.SelfTests/(夹具位于 Reactor.AppTests.Host) |
包装 TAP 子进程的 MSTest | 组件渲染进真实的 WinUI 树;通过 VisualTreeHelper 断言。 |
| 应用 E2E | tests/Reactor.AppTests/ |
MSTest + winapp ui | 真实用户输入、辅助技术所见到的 UIA 属性、跨进程行为。 |
每套套件都运行在 Microsoft.Testing.Platform 之上。整套运行的命令很 普通:
dotnet test tests/Reactor.Tests -p:Platform=x64
dotnet test tests/Reactor.SelfTests
dotnet test tests/Reactor.AppTests
定向运行时,请使用各运行器当前的筛选语法:xUnit 套件
偏好 MTP 的 --filter-class / --filter-method 系列,而 MSTest
自测与 E2E 套件仍使用 VSTest 风格的 --filter 表达式:
dotnet test tests/Reactor.Tests --filter-class "*ReconcilerMountUpdateTests*"
dotnet test tests/Reactor.SelfTests --filter "ClassName~SkipReportingTests"
dotnet test tests/Reactor.AppTests --filter "ClassName=Microsoft.UI.Reactor.AppTests.Tests.AccessibilityTests"
docs/_pipeline/apps/ 下的文档应用是第四道编译闸门,
而不是一套测试套件:mur docs compile 会构建它们中的每一个,
因此本指南里某个片段若引用了已被移除的 API,就会让文档
构建失败。
注意: 无头单元测试无法构造任何
Microsoft.UI.Xaml对象。 xUnit 运行背后没有 XAML 应用对象,因此new Button()、 一个画笔、一个几何对象、一个BitmapImage,或任何派生自AutomationPeer的类型,你碰它的那一刻就会抛COMException。单元测试可以 检验纯托管逻辑,加上 WinRT 值结构体与枚举 ——Element记录、 修饰符、Hook、布局计算、无障碍扫描器 —— 但不能碰任何 会落实成控件的东西。测试一旦需要活的控件, 它就属于自测夹具,而不是tests/Reactor.Tests。
单元层夹具¶
本节面向本仓库内部的测试。 Reactor 的组件 生命周期(
BeginRender→Render→FlushEffects→RunCleanups)、Component.Context与ContextScope都是内部的。它们对tests/Reactor.Tests可见,只是因为src/Reactor/Reactor.csproj授予了它InternalsVisibleTo,因此下面这个辅助方法在消费方测试项目中无法编译 —— 展示它是为了说明 Reactor 自己的夹具是如何写的,而不是 让你复制到应用的测试里。要从仓库之外测试你自己的组件, 请使用 元素树上的结构断言 中展示的公共面, 或从自测中驱动一个真实控件。
该生命周期被 tests/Reactor.Tests/ 中的 ContextSystemSelfHostTests 与
ComponentModelIntegrationTests 直接使用,包在一个
每类一份的辅助方法里:
private static Element MountComponent(
Component component, ContextScope scope,
Dictionary<ContextBase, object?>? contextValues = null)
{
if (contextValues is { Count: > 0 })
scope.Push(contextValues);
try
{
component.Context.BeginRender(() => { }, scope);
var element = component.Render();
component.Context.FlushEffects();
return element;
}
finally
{
if (contextValues is { Count: > 0 })
scope.Pop(contextValues.Count);
}
}
该辅助方法返回根元素。测试随后通过组件自身的公共面
(组件暴露的某个属性,或从 UseState 捕获的 setter)驱动状态,
再次调用该辅助方法重渲染,
然后断言。当夹具持有带 dispose lambda 的副作用时,请在下一个测试开始前调用
component.Context.RunCleanups()。在 try/finally 中压入
和弹出 ContextScope 很重要:否则一个在渲染中途抛异常的测试
会把它的上下文值泄漏到该类中的下一个
测试里。
完整的模式在 ComponentModelIntegrationTests.cs 里 —— 该文件
挂载一个带状态 + 上下文 + 副作用的组件,驱动 5 种不同的
生命周期转换,并在每次之后断言副作用日志。为新 Hook 添加单元夹具时,
请把它当作模板。
感知副作用的异步测试¶
UseEffect 不在渲染期间触发。它在组件的
上下文刷新副作用时触发 —— 上面那个单元 Mount 辅助方法是内联完成的。
检验副作用顺序的测试必须在挂载与下一次渲染之间观察日志,
而不是在渲染期间:
// 用作夹具目标的、感知副作用的组件。UseEffect 在下一次刷新时触发,
// 而不是在渲染期间 —— 测试必须等待这次刷新,然后
// 才能观察副作用的日志条目(见 testing.md 的“异步模式”)。
class EffectfulCounter : Component
{
public List<string> Log { get; } = new();
public override Element Render()
{
var (count, setCount) = UseState(0);
UseEffect(() =>
{
Log.Add($"effect:{count}");
return () => Log.Add($"cleanup:{count}");
}, count);
return Button($"count={count}", () => setCount(count + 1))
.AutomationName($"Counter is {count}");
}
}
针对 EffectfulCounter 的测试会挂载组件、断言
Log = ["effect:0"]、递增状态、重渲染,并断言
Log = ["effect:0", "cleanup:0", "effect:5"]。上一个副作用的清理会在新副作用主体之前
运行 —— 这就是
tests/Reactor.Tests/ComponentModelIntegrationTests.cs 所固化的契约。
对于真正的异步工作(HTTP 拉取、计时器),不要在
UseEffect 里手搓:UseResource 已经持有
CancellationToken、加载/错误状态,以及卸载时的取消,
因此测试可以通过控制交给它的拉取器来驱动它。当组件必须自己持有任务时,
请暴露完成 Task,让测试可以确定性地 await 它 ——
或者通过 UseContext 注入一个时钟接口的假实现,并手工把它
向前推进。避免在测试里用 Thread.Sleep;它会把挂钟
时间泄进套件并让 CI 变得不稳定。
元素树上的结构断言¶
Reactor 里没有黄金文件快照工具,也不需要:
Element 是 record,因此渲染出的树就是一个你可以直接
断言的值。请对你关心的形态做模式匹配,
而不是把整棵树字符串化 —— 结构断言点出了
被测属性,因此它的失败消息指向缺陷本身,
而不是一份 200 行的文本差异:
[Fact]
public void Component_TProps_Renders_With_Props()
{
var comp = new GreetingComponent { Props = "Alice" };
var el = comp.Render();
Assert.IsType<TextBlockElement>(el);
Assert.Equal("Hello, Alice!", ((TextBlockElement)el).Content);
}
对组件负责的那些槽位做断言,而不是对每个元素的每个 字段。一个把内边距、字号与子元素顺序一起钉死的测试, 会因为三个互不相关的原因失败,而只有你读完差异之后才能知道 是哪一个。绝不要对任何带有时间戳、生成 id 或哈希的东西做断言 —— 要么把它规范化,要么把它从被测组件中抽出去。
无障碍扫描器集成¶
AccessibilityScanner.Scan(root) 会遍历一个元素
树并返回 List<A11yDiagnostic>,每个发现项一条,每条
带有一个规则 Id("A11Y_001" …)、一个 Severity、一个
WcagCriterion,以及一条 Fix 建议。它接受的是 Element 而不是
控件,因此能在无头单元套件里运行:
// AccessibilityScanner 的夹具目标。扫描器遍历元素树,
// 每个发现项返回一条 A11yDiagnostic,每条都带有一个规则 Id
//("A11Y_001" = 仅图标按钮且没有无障碍名称)。
class IconOnlyButton : Component
{
public override Element Render() =>
Button(TextBlock("🔍")); // 图标内容,没有无障碍名称
}
class NamedButton : Component
{
public override Element Render() =>
Button(TextBlock("🔍"), null).AutomationName("Search");
}
[Fact]
public void A11Y_001_IconButton_Without_AutomationName()
{
var tree = VStack(
Button(TextBlock("🔍"), null) // 图标内容,没有 AutomationName
);
var findings = AccessibilityScanner.Scan(tree);
Assert.Contains(findings, f => f.Id == "A11Y_001");
}
[Fact]
public void A11Y_001_IconButton_With_AutomationName_Passes()
{
var tree = VStack(
Button(TextBlock("🔍"), null).AutomationName("Search")
);
var findings = AccessibilityScanner.Scan(tree);
Assert.DoesNotContain(findings, f => f.Id == "A11Y_001");
}
请针对特定的规则 id 断言 DoesNotContain,而不是对整个列表断言 Empty:
一个断言扫描结果完全干净的夹具,会在某条无关规则被加入的那天
开始失败,而且失败与这个测试原本要保护的东西
毫无关系。
同一个扫描器也支撑应用内开发菜单的「Run accessibility scan」 按钮,因此在这里通过的夹具,与在运行中的应用里通过的形态相同。 请把「扫描器干净」当作你交付的每个新组件 的长期门槛。
自测(真实 WinUI 树)¶
Reactor.SelfTests 是单元套件(纯 C#,无
WinUI)与完整 E2E 套件(winapp ui)之间的那一层。自测把一个
真实夹具挂载进 Reactor.AppTests.Host 窗口,遍历 WinUI
可视化树,并输出 TAP。SelfTestBatch.cs 里的 MSTest 包装
解析 TAP,并为每个夹具呈现一个测试方法。
添加一个自测:
- 在
tests/Reactor.AppTests.Host/SelfTest/Fixtures/下新增一个夹具文件,返回被测组件, 并用一个小小的断言外壳包起来。 - 在
tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs的两处注册它 ——AllFixtures列表以及Create()switch。漏掉第二处,--list-fixtures就会报出一个该次运行根本无法产生的名字,表现为 令人困惑的「缺少夹具」失败,而不是编译错误。 - MSTest 包装会在发现阶段通过
--list-fixtures收到它;测试运行器一侧无需改动代码。
E2E 夹具也有同样的两处拆分,位于
tests/Reactor.AppTests.Host/FixtureRegistry.cs(AllFixtures 加上
Build switch)。--list-fixtures 只针对自测,因此 E2E 那一半
不会有任何东西提醒你。
当单元层看不到答案时,就动用自测 —— 例如 WinUI 控件的测量尺寸会影响组件行为, 或某个自动化对等体的角色取决于已落实的 XAML 控件类时。
夹具用 H.Check(name, condition) 断言。当这台机器
确实无法运行某项检查时 —— 桌面被锁定、操作系统版本没有
该 API —— 请用 H.Skip(name, reason),而不是悄悄返回。一个
跳过了全部检查、什么都没断言的夹具会被报告为
Skipped,而不是 Passed,因此「这台机器测不了」与
「这台机器测了而且通过了」始终可以区分开来。宁可去断言
环境探测本身、只跳过依赖它的那部分,这样夹具在任何地方都仍能证明一些东西。
当这次跳过标记的是一个真实的产品缺口时,请在原因里写上
问题编号:跳过绝不是产品可用的证据,只能说明这次运行
没有得出相反结论。
提示¶
不要用 Task.Delay 驱动单元夹具。 如果某个副作用
调度了异步工作,请暴露它的完成任务,让测试可以
await 它。挂钟延迟会泄进套件并让 CI 变得不稳定。
对树的结构做断言,而不是对渲染出的像素。 Element
记录给你可以模式匹配的有类型槽位;实际渲染出的
位图取决于字体渲染、DPI 与平台合成 —— 这些都不该
出现在单元测试里。
在每个夹具的拆除阶段运行无障碍扫描。 它
很廉价,接受的是 Element 而不是控件,因此可以无头运行,
而且它把扫描器的输出摆到引入问题的那个测试
旁边。
只在 xUnit 与自测够不到的地方使用 Reactor.AppTests。
winapp ui 的 E2E 套件是慢车道;把它留给键盘导航、
焦点顺序,以及依赖合成或输入路由的点击序列。