Skip to content

测试

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);
    }
}

作为运行夹具目标的 Counter 组件

参考

套件 项目 运行器 何时动用
单元 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 的组件 生命周期(BeginRenderRenderFlushEffectsRunCleanups)、 Component.ContextContextScope 都是内部的。它们对 tests/Reactor.Tests 可见,只是因为 src/Reactor/Reactor.csproj 授予了它 InternalsVisibleTo,因此下面这个辅助方法在消费方测试项目中无法编译 —— 展示它是为了说明 Reactor 自己的夹具是如何写的,而不是 让你复制到应用的测试里。要从仓库之外测试你自己的组件, 请使用 元素树上的结构断言 中展示的公共面, 或从自测中驱动一个真实控件。

该生命周期被 tests/Reactor.Tests/ 中的 ContextSystemSelfHostTestsComponentModelIntegrationTests 直接使用,包在一个 每类一份的辅助方法里:

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 里没有黄金文件快照工具,也不需要: Elementrecord,因此渲染出的树就是一个你可以直接 断言的值。请对你关心的形态做模式匹配, 而不是把整棵树字符串化 —— 结构断言点出了 被测属性,因此它的失败消息指向缺陷本身, 而不是一份 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,并为每个夹具呈现一个测试方法。

添加一个自测:

  1. tests/Reactor.AppTests.Host/SelfTest/Fixtures/ 下新增一个夹具文件,返回被测组件, 并用一个小小的断言外壳包起来。
  2. tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs处注册它 —— AllFixtures 列表以及 Create() switch。漏掉第二处, --list-fixtures 就会报出一个该次运行根本无法产生的名字,表现为 令人困惑的「缺少夹具」失败,而不是编译错误。
  3. MSTest 包装会在发现阶段通过 --list-fixtures 收到它;测试运行器一侧无需改动代码。

E2E 夹具也有同样的两处拆分,位于 tests/Reactor.AppTests.Host/FixtureRegistry.csAllFixtures 加上 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 套件是慢车道;把它留给键盘导航、 焦点顺序,以及依赖合成或输入路由的点击序列。

后续阅读

  • Hook —— 学习路径中的上一篇: 夹具所要检验的那些原语。
  • 副作用 —— UseEffect 的生命周期与清理, 包括上文所测试的刷新顺序。
  • 无障碍 —— 扫描器的规则,以及如何 用项目特有的检查去扩展它。
  • 开发工具 —— mur CLI、预览模式,以及 为本页截图提供支持的文档流水线工具链。
  • 组件 —— 让单元层值得投入的渲染纯度规则。