Flutter 基础体系 · 第 68/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Golden 测试:基线、字体、像素差异、主题和审阅

Golden 测试是一种把 Flutter 界面渲染成图像,再与预先保存的“基线图像”比较的测试。它适合验证布局、间距、颜色、边框、阴影、文字排版和组件组合是否发生了视觉变化。

它不是“把整个应用截图后看一眼”的自动化版本。一次 Golden 测试至少包含四个对象:

  1. 被测 Widget 树:测试实际构建的界面。
  2. 渲染环境:屏幕尺寸、设备像素比、字体、主题、平台和渲染后端。
  3. 基线图像:被认可的参考结果。
  4. 比较与审阅流程:决定差异是缺陷、预期变更,还是环境漂移。

如果渲染环境不稳定,测试可能因为字体或像素变化失败;如果没有审阅基线,测试也可能把真实回归直接更新掉。因此,Golden 测试的核心不是生成图片,而是建立一条可重复、可解释、可审阅的视觉验证链路。


一、Golden 测试验证的究竟是什么

1. 基线的定义

**基线(baseline)**是某个确定渲染条件下、经过人工或产品确认的参考图像。它不是“设计稿的截图”,也不一定是“所有平台都正确的唯一答案”。

可以把一次 Golden 测试抽象为:

I=R(W,E,S)I = R(W, E, S)

其中:

  • WW 是 Widget 树;
  • EE 是渲染环境,例如字体、主题、平台和渲染器;
  • SS 是状态和输入数据;
  • RR 是 Flutter 的布局、绘制和文字栅格化过程;
  • II 是最终得到的像素图像。

基线则是:

G=R(Wapproved,Ebaseline,Sapproved)G = R(W_{\text{approved}}, E_{\text{baseline}}, S_{\text{approved}})

测试比较的是当前图像 II 与基线图像 GG,而不是直接比较 Dart 对象:

match(I,G)\operatorname{match}(I, G)

因此,即使 Widget 的 Dart 代码没有明显变化,只要字体、主题、设备像素比或渲染后端变化,II 也可能变化。

2. Golden 测试能发现什么

例如下面这些问题通常适合用 Golden 测试发现:

  • Padding 从 16 改成 12;
  • 文本样式的字号、字重或行高变化;
  • ColorScheme 的主色被错误覆盖;
  • 深色主题中某个文字仍使用浅色主题颜色;
  • 圆角、边框或阴影消失;
  • 不同状态下按钮的布局不一致;
  • 本地化文本变长后发生截断;
  • 组件组合顺序改变,导致视觉层级变化。

3. Golden 测试不能替代什么

Golden 测试通常不能充分证明:

  • 点击事件是否正确触发;
  • 表单校验逻辑是否正确;
  • 网络失败后是否显示正确状态;
  • 无障碍语义树是否完整;
  • 滚动是否能到达指定位置;
  • 在真实 Android、iOS 设备上是否拥有相同的输入、字体和 GPU 行为。

例如,一个按钮可能与基线完全相同,但其 onPressed 回调为空。反过来,按钮行为完全正确,也可能因为字体抗锯齿差异导致 Golden 失败。

所以常见的测试分工是:

  • 单元测试验证数据和业务规则;
  • Widget 测试验证交互、状态和语义;
  • Golden 测试验证稳定状态下的视觉输出;
  • 集成测试验证真实平台上的完整流程。

二、一个可运行的最小 Golden 测试

下面的示例验证一个带主题、文字和按钮的卡片组件。

1. 被测组件

lib/profile_card.dart

import 'package:flutter/material.dart';

class ProfileCard extends StatelessWidget {
  const ProfileCard({
    super.key,
    required this.name,
    required this.description,
    this.onPressed,
  });

  final String name;
  final String description;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.zero,
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              name,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            Text(
              description,
              style: Theme.of(context).textTheme.bodyMedium,
            ),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: onPressed,
              child: const Text('查看详情'),
            ),
          ],
        ),
      ),
    );
  }
}

2. Golden 测试

test/profile_card_golden_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:example/profile_card.dart';

void main() {
  testWidgets('ProfileCard - light theme', (tester) async {
    // 明确设置测试视口,避免不同测试环境使用不同尺寸。
    tester.view.physicalSize = const Size(390, 220);
    tester.view.devicePixelRatio = 1.0;

    addTearDown(() {
      // 测试结束后恢复测试视图,避免污染同一进程中的其他测试。
      tester.view.reset();
    });

    await tester.pumpWidget(
      MaterialApp(
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.indigo,
          ),
          useMaterial3: true,
        ),
        home: Scaffold(
          backgroundColor: Colors.white,
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: ProfileCard(
              name: 'Ada Lovelace',
              description: 'Flutter 工程师',
              onPressed: () {},
            ),
          ),
        ),
      ),
    );

    // 让首帧布局和绘制完成。若组件包含动画或异步状态,
    // 应在这里显式推进到确定状态,而不是依赖偶然时序。
    await tester.pump();

    await expectLater(
      find.byType(ProfileCard),
      matchesGoldenFile('goldens/profile_card_light.png'),
    );
  });
}

这个测试中的数据流是:

flowchart LR
    A[测试数据] --> B[ProfileCard]
    C[固定主题] --> D[MaterialApp]
    B --> D
    E[固定视口与 DPR] --> F[Flutter 渲染]
    D --> F
    F --> G[当前 PNG]
    H[goldens/profile_card_light.png] --> I[Golden Comparator]
    G --> I
    I --> J{是否匹配}
    J -->|是| K[测试通过]
    J -->|否| L[生成差异输出并失败]

MaterialApp 提供 Theme.of(context) 所依赖的主题上下文;tester.view 控制物理像素尺寸和设备像素比;matchesGoldenFile 触发当前渲染结果与文件基线之间的比较。

3. 首次生成基线

在项目根目录执行:

flutter test --update-goldens test/profile_card_golden_test.dart

常见结果有两种:

  • 基线不存在:测试会生成新的 Golden 文件;
  • 基线已存在:测试会用当前输出覆盖对应文件。

之后执行普通测试:

flutter test test/profile_card_golden_test.dart

此时测试不应再更新文件,而是比较当前渲染结果。如果结果不同,测试失败,并通常在测试输出目录中生成实际图像、差异图或用于诊断的比较结果。具体文件名和输出形式属于 Flutter 测试框架实现细节,不能假设所有版本完全相同;应以命令输出为准。

--update-goldens 是一个有破坏性的操作:它会把当前结果写成新的事实。不能把“命令成功”理解为“视觉变更正确”。


三、基线不是一个文件,而是一组条件

1. 视口尺寸与设备像素比

Flutter 布局通常使用逻辑像素,但最终图像使用物理像素。两者关系为:

P=L×DP = L \times D

其中:

  • LL 是逻辑尺寸;
  • DDdevicePixelRatio
  • PP 是物理像素尺寸。

例如,逻辑宽度为 390,设备像素比为 3,则物理宽度约为:

390×3=1170390 \times 3 = 1170

如果基线在 D = 1.0 下生成,而当前测试在 D = 3.0 下运行,布局的逻辑尺寸可能相同,但输出图像的分辨率、文字栅格化和边缘像素都会变化。二者不能直接作为同一基线比较。

示例中使用:

tester.view.physicalSize = const Size(390, 220);
tester.view.devicePixelRatio = 1.0;

这里的 physicalSize 是物理像素尺寸,不是逻辑尺寸。因此如果希望得到约 390 逻辑像素宽、DPR 为 3 的环境,应设置:

tester.view.physicalSize = const Size(1170, 660);
tester.view.devicePixelRatio = 3.0;

对应的逻辑尺寸约为:

1170 / 3 = 390
660 / 3 = 220

如果只想测试组件本身,不想让画布尺寸影响基线,可以固定较小视口,并让根组件使用 mainAxisSize: MainAxisSize.min 或明确的布局约束。否则,Scaffold、背景色和安全区域也会成为图像的一部分。

2. 为什么必须恢复测试视图

tester.view 修改的是当前测试绑定中的视图状态。如果不恢复,后续测试可能继承前一个测试的物理尺寸或 DPR,造成顺序相关的失败:

单独运行 test B:通过
先运行 test A,再运行 test B:失败

这不是 Golden 本身不稳定,而是测试之间共享了未清理的环境状态。addTearDown 的作用是把状态恢复动作注册到测试生命周期中,即使断言失败,也会执行清理。


四、像素差异:从图像比较到失败诊断

1. 逐像素比较模型

设图像宽度为 WW,高度为 HH,每个像素有红、绿、蓝、透明四个通道:

I(x,y)=(R,G,B,A)I(x,y) = (R,G,B,A)

当前图像 II 与基线 GG 的逐像素差异可以写成:

D(x,y)={1,I(x,y)G(x,y)0,I(x,y)=G(x,y)D(x,y) = \begin{cases} 1, & I(x,y) \ne G(x,y) \\ 0, & I(x,y) = G(x,y) \end{cases}

差异像素比例为:

r=x=1Wy=1HD(x,y)W×Hr = \frac{\sum_{x=1}^{W}\sum_{y=1}^{H}D(x,y)}{W \times H}

如果 r=0r = 0,说明没有像素差异。若 r>0r > 0,是否失败以及失败输出如何生成,由当前 Flutter 测试框架使用的 GoldenFileComparator 实现决定。

这里需要区分两个概念:

  • 像素差异:哪些位置的像素不同;
  • 视觉重要性:差异是否代表用户能感知的回归。

例如,一个抗锯齿边缘改变了 100 个像素,可能是渲染器差异;一个按钮颜色改变了同样数量的像素,可能是明确的设计缺陷。单纯统计差异像素不能替代审阅。

2. 一个完整算例

假设基线是一张 4×34 \times 3 的图像,共有:

4×3=124 \times 3 = 12

个像素。

当前图像与基线在 3 个像素上不同,则:

r=312=0.25=25%r = \frac{3}{12} = 0.25 = 25\%

如果这 3 个像素集中在圆角边缘,可能是抗锯齿差异;如果这 3 个像素位于一个颜色填充区域的中心,则可能是颜色值发生变化。

再假设两个图像尺寸不同:

  • 当前图像:390 × 220;
  • 基线图像:1170 × 660。

即使两者表达的是同一个逻辑布局,也不应直接比较。因为像素坐标系不同,图像矩阵的维度已经不一致。正确做法是固定同一组尺寸和 DPR,或者分别维护不同环境的基线。

3. 差异图的解释方法

一次失败至少应按以下顺序观察:

  1. 图像尺寸是否相同
    尺寸不同通常首先指向视口、DPR、平台或截图范围变化。

  2. 差异是否遍布整张图
    全图颜色或亮度变化,常见原因是主题、背景色、色彩空间或渲染环境变化。

  3. 差异是否只出现在文字区域
    优先检查字体文件、字重、字体回退、字号、行高和平台。

  4. 差异是否只在边缘
    检查圆角、阴影、变换、DPR 和抗锯齿。

  5. 差异是否集中在一个组件
    通常更接近代码变更,例如 Padding、颜色或状态逻辑。

  6. 差异是否随运行次数变化
    可能存在动画、异步数据、时间、随机数或字体加载时序问题。

4. 失败表现与对应诊断

失败表现 优先检查
基线文件不存在 是否首次生成,路径是否相对当前测试文件正确
整张图尺寸不同 physicalSize、DPR、平台和截图边界
所有文字都变了 字体、字重、字体回退、字体加载和操作系统
阴影边缘变化 DPR、渲染后端、平台实现和阴影参数
每次差异位置不同 动画、异步状态、随机数、时间或布局竞态
只有主题下的颜色变化 ThemeDataColorScheme、局部 Theme 覆盖
本地通过、CI 失败 Flutter SDK、引擎、字体、操作系统和渲染环境不一致

五、字体:Golden 测试最常见的隐性输入

1. 字体决定的不只是字形

文字布局至少受以下因素影响:

  • 字体族;
  • 字体文件版本;
  • 字重是否真的存在;
  • 字体回退规则;
  • 字号;
  • 字母和汉字的字面宽度;
  • TextStyle.height
  • 字距;
  • 文本方向;
  • 平台文字栅格化方式。

例如,代码要求使用 600 字重,但项目只提供了 400 和 700 两个字体文件。不同平台或不同字体实现可能选择不同的回退方式。最终结果可能表现为:

  • 文字宽度变化;
  • 行数变化;
  • 基线位置变化;
  • 文本被截断或换行;
  • 同样的布局约束下,按钮高度变化。

因此,“测试机上安装了这个字体”并不足以保证稳定。系统字体可能随着操作系统升级而变化,CI 镜像也可能没有开发机上的字体。

2. 使用项目内字体降低环境变量

如果界面依赖特定字体,应把字体作为应用资源声明,而不是依赖测试机器的系统字体。

pubspec.yaml 示例:

flutter:
  fonts:
    - family: AppSans
      fonts:
        - asset: assets/fonts/AppSans-Regular.ttf
          weight: 400
        - asset: assets/fonts/AppSans-SemiBold.ttf
          weight: 600

使用时:

const TextStyle(
  fontFamily: 'AppSans',
  fontWeight: FontWeight.w600,
)

应用入口或测试入口应明确指定字体:

MaterialApp(
  theme: ThemeData(
    fontFamily: 'AppSans',
  ),
  home: const MyPage(),
)

前置条件包括:

  • 字体文件确实存在;
  • pubspec.yaml 缩进正确;
  • 字体族名称与 fontFamily 完全一致;
  • 指定的字重有对应文件;
  • 修改资源后重新运行测试,而不是复用旧的构建产物。

3. 汉字与字体回退

即使英文使用了项目字体,字体文件也可能不包含全部汉字。此时 Flutter 会进行字体回退。回退字体来自当前平台和字体配置,因此同一段中文可能在 macOS、Linux、Windows、Android 和 iOS 上产生不同宽度和字形。

这会造成一种典型误判:

英文 Golden 稳定,加入中文后 CI 失败

此时不应先放宽像素差异,而应确认:

  1. 项目字体是否包含被测字符;
  2. 是否明确提供了中文字体;
  3. CI 是否安装了相同字体;
  4. 当前平台是否发生字体回退。

对于需要跨平台比较的中文界面,通常有两种取舍:

  • 使用包含目标字符集的项目字体,并固定渲染环境;
  • 为不同平台维护独立基线,并接受平台字体差异。

第二种方案不能消除差异,只是把差异显式建模。

4. 字体加载与测试时序

字体是异步资源时,不能在资源尚未稳定时截图。若组件依赖异步字体、网络图片或异步初始化,应先把测试推进到确定状态,再执行 Golden 断言。

pumpAndSettle 可以等待框架中没有继续调度的帧:

await tester.pumpAndSettle();

但它不是所有场景的正确答案:

  • 无限动画会让它一直等待;
  • 周期性定时器可能始终产生新帧;
  • 网络或外部异步任务不一定由 Flutter 的帧调度表示;
  • 某些动画需要测试特定中间帧,而不是最终稳定帧。

对于动画,应关闭动画、注入静态状态,或者明确推进时间:

await tester.pump(const Duration(milliseconds: 300));

若组件使用随机数、当前时间或动态 ID,也应通过依赖注入固定它们,否则相同代码每次可能生成不同图像。


六、主题:Golden 必须固定视觉上下文

1. 主题不是一个颜色常量

Flutter 的主题通过 BuildContext 向下传递,影响:

  • ColorScheme
  • 文本样式;
  • 按钮和输入框默认样式;
  • 卡片形状与阴影;
  • Material 2 或 Material 3 行为;
  • 高对比度和平台相关配置;
  • 局部组件的默认尺寸与状态颜色。

被测组件中调用:

Theme.of(context)

意味着它的输出依赖祖先上下文。如果测试只构造一个裸 ProfileCard,没有 MaterialAppTheme,则可能出现找不到 MaterialLocalizations、缺少主题默认值,或者得到与生产环境不同的视觉结果。

2. 同一组件的两个主题基线

一个组件在浅色和深色主题下是两个不同的视觉契约。应分别测试:

Future<void> pumpProfileCard(
  WidgetTester tester, {
  required ThemeData theme,
}) async {
  await tester.pumpWidget(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: theme,
      home: const Scaffold(
        body: Padding(
          padding: EdgeInsets.all(16),
          child: ProfileCard(
            name: 'Ada Lovelace',
            description: 'Flutter 工程师',
          ),
        ),
      ),
    ),
  );

  await tester.pump();
}
testWidgets('ProfileCard - dark theme', (tester) async {
  tester.view.physicalSize = const Size(390, 220);
  tester.view.devicePixelRatio = 1.0;

  addTearDown(tester.view.reset);

  await pumpProfileCard(
    tester,
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.indigo,
        brightness: Brightness.dark,
      ),
      useMaterial3: true,
    ),
  );

  await expectLater(
    find.byType(ProfileCard),
    matchesGoldenFile('goldens/profile_card_dark.png'),
  );
});

这里的关键不是“多生成一张图片”,而是明确声明:

GlightGdarkG_{\text{light}} \ne G_{\text{dark}}

因为亮度模式是输入条件的一部分。把两种主题共用一张基线,会使测试失去表达能力。

3. ThemeData 的隐式变化

以下变更都可能导致大量 Golden 变化:

ThemeData(useMaterial3: true)

改为:

ThemeData(useMaterial3: false)

或者改变:

  • colorScheme 的 seed;
  • textTheme
  • fontFamily
  • visualDensity
  • cardTheme
  • filledButtonTheme
  • pageTransitionsTheme

这类变更可能是一次有意的全局设计升级,也可能是测试没有显式固定主题导致的环境漂移。审阅时应先确认变更范围:如果几乎所有 Golden 都同时变化,优先检查共享主题和 SDK 版本,而不是逐个修组件。


七、状态、动画和异步数据必须确定

Golden 测试只能对“同一输入得到同一输出”负责。设状态机为:

stateDiagram-v2
    [*] --> Loading
    Loading --> Loaded: 数据完成
    Loading --> Error: 请求失败
    Loaded --> [*]
    Error --> [*]

如果测试在 Loading 状态截图,基线必须明确表示加载态;如果测试想验证 Loaded,就必须让数据源可控并等待状态转换完成。

推荐通过构造函数注入静态数据:

class UserPage extends StatelessWidget {
  const UserPage({
    super.key,
    required this.user,
  });

  final User user;

  @override
  Widget build(BuildContext context) {
    return Text(user.name);
  }
}

而不是让 Golden 测试直接访问真实网络。真实网络会引入:

  • 返回数据不同;
  • 请求延迟不同;
  • 失败重试;
  • 图片下载时间不同;
  • 服务器内容变化。

同样,下面这些内容都应固定:

DateTime.now()
Random()
UniqueKey()
MediaQuery.of(context).platformBrightness

否则基线描述的不是组件规则,而是某次运行恰好得到的结果。

动画的中间帧

假设动画持续 500 毫秒,测试在启动后立即截图,捕获的可能是 0 毫秒、16 毫秒或其他时间点。即使代码完全不变,多次测试也可能得到不同图像。

可以选择:

  • 为 Golden 测试关闭动画;
  • 使用静态状态构造组件;
  • 通过 pump(Duration) 固定到指定时间;
  • 分别为动画起始态和结束态建立基线。

不要无条件使用:

await tester.pumpAndSettle();

如果存在无限旋转进度条,它不会进入 settled 状态。此时应直接测试加载态的静态表示,或显式推进有限时间并截取确定帧。


八、平台、渲染后端与基线分层

1. 为什么桌面和移动端不一定相同

相同 Flutter 代码在 Android、iOS、Windows、macOS、Linux 和 Web 上可能存在差异,来源包括:

  • 系统字体和字体回退;
  • 文本栅格化;
  • DPR;
  • 操作系统的色彩和合成行为;
  • Flutter Engine 版本;
  • Skia 或 Impeller 等渲染实现;
  • Web 浏览器和浏览器渲染管线;
  • 平台默认 padding、系统字体缩放和安全区域。

Flutter API 规定了 Widget 的语义和布局模型,但并不保证所有平台、所有引擎和所有字体都产生逐像素相同的 PNG。

因此应区分三种目标:

目标 A:组件级稳定视觉回归

在固定的 Flutter 测试环境中运行 VM Golden,主要验证 Widget 代码和主题变更。这通常最容易维护。

目标 B:多平台视觉契约

分别在 Android、iOS、桌面或 Web 的指定环境生成基线。例如:

goldens/
  linux/
  macos/
  android/
  ios/
  web/

每个目录中的基线都必须记录对应的 SDK、平台、字体和视口条件。

目标 C:真实设备截图验证

使用集成测试或专用截图测试在真实设备、模拟器或浏览器中截取屏幕。这更接近用户看到的结果,但执行成本、环境管理和审阅成本也更高。

不要把目标 A 的基线直接宣称为目标 C 的证明。

2. Web 的特殊边界

Web 还额外受到以下因素影响:

  • Chrome、Safari、Firefox 的文字渲染不同;
  • 浏览器缩放比例影响 DPR;
  • 字体加载和浏览器字体缓存存在差异;
  • Canvas、HTML 渲染器和浏览器合成方式不同;
  • 浏览器版本升级可能改变栅格化结果。

如果需要 Web Golden,应固定浏览器类型、版本、缩放比例、字体和窗口尺寸,并确认当前 Flutter 测试链路对目标 Web 平台支持所需的 Golden 比较方式。不能因为 Dart 测试命令能启动 Web 测试,就假设它与移动端 VM Golden 拥有相同的像素稳定性。

3. SDK 与引擎版本

Golden 图像是 Flutter Engine 输出的一部分。升级 Flutter 版本可能导致:

  • 默认 Material 样式变化;
  • 字体度量变化;
  • 文本抗锯齿变化;
  • 阴影或裁剪边缘变化;
  • Web 浏览器编译产物变化。

升级 SDK 后批量 Golden 失败,不应直接全部更新。应先选择少量代表性样例,确认变化来自预期的引擎或主题变化,再决定是否重新生成整批基线。


九、Golden 文件的路径与比较器

1. matchesGoldenFile 的作用

matchesGoldenFileflutter_test 提供的匹配器。典型调用方式是:

await expectLater(
  find.byType(ProfileCard),
  matchesGoldenFile('goldens/profile_card_light.png'),
);

执行过程可以理解为:

  1. 根据 Finder 找到目标 RenderObject;
  2. 将目标区域渲染为图像;
  3. 读取相对路径对应的 Golden 文件;
  4. 使用当前配置的 GoldenFileComparator 比较;
  5. 成功则断言通过,失败则生成诊断信息并使测试失败。

Golden 路径不是业务路由,也不是资源加载路径。它是测试比较器使用的文件路径。为了避免路径混乱,应让目录结构与测试文件和组件对应:

test/
  profile_card_golden_test.dart
  goldens/
    profile_card_light.png
    profile_card_dark.png

团队应统一约定路径语义,因为相对路径的解析和比较器配置属于测试环境的一部分。不要在不同测试中随意混合项目根目录、测试文件目录和临时目录语义。

2. 缺失基线与损坏基线

缺失基线的正确处理通常是:

  1. 普通测试运行,确认测试确实到达 Golden 断言;
  2. 检查目标路径是否正确;
  3. 使用 --update-goldens 生成;
  4. 审阅生成的图像;
  5. 将基线提交到版本控制。

如果 PNG 文件损坏、尺寸为零或不是预期图像,不应直接重新生成掩盖问题。先检查:

  • 文件是否被错误脚本截断;
  • 是否在合并冲突中损坏;
  • 是否把差异图误命名为基线;
  • 生成命令是否使用了错误的测试筛选条件。

3. 自定义比较器的取舍

Flutter 测试框架通过 GoldenFileComparator 抽象比较行为。工程可以替换全局比较器,以接入远程基线、平台目录或定制差异报告。

但自定义比较器会改变测试的语义和故障路径:

  • 基线从哪里读取;
  • 比较失败如何报告;
  • 是否生成差异图;
  • 并发运行时是否有临时文件冲突;
  • CI 中凭证或网络失败如何处理;
  • 比较器失败与产品视觉失败如何区分。

如果没有明确需求,不应为了“忽略几个像素”就引入全局宽松比较器。宽松阈值可能隐藏真实的细线、边框或文字错误,而且会影响所有测试。更安全的方式是先定位差异来源,再决定是否为某一平台维护独立基线或为特定组件采用专门的容差策略。


十、审阅:更新基线不是批准变更

1. 视觉变更的四种分类

一次 Golden 差异通常属于以下类别之一:

代码缺陷

例如:

padding: const EdgeInsets.all(16)

被错误改成:

padding: const EdgeInsets.all(8)

如果设计没有变化,应修复代码,而不是更新基线。

预期产品变更

例如设计确实要求按钮改为新的品牌色。此时应同时审阅:

  • 代码变更;
  • Golden 差异;
  • 设计或产品变更依据;
  • 受影响页面范围。

确认一致后,才使用 --update-goldens 更新。

渲染环境漂移

例如升级 Flutter、操作系统或字体后,所有文字边缘变化。此时更新基线可能是合理的,但提交说明应记录环境变化,否则未来无法判断这些图像为何变化。

测试不确定性

例如异步数据未固定或动画未停止。此时不能更新基线,因为新图像只是另一次时序结果。

2. 一个可审阅的变更流程

修改 Widget
   ↓
运行普通 Golden 测试
   ↓
读取失败输出并比较实际图、基线图、差异图
   ↓
判断:代码缺陷 / 预期设计变更 / 环境漂移 / 测试不稳定
   ↓
修复代码或固定环境
   ↓
仅在确认变更正确后执行 --update-goldens
   ↓
复跑普通测试
   ↓
提交代码与基线

其中最重要的约束是:开发者不能把“更新基线”作为失败恢复按钮。更新动作应当与代码变更在同一个审阅范围内出现,避免出现“图片变了,但没有任何代码解释”的提交。

3. 审阅差异时的具体问题

审阅者应至少回答:

  1. 差异是否与代码变更的区域一致?
  2. 差异是否扩大到无关页面?
  3. 文字是否发生换行、截断或字重改变?
  4. 颜色变化是否符合主题和设计令牌?
  5. 差异是单个平台特有,还是所有平台共有?
  6. 基线是否由固定的 SDK、字体和视口生成?
  7. 是否存在仅修改 Golden 文件而没有修改组件代码的情况?
  8. 差异图是否反映真实用户可见变化,还是仅为环境噪声?

如果只看到“测试通过”,而没有检查基线变化,Golden 测试实际上可能退化成“自动接受当前结果”。


十一、常见反例与为什么会失败

反例一:依赖默认窗口

testWidgets('golden', (tester) async {
  await tester.pumpWidget(const MyPage());

  await expectLater(
    find.byType(MyPage),
    matchesGoldenFile('goldens/my_page.png'),
  );
});

问题在于测试没有明确声明视口和 DPR。当前机器、测试绑定或未来框架默认值变化,都可能改变图像尺寸和布局结果。

修复方式是显式设置测试视图,并在结束后恢复。

反例二:直接使用系统字体

Text(
  'Flutter Golden',
  style: const TextStyle(
    fontFamily: 'SomeSystemFont',
  ),
)

问题在于 SomeSystemFont 在不同平台上可能不存在,或者文件版本和字形度量不同。最终可能发生字体回退。

修复方式是使用项目内字体,或者按平台分别维护基线,并在 CI 固定字体环境。

反例三:在网络请求完成前截图

await tester.pumpWidget(const RemotePage());
await tester.pump();

await expectLater(
  find.byType(RemotePage),
  matchesGoldenFile('goldens/remote_page.png'),
);

此时截图可能处于加载态,也可能因缓存恰好已经完成而处于内容态。测试没有定义要验证哪个状态。

修复方式是注入假数据源,显式选择并等待 LoadingLoadedError 状态。

反例四:无条件更新全部基线

flutter test --update-goldens

这个命令可能覆盖大量本来正确的基线。若变化原因是字体或视口误配置,仓库会把错误环境固化下来。

更安全的过程是先运行单个测试,确认差异原因,再限定测试文件或测试名称更新,最后执行普通测试确认结果。

反例五:认为少量像素差异一定可以忽略

一个 1 像素边框的变化可能只影响很少像素,却代表组件边界错误;一个阴影抗锯齿变化可能影响很多像素,却不一定是功能缺陷。

像素数量是诊断证据,不是自动批准条件。是否接受必须结合差异位置、视觉语义和运行环境判断。


十二、生产中的组织方式

1. 基线文件应进入版本控制

Golden 文件是测试输入的一部分,应与测试代码一起提交。这样代码审阅者可以看到:

  • 哪个组件发生了变化;
  • 变化范围多大;
  • 变化是否覆盖浅色和深色主题;
  • 是否只有某个平台受到影响。

不建议把基线只存放在开发者本地或 CI 缓存中,否则同一提交无法重现相同测试条件。

2. 固定生成环境

至少应记录并尽量固定:

  • Flutter SDK 版本;
  • Dart 版本;
  • 操作系统镜像;
  • 字体文件及其版本;
  • 测试视口尺寸;
  • devicePixelRatio
  • 主题配置;
  • Web 浏览器及版本(如果测试 Web);
  • 生成基线的命令。

其中有些配置可以写进代码,有些应由 CI 镜像或文档保证。代码固定了视口,不代表自动固定了字体和 Flutter Engine。

3. 组件级优先于整页级

组件级 Golden 更容易定位差异:

find.byType(ProfileCard)

整页 Golden 更接近用户最终看到的内容,但差异定位更困难。合理的组合通常是:

  • 对高复用组件测试多个状态;
  • 对少量关键页面测试完整组合;
  • 不为每一个页面状态都生成巨大的整页基线。

Golden 数量不是质量指标。每张基线都增加生成、审阅、存储和升级成本。

4. 资源与大图的成本

Golden PNG 可能较大。包含大量图片、阴影或高分辨率画布的测试会增加:

  • Git 仓库存储;
  • CI 下载时间;
  • 测试运行时间;
  • 差异审阅难度。

如果目标是验证卡片布局,应尽量使用固定的小尺寸和稳定的占位图片,而不是把真实网络图片和整张长页面带入测试。这样可以让失败原因更接近被测组件本身。


十三、把 Golden 测试设计成可解释的契约

一个可靠的 Golden 测试可以明确回答下面的问题:

  • 测什么状态? 加载、成功、失败、空数据还是禁用态?
  • 在哪个环境测? 视口、DPR、平台、字体和 SDK 是什么?
  • 使用什么主题? 浅色、深色、Material 2 还是 Material 3?
  • 图像从哪里来? 测试文件如何定位基线?
  • 失败后怎么看? 是否有实际图、基线图和差异输出?
  • 谁批准变化? 基线更新是否和代码、设计变更一起审阅?
  • 无法跨平台一致时怎么办? 是固定环境,还是维护平台独立基线?

当这些问题都有明确答案时,Golden 测试就不再是“截图测试”,而是一个可版本化的视觉契约:

视觉契约=Widget+状态+主题+字体+视口+渲染环境+审阅规则\text{视觉契约} = \text{Widget} + \text{状态} + \text{主题} + \text{字体} + \text{视口} + \text{渲染环境} + \text{审阅规则}

任何一项未固定,都可能成为差异来源;任何一次基线更新,都应说明是哪一项发生了有意变化。


结语

Flutter Golden 测试的比较对象是最终像素,但决定像素的并不只有 Widget 代码。基线必须绑定到确定的视口、设备像素比、字体、主题、状态和渲染环境;像素差异需要结合位置、范围和原因解释;Android、iOS、桌面和 Web 之间不能默认共享同一组逐像素基线;--update-goldens 只能在差异被确认后使用。

真正有价值的 Golden 测试不是拥有很多 PNG,而是能在失败时回答:哪里变了、为什么变了、是否应该变,以及这次变化由谁审阅通过。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。