mcpField
W
widget-golden-and-a11y-testing

widget-golden-and-a11y-testing

github.com/zakariaf测试与质量63164 次安装5 周前更新
代码仓库

关于 widget-golden-and-a11y-testing

面向 Flutter 开发者的 Widget、golden 和无障碍测试技能,使用固定设备尺寸与真实 Widget 树几何数据检查布局。它将 golden 限定于字形与镜像验证,并通过语义和颜色值测试补充无障碍检查。

主要功能

  1. 01固定设备尺寸的测试框架
  2. 02文本缩放与粗体溢出矩阵
  3. 03基于几何数据的布局断言
  4. 04语义与触控目标检查
  5. 05纯 Dart 对比度验证
  6. 06受控的双通道 golden 测试

适用场景

  1. 01验证多种屏幕与文字缩放下的布局
  2. 02发现未触发溢出错误的文字裁切
  3. 03检查控件语义和触控目标尺寸
  4. 04验证 RTL 字形与镜像效果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill widget-golden-and-a11y-testing

安装后在 Claude Code 中输入/widget-golden-and-a11y-testing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
a11y_test.dart
harness.dart
overflow_matrix_test.dart
references/
a11y-guidelines-and-limits.md
golden-two-lanes.md
harness-and-mediaquery.md
overflow-and-textscale.md
scripts/
check-test-hygiene.sh

详细介绍

Widget、golden 和无障碍测试

一个测试框架固定了表层;布局和无障碍通过真实 Widget 树上的计算几何信息断言,而不是通过认可的像素断言。Golden 图仅作为范围有限、真实可靠的安全网,用于验证字形塑形和镜像效果——绝不能作为布局关卡。本技能涵盖 Widget 层级;纯核心/Drift/Notifier 层级请参阅 testing-strategy。

请根据当前任务阅读相应参考文档:

  • references/harness-and-mediaquery.md — pumpApp、Device 预设、四条关键代码行、驱动 MediaQuery 标志,以及查找器策略。
  • references/overflow-and-textscale.md — 两类溢出、三个陷阱、矩阵、适配断言,以及四种错误的修复方式。
  • references/a11y-guidelines-and-limits.md — 四项内置指南及其缺陷、语义/遍历关卡、纯 Dart 对比,以及自动化真正无法覆盖的内容。
  • references/golden-two-lanes.md — 拒绝使用 Golden 图的理由、Ahem 与真实字体两条测试路径、RTL Golden 图,以及阻止意外认可。

提交 PR 前运行 scripts/check-test-hygiene.sh。

不可妥协的规则

  1. 每项布局/几何测试都要固定设备。 默认 Widget 表面尺寸为逻辑像素 800x600,比任何手机都宽。若不固定设备,内容会宽约 2 倍,所有内容都能塞下,测试套件也会全部通过,但实际发布到手机上的版本却无法正常使用。先调用 useDevice(...),再调用 pumpApp(...)。
  2. physicalSize 的单位是物理像素——务必乘以 DPR。 默认 DPR 为 3.0 时,view.physicalSize = Size(320, 640) 对应的逻辑表面尺寸是 107x213,并不是手机尺寸。设置 devicePixelRatio 和 physicalSize = logical * dpr,然后调用 addTearDown(view.reset)——若尺寸设置泄漏,会污染文件中的所有后续测试。
  3. 在 MaterialApp 上方构建 MediaQuery,并基于 .copyWith 创建。 MaterialApp 不会自行插入 MediaQuery;来自 pumpWidget 的 View 所提供的 MediaQuery 才是最近的一层。直接使用 MediaQueryData() 会将设备刚刚固定的、源自视图的尺寸清零——测试随后测得的屏幕尺寸是 0x0,却仍会通过。
  4. 使用 pump(),切勿用 pumpAndSettle() 等待动画。 pumpAndSettle 的超时时间为 10 分钟,而且会截断堆栈跟踪;遇到无限循环的启动页、闪烁效果或加载指示器时还会永久挂起。状态变化时使用 pump();由计时器驱动的异步操作使用 pump(duration)/fakeAsync——pump() 不会推进虚拟时钟。
  5. 绝不抑制溢出。 RenderFlex 溢出已经会让 Widget 测试失败(它会通过 FlutterError.reportError 报错,并由 binding 在测试结束时重新抛出)。切勿调用 takeException() 吞掉错误,切勿赋值给 FlutterError.onError,切勿复制 ignoreOverflowErrors,也切勿在全局 tearDown 中调用 takeException()——这些操作都会让整张安全网失效。
  6. 每个(设备、缩放比例、粗体)组合都要单独运行一次 testWidgets——切勿在一个测试中循环。 每个 RenderObject 只会报告一次溢出(该标记仅在 reassemble() 时重置),因此在一个测试中循环测试缩放比例,会导致第一个比例之后的所有比例都被静默漏报。在 testWidgets 调用外层循环。
  7. 断言内容是否适配,而不仅仅是没有溢出。 被裁切的 Text 不会报告任何信息——RenderParagraph 没有溢出指示器。takeException(), isNull 虽然必要,却并不充分;还要添加 getSize/getRect 断言,确认标签完整位于计算出的单元格内。
  8. 布局测试优先断言计算出的几何信息,而不是使用 golden。 断言同一行中的单元格 top 相同,同一列中的单元格 left 相同(使用 moreOrLessEquals,epsilon: 0.5);并通过 getSize 循环断言点击目标尺寸。这样失败时会给出便于人工处理的说明。Golden 无法断言任何内容——即使屏幕上显示的是被裁切、无法阅读的文字,只要被接受为基准,测试就会永远通过。
  9. 对纯 Dart 中的颜色值断言对比度,绝不检查像素。 meetsGuideline(textContrastGuideline) 会对图层截图并分析直方图——白色文字显示在 #FAFAFA 上时也会通过(这是 Flutter 尚未修复的问题)。使用纯 Dart 编写的 WCAG + APCA 测试直接检查主题颜色,不会误判为通过。对于仅以色度区分的状态对(亮度相同的选中状态与表面状态),直接断言 wcag(theme.selected, theme.surface)——灰度模式用户感知到的正是两者的亮度差异,无需单独设置灰度通道。
  10. 对 meetsGuideline 使用 await expectLater(...)——它返回的是 AsyncMatcher。 单独使用 expect() 看起来没问题,实际上却不会执行任何断言。仅将四项内置规范作为辅助性预警;真正的门槛是几何测试和纯 Dart 对比度测试。
  11. 设置两组 golden 测试流程,且都调用 loadAppFonts();阻止意外执行 --update-goldens。 Ahem 方块字体的字节在不同操作系统间保持稳定,可用于验证几何布局/镜像,但不能验证字形塑形;还要设置一组使用真实字体、范围较窄的测试流程,并固定操作系统,以验证文字连写和数字字形。为每项 golden 添加 @Tags(['golden']),并且只在一个固定环境中生成已批准的文件。
  12. 切勿限制 TextScaler。 withClampedTextScaling、textScaleFactor 和 FittedBox 会绕过测试矩阵,使对比度和点击目标测试仍然通过,还会覆盖用户自行设置的操作系统偏好。应修复布局,而不是文字。

测试框架 — 每个测试都导入的单个文件

test/support/harness.dart 定义了一个 Device 值类型和一个 pumpApp 扩展。 接缝在被覆盖之前会抛出异常,因此未覆盖的依赖会明确报错,而不是在测试中悄悄构造一个真实服务。参见 references/harness-and-mediaquery.md 和 examples/harness.dart。

class Device {
  const Device(this.name, this.logicalSize, this.dpr);
  final String name;
  final Size logicalSize;
  final double dpr;

  // Neutral presets; name them by measured size, not a marketing model.
  static const compact = Device('compact_320', Size(320, 640), 2.0);
  static const small = Device('small_360', Size(360, 800), 3.0);
  static const medium = Device('medium_412', Size(412, 915), 2.625);
  static const all = <Device>[compact, small, medium];
}

extension TestHarness on WidgetTester {
  void useDevice(Device d) {
    view.devicePixelRatio = d.dpr;
    view.physicalSize = d.logicalSize * d.dpr; // physical px — multiply by DPR
    addTearDown(view.reset);                    // one call; nothing forgotten
  }

  Future<void> pumpApp({
    List<Override> overrides = const <Override>[],
    TextScaler textScaler = TextScaler.noScaling,
    bool boldText = false,
    bool accessibleNavigation = false,
  }) async {
    await pumpWidget(
      ProviderScope(
        overrides: overrides,
        child: Builder(
          builder: (context) => MediaQuery(
            data: MediaQuery.of(context).copyWith( // copyWith, NOT MediaQueryData()
              textScaler: textScaler,
              boldText: boldText,
              accessibleNavigation: accessibleNavigation,
            ),
            child: const App(),
          ),
        ),
      ),
    );
    await pump(); // one frame; never pumpAndSettle
  }
}

溢出与适配矩阵

Device.all x [1.0, 1.3, 1.5, 2.0, 3.0] x [false, true] bold = 每个元组对应一个 testWidgets。TextScaler.linear 是有意采用的过度近似 (Android 14+ 对大文本的缩放幅度更小)——这种做法偏保守,但并不忠实于设备。列表中之所以包含 1.3 和 1.5,正是因为设备的非线性缩放会让中间范围成为不易察觉的部分。boldText 会加宽字形前进宽度——但只有在加载了真正的比例字体时才会如此。使用默认的 Ahem 测试字体时,无论字重如何,每个字形都是固定的 em 方块,因此除非矩阵先调用 loadAppFonts(),否则 bold 轴不会产生作用;加载真实字体后,它会让未加粗时能通过的内容发生溢出,并暴露 em 方块所掩盖的真实字符宽度问题。

// Real proportional fonts, or the bold + character-width axes are inert under Ahem.
setUpAll(loadAppFonts);

for (final device in Device.all) {
  for (final scale in const <double>[1.0, 1.3, 1.5, 2.0, 3.0]) {
    for (final bold in const <bool>[false, true]) {
      testWidgets('no overflow @ ${device.name} x$scale${bold ? ' bold' : ''}',
          (tester) async {
        tester.useDevice(device);
        await tester.pumpApp(textScaler: TextScaler.linear(scale), boldText: bold);
        // Explicit for a readable message; the binding also rethrows at test end.
        expect(tester.takeException(), isNull,
            reason: 'content overflowed at ${device.name} x$scale');
      });
    }
  }
}

适配断言才是真正的关卡——它能捕获被裁切的 Text 从不报告的静默问题。参见 references/overflow-and-textscale.md 和 examples/overflow_matrix_test.dart。

无障碍关卡

在 testWidgets 中,Semantics 默认处于开启状态。使用 isSemantics 直接断言节点的角色和标签(而不是已弃用的 containsSemantics),通过显式的 getSize 循环测量点击目标(内置指南会跳过所有紧贴视图边缘的节点),并在纯 Dart 中断言颜色值的对比度。

testWidgets('each item exposes a button labelled by its display name',
    (tester) async {
  await tester.pumpApp();
  final node = tester.getSemantics(find.byKey(const ValueKey('item_0')));
  expect(node, isSemantics(
    label: 'First item',
    isButton: true,
    hasEnabledState: true,
    isEnabled: true,
    hasTapAction: true,
  ));
});

实事求是:Flutter 提供了四项可由机器检查的指南(其中一项已知有问题),覆盖的真实无障碍场景只占很小一部分。绝不要声称某个测试套件“测试了无障碍”。Switch Access / Switch Control 完全无法自动测试。参见 references/a11y-guidelines-and-limits.md 和 examples/a11y_test.dart。

Golden 测试通道

布局由计算出的几何数据验证,因此 Golden 测试范围很窄:只覆盖几何数据无法呈现的字形塑形、镜像和数字渲染。两个通道都会调用 loadAppFonts();RTL Golden 测试会在 Directionality 下泵入组件。参见 references/golden-two-lanes.md。

@Tags(['golden'])
library;

testWidgets('card mirrors correctly in RTL', (tester) async {
  await tester.pumpWidget(const Directionality(
    textDirection: TextDirection.rtl,
    child: _CardHarness(),
  ));
  await expectLater(find.byType(ItemCard),
      matchesGoldenFile('goldens/item_card_rtl.png'));
});

反模式

  • 未固定尺寸的布局测试。 在 800x600 上通过,却发布了在 360dp 手机上无法正常显示的内容。规则 1。
  • 在一个 testWidgets 中用 for 循环遍历缩放比例。 每个 RenderObject 只会报告一次溢出;缩放比例 2..n 会被静默跳过。规则 6。
  • 在全局 tearDown 中调用 takeException()。 在 binding 重新抛出异常之前清除 _pendingExceptionDetails——让整个溢出防护网形同虚设。
  • 在布局测试中使用 ignoreOverflowErrors / FlutterError.onError = ...。 这个广受欢迎的辅助方法会丢掉现有的防护网。
  • 使用 FittedBox / TextOverflow.ellipsis / withClampedTextScaling 让红色矩阵变绿。 这会让最复杂的标签变得最小,抵消用户的 TextScaler,并在产品已无法阅读时仍然通过。修复布局。
  • 使用 expect(tester, meetsGuideline(...)),却不调用 await expectLater。 这不会断言任何内容——匹配器是异步的。
  • 仅凭 meetsGuideline 就声称点击目标符合要求。 它会跳过所有紧贴视图边缘的节点;应添加显式的 getSize 循环。
  • 仅凭 textContrastGuideline 就声称对比度符合要求。 在低变化背景上会出现已知的假阴性;应在纯 Dart 中断言颜色比例。
  • 使用 find.byType(SomeWidget) 检查行为或几何数据。 这会让测试依赖类层级;重命名会让整个测试套件无故变红。行为检查使用 find.bySemanticsLabel,几何检查使用 find.byKey。
  • 把布局 Golden 测试作为关卡。 它会认可已发布的任何内容,包括被裁切的文本;而且任何字体栅格化方式不同的主机都会导致测试变红。

完成定义

  • 每个布局/几何测试,以及任何测量尺寸或位置的无障碍测试,都必须在 pumpApp(...) 之前调用 useDevice(...)。纯语义测试(角色/标签、遍历)无需指定设备。
  • 每个(设备、缩放比例、粗体)组合都对应一个 testWidgets,组成溢出矩阵;test/ 中不得有任何抑制。
  • 溢出矩阵需有适配断言(在计算出的单元格内调用 getSize/getRect)作为支撑;用几何不变量取代布局黄金图。
  • 使用显式的 getSize 循环测量点击目标;meetsGuideline 仅作参考,且必须通过 await expectLater 调用。
  • 使用 isSemantics 断言语义;标签应包含显示名称,而非内部数据;如果遍历顺序是有意的设计决策,则应断言该顺序。
  • 使用纯 Dart 针对主题颜色值断言对比度(WCAG + APCA),并遍历每个主题;仅色度不同的状态对,直接断言两种状态颜色之间的 wcag。
  • 黄金图(如果有)需标记为 @Tags(['golden']),两个流水线都要调用 loadAppFonts,并提供 RTL 黄金图;CI 必须阻止 --update-goldens。
  • scripts/check-test-hygiene.sh 检查通过。

相关技能

  • 有关整体测试原则(纯时钟注入核心、优先使用伪造实现而非模拟、真实内存数据库、覆盖率策略),请参阅 testing-strategy;本组件测试层基于这些原则。
  • 有关如何编写这些测试所断言的 Semantics/角色/标签,请参阅 accessibility-as-code;有关 RTL 黄金图验证的方向几何,请参阅 i18n-rtl-l10n。
  • 有关纯 Dart 对比度检查读取的主题/ColorScheme,请参阅 design-system-structure;有关布局测试不应重复验证的 const/重建规则,请参阅 flutter-performance。
  • 有关 pumpApp 使用的 ProviderScope/覆盖接缝,请参阅 state-management-riverpod;有关将黄金图流水线和 grep 检查接入 CI,请参阅 ci-pipeline-and-gates。
  • 有关唯一获准覆盖已提交黄金图像的流程,请参阅 run-goldens-rebaseline;有关每个黄金图和组件测试都会泵送的四种屏幕状态,请参阅 ui-states-and-feedback。

参考资料

评论

支持 Markdown