关于 widget-golden-and-a11y-testing
面向 Flutter 开发者的 Widget、golden 和无障碍测试技能,使用固定设备尺寸与真实 Widget 树几何数据检查布局。它将 golden 限定于字形与镜像验证,并通过语义和颜色值测试补充无障碍检查。
主要功能
- 01固定设备尺寸的测试框架
- 02文本缩放与粗体溢出矩阵
- 03基于几何数据的布局断言
- 04语义与触控目标检查
- 05纯 Dart 对比度验证
- 06受控的双通道 golden 测试
适用场景
- 01验证多种屏幕与文字缩放下的布局
- 02发现未触发溢出错误的文字裁切
- 03检查控件语义和触控目标尺寸
- 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.dartharness.dartoverflow_matrix_test.dartreferences/a11y-guidelines-and-limits.mdgolden-two-lanes.mdharness-and-mediaquery.mdoverflow-and-textscale.mdscripts/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。
不可妥协的规则
- 每项布局/几何测试都要固定设备。 默认 Widget 表面尺寸为逻辑像素 800x600,比任何手机都宽。若不固定设备,内容会宽约 2 倍,所有内容都能塞下,测试套件也会全部通过,但实际发布到手机上的版本却无法正常使用。先调用
useDevice(...),再调用pumpApp(...)。 physicalSize的单位是物理像素——务必乘以 DPR。 默认 DPR 为 3.0 时,view.physicalSize = Size(320, 640)对应的逻辑表面尺寸是 107x213,并不是手机尺寸。设置devicePixelRatio和physicalSize = logical * dpr,然后调用addTearDown(view.reset)——若尺寸设置泄漏,会污染文件中的所有后续测试。- 在
MaterialApp上方构建MediaQuery,并基于.copyWith创建。MaterialApp不会自行插入MediaQuery;来自pumpWidget的View所提供的MediaQuery才是最近的一层。直接使用MediaQueryData()会将设备刚刚固定的、源自视图的尺寸清零——测试随后测得的屏幕尺寸是 0x0,却仍会通过。 - 使用
pump(),切勿用pumpAndSettle()等待动画。pumpAndSettle的超时时间为 10 分钟,而且会截断堆栈跟踪;遇到无限循环的启动页、闪烁效果或加载指示器时还会永久挂起。状态变化时使用pump();由计时器驱动的异步操作使用pump(duration)/fakeAsync——pump()不会推进虚拟时钟。 - 绝不抑制溢出。
RenderFlex溢出已经会让 Widget 测试失败(它会通过FlutterError.reportError报错,并由 binding 在测试结束时重新抛出)。切勿调用takeException()吞掉错误,切勿赋值给FlutterError.onError,切勿复制ignoreOverflowErrors,也切勿在全局tearDown中调用takeException()——这些操作都会让整张安全网失效。 - 每个(设备、缩放比例、粗体)组合都要单独运行一次
testWidgets——切勿在一个测试中循环。 每个RenderObject只会报告一次溢出(该标记仅在reassemble()时重置),因此在一个测试中循环测试缩放比例,会导致第一个比例之后的所有比例都被静默漏报。在testWidgets调用外层循环。 - 断言内容是否适配,而不仅仅是没有溢出。 被裁切的
Text不会报告任何信息——RenderParagraph没有溢出指示器。takeException(), isNull虽然必要,却并不充分;还要添加getSize/getRect断言,确认标签完整位于计算出的单元格内。 - 布局测试优先断言计算出的几何信息,而不是使用 golden。 断言同一行中的单元格
top相同,同一列中的单元格left相同(使用moreOrLessEquals,epsilon: 0.5);并通过getSize循环断言点击目标尺寸。这样失败时会给出便于人工处理的说明。Golden 无法断言任何内容——即使屏幕上显示的是被裁切、无法阅读的文字,只要被接受为基准,测试就会永远通过。 - 对纯 Dart 中的颜色值断言对比度,绝不检查像素。
meetsGuideline(textContrastGuideline)会对图层截图并分析直方图——白色文字显示在#FAFAFA上时也会通过(这是 Flutter 尚未修复的问题)。使用纯 Dart 编写的 WCAG + APCA 测试直接检查主题颜色,不会误判为通过。对于仅以色度区分的状态对(亮度相同的选中状态与表面状态),直接断言wcag(theme.selected, theme.surface)——灰度模式用户感知到的正是两者的亮度差异,无需单独设置灰度通道。 - 对
meetsGuideline使用await expectLater(...)——它返回的是AsyncMatcher。 单独使用expect()看起来没问题,实际上却不会执行任何断言。仅将四项内置规范作为辅助性预警;真正的门槛是几何测试和纯 Dart 对比度测试。 - 设置两组 golden 测试流程,且都调用
loadAppFonts();阻止意外执行--update-goldens。 Ahem 方块字体的字节在不同操作系统间保持稳定,可用于验证几何布局/镜像,但不能验证字形塑形;还要设置一组使用真实字体、范围较窄的测试流程,并固定操作系统,以验证文字连写和数字字形。为每项 golden 添加@Tags(['golden']),并且只在一个固定环境中生成已批准的文件。 - 切勿限制
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。
参考资料
- Flutter — 组件测试简介:https://docs.flutter.dev/cookbook/testing/widget/introduction
- Flutter API —
matchesGoldenFile(受操作系统/字体/版本影响,--update-goldens):https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html - Flutter API —
WidgetTester/TestFlutterView:https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html - Flutter — 无障碍指南/
meetsGuideline:https://api.flutter.dev/flutter/flutter_test/AccessibilityGuideline-class.html - Flutter — 语义匹配器(
isSemantics、matchesSemantics):https://api.flutter.dev/flutter/flutter_test/matchesSemantics.html alchemist— 黄金图测试(loadAppFonts、CI 与平台流水线):https://pub.dev/packages/alchemist- WCAG 2.2 对比度(1.4.3 / 1.4.6)和 APCA:https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html