← 返回蜂巢洞察

如何测试Flutter应用程序:单元测试、组件测试、黄金标准测试以及集成测试详解

第一次在技术面试中被问到“你的测试覆盖范围是多少?”时,我并没有一个令人满意的答案。 那时我已经发布了几款真正的Flutter应用程序,它们可以正常运行,用户也在使用它们。但我的测试工作其实非常有限——仅仅是为某个定价功能编写了少量的单元测试而已,并没有其他测试内容。 几个月后,我对其中一个任务完成流程进行了重构,这个修改在代码差异对比中看起来完全没问题,但却破坏了用户真正关心的一个功能:当用户将某项任务标记为已完成时,该任务并不会从错误的列表中移除。虽然没有任何程序崩溃,也没有任何错误日志被记录下来,但用户却开始不再信任这款应用程序。而我直到有用户把这个问题告诉了一位也是测试人员的朋友,才发

第一次在技术面试中被问到“你的测试覆盖范围是多少?”时,我并没有一个令人满意的答案。

那时我已经发布了几款真正的Flutter应用程序,它们可以正常运行,用户也在使用它们。但我的测试工作其实非常有限——仅仅是为某个定价功能编写了少量的单元测试而已,并没有其他测试内容。

几个月后,我对其中一个任务完成流程进行了重构,这个修改在代码差异对比中看起来完全没问题,但却破坏了用户真正关心的一个功能:当用户将某项任务标记为已完成时,该任务并不会从错误的列表中移除。虽然没有任何程序崩溃,也没有任何错误日志被记录下来,但用户却开始不再信任这款应用程序。而我直到有用户把这个问题告诉了一位也是测试人员的朋友,才发现了这个漏洞。

这才是测试真正要解决的问题——那些不会导致程序崩溃的故障,因为程序崩溃通常都会被报告出来。而那些在代码看起来正常的情况下悄悄出现的、会破坏用户体验的问题,只有通过专门的测试才能被发现。

从那以后,我又发布了几款应用程序,现在我在编写测试时也会更加有针对性:并不是所有功能都会进行测试,而是那些曾经给我带来麻烦的功能。

这篇文章介绍了Flutter提供的四种类型的测试方法(单元测试、组件测试、黄金测试和集成测试),并以一个具体的功能为例,详细说明了这四种测试在各个层面上的应用方式。采用这种讲解方式是因为,单独阅读这些分散的测试内容,我根本无法理解它们之间是如何相互关联的;只有将它们结合在一个具体的功能上来分析,才能真正理解它们的作用。

目录

先决条件

在开始学习本教程之前,您需要具备以下基础知识:

  • Dart和Flutter的基本语法:包括类、async/await以及如何构建简单的组件。需要注意的是,这不是一个从零开始学习Flutter的教程,它假设您已经能够创建屏幕界面了;也许只是还没有进行过正式的测试而已。

  • Provider/ChangeNotifier模式或类似的设计模式(如Riverpod、Bloc等):`TaskNotifier`继承自`ChangeNotifier`,因此教程中的示例假定您熟悉这种状态管理方式,即使您的实际应用可能采用了其他实现方案。

此外,您还需要安装并配置以下工具:

  • Flutter SDK(最新稳定版本即可。教程中的示例并不依赖任何实验性功能。)

  • 支持Flutter/Dart的开发工具(VS Code或Android Studio都适合使用。)

  • 用于集成测试的设备或模拟器。iOS模拟器或Android模拟器即可满足需求,无需真实设备。

  • 以下开发依赖库,虽然会在后续内容中具体介绍这些库的用途,但提前准备好它们会很有帮助:

  dev_dependencies:
    flutter_test:
      sdk: flutter
    integration_test:
      sdk: flutter
    mocktail: ^1.0.4
    golden_toolkit: ^0.15.0

如果您能在一个空项目中运行`flutter test`并且程序能够正常退出,那么您就可以开始学习了。

为什么需要四种类型的测试,而不仅仅是“测试”本身

我早期读过的所有Flutter测试教程都将“测试”视为一个统一的概念。但实际上并非如此。这四种类型的测试分别用于解答不同的问题,如果将它们混淆在一起,就会导致人们觉得某些测试步骤是多余的,或者浪费时间。

单元测试用于验证:对于给定的输入,这段代码是否能够产生正确的输出?在进行单元测试时,不需要使用任何组件、渲染效果或模拟设备,只需要一个函数或一个类,以及相应的断言语句即可。这类测试的运行速度非常快,必要时甚至可以同时运行数千次。

组件测试用于确认:在特定的状态下,这个组件是否能够正确地显示和运行?这些测试是在模拟环境中进行的,不需要真实的设备或像素。由于测试速度很快,因此每次保存代码后都可以立即进行测试;而且这类测试能够捕捉到诸如“请求失败时重试按钮没有出现”之类的问题。

黄金标准测试用于检查:组件的显示效果是否与预期一致?这种测试会将渲染后的组件图像与保存的参考图像进行逐像素对比。只有这种类型的测试才能发现诸如“内边距发生了变化”或“文本溢出了”这类肉眼可见的问题,因为普通的断言方法无法检测到这些异常。

集成测试的目的是确定:在真实设备或模拟设备上编译并运行的应用程序,其各个组件之间是否能够真正实现无缝协作、正常运行?这类测试的执行速度较慢,成本也相对较高,但在这四种测试类型中,只有集成测试能够发现那些仅存在于不同系统组件之间的交互过程中的错误——比如某个数据存储模块向通知机制返回了错误类型的数据,而该通知机制却仍能正确地处理这些数据。

这四种测试方法中没有哪一种可以替代其他方法。价格计算错误应该通过单元测试来发现;缺失的错误处理状态需要通过小部件测试来检查;布局问题则适合用集成测试来排查;而端到端流程出现故障时,自然应该使用集成测试来进行诊断。如果只使用其中的一种测试方法,那么就会有三类错误被遗漏而无法被发现。

我们正在测试的功能

为了使这个例子更具针对性,我们所测试的每一个功能都是基于同一个核心组件构建的:一个任务列表,用户可以在其中标记任务的完成状态,而完成任务的任务数量会实时反映在应用栏中。

// lib/task.dart
class Task {
  const Task({required this.id, required this.title, this.isDone = false});

  final String id;
  final String title;
  final bool isDone;

  Task copyWith({bool? isDone}) => Task(id: id, title: title, isDone: isDone ?? this.isDone);
}
// lib/task_repository.dart
abstract class TaskRepository {
  Future> fetchTasks();
  Future setTaskDone(String id, bool isDone);
}
// lib/task_logic.dart
// 这是一个在实际产品中出现的错误:当任务列表中包含来自多个不同来源的任务时,这个函数会使用错误的字段来进行过滤,
// 结果导致某些任务被错误地标记为已完成。如果对这个函数进行一次单元测试,就可以在产品发布之前发现这个问题。
int countCompleted(List tasks) => tasks.where((t) => t.isDone).length;

List markDone(List tasks, String id) => tasks
    .map((t) => t.id == id ? t.copyWith(isDone: true) : t)
    .ToList();
// lib/task notifier.dart
class TaskNotifier extends ChangeNotifier {
  TaskNotifier(this._repository);
  final TaskRepository _repository;

  List _tasks = [];
  bool isLoading = false;
  String? error;

  List get tasks => _tasks;
  int get completedCount => countCompleted(_tasks);

  Future load() async {
    isLoading = true;
    error = null;
    notifyListeners();

    try {
      _tasks = await _repository.fetchTasks();
    } catch (_) {
      error = '无法加载任务数据。请重试。';
    }

    isLoading = false;
    notifyListeners();
  }

  Future complete(String id) async {
    final previous = _tasks;
    _tasks = markDone(_tasks, id); // 进行乐观更新
    notifyListeners();

    try {
      await _repository.setTaskDone(id, true);
    } catch (_) {
      _tasks = previous; // 如果失败,则恢复之前的状态
      notifyListeners();
    }
  }
}
// lib/task_screen.dart
class TaskScreen extends StatefulWidget {
  const TaskScreen({super.key, required this.notifier});
  final TaskNotifier notifier;

  @override
  State createState() => _TaskScreenState();
}

class _TaskScreenState extends State {
  @override
  void initState() {
    super.initState();
    widget.notifier.load();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: widget.notifier,
      builder: (context, _) {
        final notifier = widget.notifier;

        return Scaffold(
          appBar: AppBar(title: Text('任务列表 (${notifier_completedCount}项已完成)')),
          body: notifier isLoading
              ? const Center(child: CircularProgressIndicator())
              : notifier.error != null
                  ? Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(notifier.error!),
                          const SizedBox(height: 8),
                          ElevatedButton(
                            onPressed: notifier.load,
                            child: const Text('重试'),
                          ),
                        ],
                      ),
                    )
                  : ListView(
                      children: notifier.tasks
                          .map((task) => CheckboxListTile(
                                key: ValueKey(task.id),
                                title: Text(task.title),
                                value: task.isDone,
                                onChanged: task.isDone
                                    ? null
                                    : (_) => notifier.complete(task.id),
                              ))
                          .ToList(),
                    ),
        );
      },
    );
  }
}

这就是这个功能的全部内容。现在,让我们用四种不同的方式来测试它。

单元测试:独立验证业务逻辑

countCompletedmarkDone都是纯粹的Dart函数,完全不依赖于Flutter框架:它们不需要BuildContext、组件,也不需要任何测试设备。这样设计是有意为之的——如此重要的逻辑,根本不应该依赖渲染引擎来进行验证。

// test/task_logic_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/task.dart';
import 'package:my_app/task_logic.dart';

void main() {
  group('countCompleted', () {
    test('对于空列表,应返回0', () {
      expect(countCompleted([]), 0);
    });

    test('只计算被标记为“已完成”的任务', () {
      final tasks = [
        const Task(id: '1', title: 'A', isDone: true),
        const Task(id: '2', title: 'B', isDone: false),
        const Task(id: '3', title: 'C', isDone: true),
      ];

      expect(countCompleted(tasks), 2);
    });
  });

  group('markDone', () {
    test('只标记id匹配的任务', () {
      final tasks = [
        const Task(id: '1', title: 'A'),
        const Task(id: '2', title: 'B'),
      ];

      final result = markDone(tasks, '2');

      // 这是一个关键的测试用例——正是我曾经犯过的错误。
      // 如果实现方式过于简单,且判断依据错误,那么就会错误地标记任务‘1’为已完成,
      // 这样这个测试会立即失败,而不会在用户报告问题三周后才被发现。
      expect(result.firstWhere((t) => t.id == '1').isDone, false);
      expect(result.firstWhere((t) => t.id == '2').isDone, true);
    });

    test('如果id不存在,应返回未改变的列表', () {
      final tasks = [const Task(id: '1', title: 'A')];
      final result = markDone(tasks, 'nonexistent');

      expect(result.first.isDone, false);
    });
  });
}

运行这些测试的命令是:

flutter test test/task_logic_test.dart

每个测试的执行时间都只有几毫秒。完全没有理由不编写这样的测试用例——编写这类测试的成本几乎为零,而且恰恰是在这个层面,一些错误的假设才可能悄悄地被部署到生产环境中。因为当逻辑出现细微错误时,什么都不会发生明显的变化;例如,复选框仍然可以正常切换状态,只不过它可能会错误地选中另一个任务。

有一个习惯值得尽早养成:使用group和参数化循环,而不是重复编写几乎完全相同的测试代码。我以前曾经为同一个函数的五种边界情况分别编写了五个几乎一模一样的测试函数,而在进行代码重构后,其中必然会有一个测试函数与其他函数出现不一致的情况。

group('使用不同的id来测试markDone功能', () {
  final cases = {
    '1': true,   // 存在,应该被标记为已完成
    '2': false,  // 存在,id不同,应保持不变
    'x': false,  // 不存在,应该什么都不做
  };

  for (final entry in cases.entries) {
    test('当id为${entry.key}时,结果应为isDone=${entry.value}', () {
      final tasks = [
        const Task(id: '1', title: 'A'),
        const Task(id: '2', title: 'B'),
      ];
      final result = markDone(tasks, '1');
      final target = result.where((t) => t.id == entry.key);

      if (target.isEmpty) {
        // 当id为‘x’时,列表应保持不变
        expect(result.length, tasks.length);
      } else {
        expect(target.first.isDone, entry.value);
      }
    });
  }
});

在某些情况下,这样做并非绝对必要,但一旦一个函数需要测试五六个不同的情况,使用循环结构就能使代码的逻辑更加清晰易懂,这样添加第六种测试情况也只需要修改一行代码而已,而不会导致出现复制粘贴测试代码后却忘记及时更新的情况。

测试异步逻辑与异常处理

在真实的应用程序中,大多数有趣的逻辑都不是同步执行的函数。这些逻辑往往是异步的,而且也可能会出错。flutter_test中的test()函数能够原生地处理返回Future类型的代码,这使得相关测试操作比人们想象的要简单得多。但在这一功能成为自动化工具之前,我曾经反复犯过两个错误。

test('当任务ID不存在时,setTaskDone会抛出异常', () async {
  final repository = FakeTaskRepository();

  // expect()与throwsA结合使用适用于同步执行的异常处理场景;
  //但对于那些会以错误方式完成的异步操作,需要使用expectLater与throwsA结合,或者使用下面的异步匹配器形式。
  await expectLater(
    () => repository.setTaskDone('nonexistent', true),
    throwsA(isA。)(),
  );
});

test('当没有任务需要获取时,fetchTasks应该返回一个空列表,而不是null', () async {
  final repository = FakeTaskRepository(seed: []);

  final result = await repository.fetchTasks();

  // 这个测试看起来很简单,但实际上我曾经在某个组件中编写了这样的代码:直接检查result是否为null,而没有考虑到当repository为空时应该返回一个空列表。其实只需这一行代码就能避免错误发生。
  expect(result, isEmpty);
  expect(result, isNotNull);
});

在我刚开始进行测试的时候,最常犯的一个错误就是编写expect(() => someAsyncFunction(), throwsA(...))这样的代码,却忘记了在前面加上await。由于被测试的函数是异步执行的,因此异常会在一个尚未完成的Future对象中抛出,而当同步执行的expect()函数运行时,这个异常并不会被真正处理。这样一来,即使代码存在错误,测试也会通过——因为实际上并没有等待异常的发生。正确的做法是使用expectLater结合await来模拟异常情况。

组件测试:在没有设备的情况下测试用户界面

TaskScreen组件无论是在加载数据、显示错误信息还是展示实际数据时,都必须能够正确地呈现界面。为了实现这一点,我们需要使用一个模拟的TaskRepository》对象,而无需进行真实的网络请求。mocktail是目前Dart语言中用于此类测试的标准工具,因为它不需要像旧版本的模拟框架那样生成额外的代码。

dev_dependencies:
  mocktail: ^1.0.4
// test/task_screen_test.dart import 'packageflutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:my_app/task.dart'; import 'package:my_app/taskNotifier.dart'; import 'package:my_app/task_repository.dart'; import 'package:my_app/task_screen.dart'; class MockTaskRepository extends Mock implements TaskRepository {} void main() { late MockTaskRepository repository; setUp(() { repository = MockTaskRepository(); }); testWidgets('在获取数据时显示加载指示器', (tester) async { // 一个永远不会完成的Completer会使得相关组件一直处于加载状态,直到这个测试结束。 repository.fetchTasks; // 通过when()方法注册了这个操作 when(() =&> repository.fetchTasks()) .thenAnswer((_) =&> Completer>&().future); await tester.pumpWidget(MaterialApp( home: TaskScreen(notifier: TaskNotifier(repository)), }); // pump()方法会更新界面一次,这样就能看到加载状态,而无需等待所有操作完成。 await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); }); testWidgets('数据获取完成后显示任务列表', (tester) async { when(() =&> repository.fetchTasks()).thenAnswer( (_) async =&> [ const Task(id: '1', title: '买牛奶'), const Task(id: '2', title: '遛狗', isDone: true), ], ); await tester.pumpWidget(MaterialApp( home: TaskScreen(notifier: TaskNotifier(repository)), }); // pumpAndSettle方法会等待所有操作完成,这样就可以检查最终的结果了。 await tester.pumpAndSettle(); expect(find.text('买牛奶'), findsOneWidget); expect(find.text('任务列表 (1项已完成)', findsOneWidget); }); testWidgets('在出现错误时显示错误信息,并提供重试按钮', (tester) async { when(() =&> repository.fetchTasks()).thenThrow(Exception('网络错误')); await tester.pumpWidget(MaterialApp( home: TaskScreen(notifier: TaskNotifier(repository)), ); await tester.pumpAndSettle(); expect(find.text('无法加载任务,请重试。'), findsOneWidget); // 现在让重试操作成功,确认点击“重试”按钮后确实能够恢复任务列表。 when(() =&> repository.fetchTasks()) .thenAnswer((_) async =&> [const Task(id: '1', title: '买牛奶')]); await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.text('买牛奶'), findsOneWidget); expect(find.text('无法加载任务,请重试。'), findsNothing); }); testWidgets('完成任务后会更新已完成的任务数量', (tester) async { when(() =&> repository.fetchTasks()).thenAnswer( (_) async =&> [const Task(id: '1', title: '买牛奶')] ); when(() =&> repository.setTaskDone('1', true)).thenAnswer((_) async {}; await tester.pumpWidget(MaterialApp( home: TaskScreen(notifier: TaskNotifier(repository)), }); await tester.pumpAndSettle(); expect(find.text('任务列表 (0项已完成)', findsOneWidget); await tester.tap(find.byType(CheckboxListTile)); await tester.pumpAndSettle(); expect(find.text('任务列表 (1项已完成)', findsOneWidget); });

重试测试才是真正需要重点关注的部分。人们很容易只看到“出现了重试按钮”这一结果,但这仅仅证明了按钮的存在,并不能证明点击它之后确实会产生什么效果。只有继续进行测试,验证应用程序在重试后的状态是否恢复正常,才能确保重试按钮没有被错误地连接到错误的回调函数上——这种错误其实非常容易发生。

常见的Widget测试错误

我曾经犯过所有这些错误,而且通常还不止一次。

1. 当你本应使用pumpAndSettle()时,却使用了pump(),或者反过来也是如此。

pump()只会让界面向前推进一帧。而pumpAndSettle()则会不断推进界面帧数,直到没有更多的帧需要更新为止——这正是异步操作完成后应该发生的情况。但是,如果Widget树中有一些元素在持续进行动画播放(比如CircularProgressIndicator),那么pumpAndSettle()就会无限循环下去,最终导致程序超时。

有一次,我花了整整二十分钟才弄清楚为什么测试会超时,后来才发现,正是因为加载指示器在不断进行动画播放,才导致了pumpAndSettle()无法完成动画更新。

对于这种情况,解决办法是:当被测试的Widget中包含那些会持续动画播放的元素时,应该使用pump()来指定要推进的帧数,或者使用pump(duration)来指定动画执行的时长,而不是使用pumpAndSettle()

// 如果Widget树中包含CircularProgressIndicator,这个代码就会导致超时,
// 因为该组件会无限循环进行动画播放,永远不会停止。
await tester.pumpAndSettle();

// 而使用以下代码则可以指定推进固定数量的帧数——
// 当你想要在加载过程尚未完成时就捕获相关状态时,这种方法非常适用。
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));

2. 当使用Key作为识别依据会更稳定时,却选择通过文本来查找Widget。

find.text('Buy milk')这种查找方式会在产品描述发生变化时出错,或者在未来的测试中,如果两个任务的名字相同,也会导致错误。现在,我会为所有需要可靠识别的元素指定键值对,就像上面的CheckboxListTile是使用ValueKey(task.id)来识别的那样:find.byKey(const ValueKey('1'))这种查找方式不会受到任务名称变化的影响。

3>忘记了一点:任何涉及到Theme.of(context)Navigator的Widget测试,实际上都被MaterialApp所包裹着。

如果没有使用MaterialApp作为父组件,直接调用pumpWidget(TaskScreen(...))时,第一次尝试执行与DirectionalityNavigator相关的操作时,就会出现“缺少这些组件”的错误提示。不过,在刚开始遇到这种错误时,人们往往不会立刻意识到应该用MaterialApp来包装相关代码。

测试文本输入与滚动功能

有两种交互行为出现得非常频繁,因此值得专门用示例来讲解:在字段中输入内容,以及滚动页面以查看屏幕外的内容。

testWidgets('在字段中输入内容并提交会触发相关操作', (tester) async {
  final repository = MockTaskRepository();
  when(() => repository.fetchTasks()).thenAnswer((_) async => []);
  when(() => repository.addTask(any)).thenAnswer((_) async {};

  await tester.pumpWidget(MaterialApp(
    home: TaskScreen(notifier: TaskNotifier(repository)),
  );
  await tester.pumpAndSettle();

  // enterText方法可以模拟直接输入文本的操作——在绝大多数测试中,没有必要模拟每次按键的动作。
  await tester.enterText(find.byKey(const Key('new_task_field')), '买牛奶');
  await tester.tap(find.byKey(const Key('add_task_button'));
  await tester.pumpAndSettle();

  verify(() => repository.addTask('买牛奶').called(1);
});

testWidgets('滚动页面可以显示屏幕下方的任务', (tester) async {
  final repository = MockTaskRepository();
  when(() => repository.fetchTasks()).thenAnswer(
    (_) async => List.generate(
      30,
      (i) => Task(id: '$i', title: '任务 $i'),
    ),
  );

  await tester.pumpWidget(MaterialApp(
    home: TaskScreen(notifier: TaskNotifier(repository)),
  );
  await tester.pumpAndSettle();

  // 在一个包含30项任务的列表中,第25项在首次渲染时是显示在屏幕外的。
  expect(find.text('任务25'), findsNothing);

  // scrollUntilVisible方法会反复滚动固定距离,并在每次滚动后检查页面内容——当不知道需要滚动多远才能看到特定项目时,这个方法非常有用。
  await tester.scrollUntilVisible(
    find.text('任务25'),
    500.0,
    scrollable: find.byType(Scrollable),
  );

  expect(find.text('任务25'), findsOneWidget);
});

黄金测试:捕捉视觉回归问题

到目前为止,所有的测试都在检查行为是否正确:比如正确的文本是否出现了,数量是否得到了准确的更新。然而,这些测试无法发现那些会导致复选框列表在小屏幕上超出容器范围的问题,也无法察觉那些会使得重试按钮移出屏幕范围的细节修改。这就是为什么需要黄金测试的存在。

黄金测试会将某个组件渲染出来,然后将其每一像素都与保存好的参考图像进行对比。

// test/task_screen_golden_test.dart
import 'package:flutter/material.dart';
import 'packageflutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_app/task.dart';
import 'package:my_app/taskNotifier.dart';
import 'package:my_app/task_repository.dart';
import 'package:my_app/task_screen.dart';

class MockTaskRepository extends Mock implements TaskRepository {}

void main() {
  testWidgets('带有数据的任务页面应与黄金参考文件一致', (tester) async {
    final repository = MockTaskRepository();
    when(() => repository.fetchTasks()).thenAnswer(
      (_) async => [
        const Task(id: '1', title: '买牛奶'),
        const Task(id: '2', title: '遛狗', isDone: true),
      ],
    );

    await tester.pumpWidget(MaterialApp(
      home: TaskScreen(notifier: TaskNotifier(repository)),
    );
    await tester.pumpAndSettle();

    // 在第一次运行时,这个测试会生成参考图像;之后每次运行如果有任何像素不同,这个测试就会失败。
    await expectLater(
      find.byType(TaskScreen),
      matchesGoldenFile('goldens/task_screen_with_data.png'),
    );
  });

  testWidgets('任务页面的错误状态应与黄金参考文件一致', (tester) async {
    final repository = MockTaskRepository();
    when(() => repository.fetchTasks()).thenThrow(Exception('error'));
    }

    await tester.pumpWidget(MaterialApp(
      home: TaskScreen(notifier: TaskNotifier(repository)),
    );
    await tester.pumpAndSettle();

    await expectLater(
      find.byType(TaskScreen),
      matchesGoldenFile('goldens/task_screen_error.png'),
    );
  });
}

使用以下命令生成初始参考图像:

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

将生成的〈.png〉文件与测试代码一起提交。从此之后,执行〈flutter test〉命令时会进行对比检查,而不会重新生成参考图像。如果后续的修改导致某个像素的位置发生变化,测试就会失败,并会显示差异信息,这样就不会等到三轮迭代后才发现真实设备上的界面显示有异常。

在大量依赖这一机制之前,有两点需要注意。首先,不同机器或持续集成工具在字体渲染方面可能存在细微差异,这可能会导致一些与代码无关的错误出现。在一致的Docker环境中运行参考图像生成脚本,或者使用像〈golden_toolkit〉这样的工具(该工具可以统一字体加载方式),就能解决大部分这类问题。

其次,在开发过程中屏幕布局经常发生变化时,维护这些参考图像会非常耗时。因此,我只会为那些稳定性较高、需要重点测试的界面生成参考图像,而不是为每一个布局调整都重新生成它们,因为这样做反而违背了生成参考图像的初衷。

多设备及深色模式下的参考图像生成

单个参考图像仅能证明在某一屏幕尺寸和某一主题下界面显示是正确的。我曾经就是这样发现一个错误的:在一个标准尺寸的手机上,任务标题能够正常显示,但在小屏幕设备上却会超出显示范围9像素,直到有使用旧款窄屏手机的用户提交支持请求,我们才发现了这个问题。

〈golden_toolkit〉中的〈multiScreenGolden〉功能可以在一次测试中同时检测多个设备尺寸下的界面显示效果,这也是我在所有需要生成参考图像的测试中都会使用的方法:

devDependencies:
  golden_toolkit: ^0.15.0
testGoldens('task screen across device sizes', (tester) async {
  final repository = MockTaskRepository();
  when(() => repository.fetchTasks()).thenAnswer(
    (_) async => [const Task(id: '1', title: 'Buy milk, eggs, and bread')]
  );

  final builder = DeviceBuilder()
    ..overrideDevicesForAllScenarios(devices: [
      Device.phone,       // 窄屏设备——正是这种设备发现了文本显示超出的问题
      Device.iphone11,
      Device.tabletLandscape,
    ])
    ..addScenario(
      widget: MaterialApp(home: TaskScreen(notifier: TaskNotifier(repository))),
      name: 'with data',
    );

  await tester.pumpDeviceBuilder(builder);
  await screenMatchesGolden(tester, 'task_screen_multi_device');
});

如果你的应用程序支持深色模式,那么也应该为深色模式生成参考图像。如果某个文本颜色在深色背景下无法被显示出来,那就会成为一个非常尴尬的错误;而如果所有的参考图像生成测试都只在浅色模式下进行,那么这种问题就完全不会被发现。

testGoldens('task screen in dark mode', (tester) async { final repository = MockTaskRepository(); when(() => repository.fetchTasks()).thenAnswer( (_) async => [const Task(id: '1', title: 'Buy milk')] ); await tester.pumpWidgetBuilder( TaskScreen(notifier: TaskNotifier(repository)), wrapper: materialAppWrappertheme: ThemeData.dark()), ); await tester.pumpAndSettle(); await screenMatchesGolden(tester, 'task_screen_dark_mode'); });

防止金色测试成为维护负担

我在多个团队中都观察到过这样的情况:起初人们会热情地添加许多金色测试用例,但后来某个合理的设计变更影响了十五个界面中共同使用的组件,结果这十五个金色测试用例全部失败了。团队随后直接执行了`--update-goldens`命令,却并没有仔细检查每一处代码差异。毕竟,在截止日期的压力下,逐一审查十五份代码差异显然显得很不值得。

就在这一刻,金色测试用例就不再能起到保护作用了。因为从那时起,团队的反应往往是“重新生成测试用例然后继续工作”,而不是“仔细查看发生了哪些变化,并确认这些变化确实是故意为之的”。

有两条措施可以避免这种情况的发生。首先,要确保金色测试用例的数量控制在较少范围内,并且要精心挑选这些测试用例。我之前已经提到过这一点,但这里还是需要再次强调:应该选择五到六个具有高价值的界面进行测试,而不是五十个。

其次,当有一批金色测试用例失败时,应该把这视为一个信号,认真查看相关的代码差异,而不仅仅是简单地勾选某个复选框来忽略这些错误。大多数用于执行金色测试的持续集成系统都允许将代码差异文件作为构建结果上传,这样审查人员就可以在拉取请求中快速查看这些差异文件,而不需要先在本地下载相关分支。

集成测试:端到端的整体应用测试

单元测试和组件测试是在模拟的Dart环境中运行的。在这种环境下并没有真正的渲染引擎或平台环境,网络连接也是通过虚拟的仓库来实现的。正因为如此,这些测试的执行速度很快,但同时也正是这种环境使得它们无法检测出这样一个问题:当应用程序的所有组件真正被连接在一起时,它在真实的设备或模拟器上是否能够正常运行。

dev_dependencies:
  integration_test:
    sdk: flutter
// integration_test/complete_task_test.dart
import 'packageflutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('用户能够加载任务并完成整个流程', (tester) async {
    // 这里会运行实际的应用程序代码——无论是真实的应用程序本身,还是与之连接的后端服务,
    // 在这个构建环境中通常是在预发布环境下进行测试。
    app.main();
    await tester.pumpAndSettle();

    expect(find.text('Buy milk'), findsOneWidget);
    expect(find.textContaining('0 done'), findsOneWidget);

    await tester.tap(find.byType(CheckboxListTile).first);
    await tester.pumpAndSettle();

    expect(find.textContaining('1 done'), findsOneWidget);
  });
}

你可以在真实的设备或模拟器上运行这个测试:

flutter test integration_test/complete_task_test.dart

这种测试方式的速度较慢(耗时以秒而非毫秒为单位),因为它是真正在编译并运行整个应用程序,而不是模拟某个界面结构。正是由于这种测试需要消耗一定的资源,因此集成测试应该重点覆盖那些一旦出现故障就会造成严重后果的操作流程,比如完成购买操作或登录流程——这些才是应用程序存在的根本目的,也就是让用户能够执行的核心功能。而在一个实际的项目中,我会进行五到六次这样的测试,以便在用户真正使用应用程序之前,了解那些需要重点关注的操作流程。

不稳定现象、重试机制与真实设备

与其他三种类型的测试不同,集成测试常常会以一些与错误无关的原因导致失败:例如,CI模拟器的运行速度比平时慢, staging环境中的网络请求耗时超过了预期,或者在下一个操作执行之前动画效果尚未完成等等。所有这些情况导致的失败,都与应用程序是否真正能够正常工作毫无关系。

有一个习惯让我避免了很多麻烦:即使看起来有些多余,我也从不直接将tester.tap()与另一个交互操作连接起来,而一定会在中间添加pumpAndSettle()(或显式的pump(duration))。这个习惯对我帮助很大。

// 不稳定的情况:如果tester.tap()触发了某些异步操作(比如网络请求或动画效果),那么在这些操作完成之前,下一个find()操作就会执行,从而导致测试结果无法预测。
await tester.tap(find.byType(CheckboxListTile).first);
expect(find.textContaining('1 done'), findsOneWidget);

// 可靠的做法:要确保tester.tap()触发的所有操作都完成之后,再检查测试结果。
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(find.textContaining('1 done'), findsOneWidget);

对于CI测试来说,使用真实设备来进行集成测试能够发现一些模拟器完全无法捕捉到的错误:比如权限对话框的行为差异、摄像头或生物识别功能的异常反应,以及只有在真实硬件环境下才会出现的内存压力问题。不过,这种测试方式也是最耗时且成本最高的,因此我通常会定期进行这类测试(比如每晚或在发布新版本之前),而不是在每次代码提交后都执行。

如果你的应用程序足够复杂,需要使用更高级的集成测试工具来处理复杂的逻辑流程(如原生权限管理、生物识别功能的模拟测试等),那么patrol这个工具值得你考虑。它是在integration_test的基础上开发的,增加了许多基础包所没有的功能。

各种测试类型的实际作用所在

在使用这种四层测试架构开发了几款应用程序之后,我总结了如下分配测试资源的策略及其原因:

单元测试应得到充分重视。编写和运行单元测试的成本几乎为零,而且它们是唯一能够在错误产生之前就将其发现的测试层次。任何涉及复杂逻辑的计算、过滤操作或业务逻辑处理,都应该进行单元测试。

对于那些具有多种状态界的界面,都需要进行Widget测试。界面的加载状态、错误状态和成功状态分别对应不同的代码执行路径,而错误很可能隐藏在这些不同的状态下。如果一个界面只有一种状态,那么进行Widget测试的意义就不大;但如果该界面有三种状态,那么忽略其中两种状态的测试,就相当于忽略了该界面三分之二的实际功能表现。

“黄金测试”应当谨慎且有针对性地使用。我会将这类测试保留用于那些一旦出现视觉上的异常变化就会造成严重后果的界面,比如结账流程页面,或是用户每次使用应用时都会看到的核心界面。我不会在应用程序中的每一个界面都使用这种测试方法,因为进行这样的测试需要投入大量的维护成本,而且并不是所有的界面布局都值得被固定下来、不被修改。

针对那些决定应用程序功能的核心流程,需要进行集成测试。这些测试并不追求全面性,但足以确保当所有组件真正连接在一起时,应用程序仍能正常运行。

逐渐破坏测试套件的错误

这些错误并不会立即导致构建失败,但如果不加以解决,它们会让测试套件变得越来越不可靠。就像一个结构混乱的代码库一样,虽然不会在任何一天直接引发故障,但每个月都会变得越来越难以修改。

第一个常见的错误是:对实际需要测试的对象进行模拟。我曾经看到过这样的“单元测试”:它对HTTP客户端进行了极其彻底的模拟,以至于这种测试实际上只是在验证Dio框架自带的客户端是否按照其文档中描述的方式运行而已。如果当代码中存在真实故障时,测试依然能够通过,那么这种测试就根本无法真正检验你的代码。

第二个错误是:因为设置起来比较麻烦,所以故意跳过那些可能引发故障的测试路径。在本文中提到的每一个测试场景,都会出现加载状态、错误状态和成功状态。我见过一些团队(包括我自己在早期也这么做过)只编写针对成功状态的测试,因为这些测试更容易设置。然而,错误状态恰恰是最有可能存在真实故障的地方,因为在手动测试过程中,开发者很少会去测试这些状态。

将那些偶尔会失败但重试后又能通过的测试视为需要“重新尝试”而非“修复”的对象,也是一个错误的做法。如果一个测试在二十次执行中有一次会失败,但在重试后却能通过,那么它并不是“偶尔出现故障”,而是你在代码中存在某种竞争条件问题,或者是你的测试在时间计算方面存在假设错误。如果使用持续集成工具的自动重试机制来掩盖这些问题,整个团队就会逐渐不再信任那些显示为红色的构建结果,而这个问题的严重性远远超过那个偶尔失败的测试本身。

最后,另一个错误的做法是编写那些只检验实现细节而非行为结果的测试。例如,如果一个测试检查的是`notifier._tasks.length`(一个私有字段),而不是`notifier.tasks.length`或渲染后的用户界面,那么这个测试就与一些本不应该被直接测试的内部结构紧密相关。一旦你对这些内部实现进行了重构,但实际的行为并没有改变,这个测试就会失败,而这种失败的原因与任何真正的故障都无关。

整体运行所有测试

一个`Makefile`或持续集成脚本,如果能够按顺序执行这四种类型的测试,并且先执行成本较低的测试,那么就能在那些成本较高的测试开始之前发现大多数问题:


# 首先运行这些逻辑测试,以便尽快发现逻辑错误。
flutter test test/task_logic_test.dart

# 然后测试所有界面状态下的组件行为。
flutter test test/task_screen_test.dart

# 接着检查关键界面的视觉效果是否发生变化。
flutter test test/task_screen_golden_test.dart

# 最后,在真实的设备或模拟器上运行集成测试,因为这个过程最耗时。
flutter test integration_test/complete_task_test.dart
在持续集成流程中,我会在每次收到拉取请求时执行前三个测试步骤。这些测试的执行速度足够快,因此完全没有理由不进行这些测试。而集成测试则会在代码合并到主分支时或每晚自动运行——因为这种测试需要真实的设备或模拟器来执行,而且耗时较长;如果让每个拉取请求都等待这种测试完成,就会耽误整个团队的工作进度。不过,对于大多数漏洞来说,一个完善的组件测试套件就已经能够捕捉到大部分问题了。

端到端:在一个持续集成流程中完成所有四层测试

下面是我在实际项目中如何将这些测试步骤集成到GitHub Actions中的。首先会执行那些快速测试,而且这些测试是互锁的——也就是说,如果在任何一步测试中出现失败,整个流程就会立即停止,从而避免在后续步骤上浪费时间:
# .github/workflows/test.yml
name: 测试

on: [pull_request, push]

jobs:
  unit-and-widget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter pub get
      # 单元测试和组件测试一起执行——这两种测试都很快,而且会在每个拉取请求时自动运行,无需考虑成本问题。
      - run: flutter test test/task_logic_test.dart test/task_screen_test.dart

  golden:
    runs-on: ubuntu-latest
    needs: unit-and-widget
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter pub get
      - run: flutter test test/task_screen_golden_test.dart
      # 上传测试结果差异文件,这样审核人员就可以清楚地看到哪些地方发生了变化,而无需在本地拉取相关分支——这一措施能够确保“黄金标准”测试中的失败案例不会被简单地忽略或标记为“已修复”。
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: golden-diffs
          path: test/failures/

  integration:
    runs-on: macos-latest # 需要iOS模拟器;否则可以使用ubuntu和Android模拟器
    needs: golden
    if: github.ref == 'refs/heads/main' # 仅在代码合并到主分支时执行,而不是每次收到拉取请求时都运行
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter pub get
      - run: flutter test integration_test/complete_task_test.dart -d "iPhone 15"
needs:链以及集成测试步骤中的if:条件起到了关键作用:它们能够阻止那些耗时最长、成本最高的测试在每次代码推送时都被执行,同时又能确保这些测试在代码合并到主分支之前一定能够完成。

最后的思考

我曾经认为“测试”只是一项简单的任务,要么进行测试,要么不进行测试,只不过是仪表板上显示的一个百分比而已。但实际上并非如此。单元测试用于验证代码逻辑的正确性,组件测试用于检查各个组件的正常运行状态,“黄金标准”测试用于确保所有界面元素的外观正确无误,而集成测试则用来确认整个系统在真实设备上能否正常协同工作。 一旦你在某个具体功能上完成了这些测试环境的搭建,后续编写这些测试代码其实并不困难。正因如此,我在撰写这篇文章时选择围绕一个具体的功能来进行讲解,而不是将内容拆分成四个相互独立的章节。本文最初提到的那个任务完成逻辑错误的问题(也就是某个任务被错误地标记为已完成),实际上只需要在markDone方法上编写一条单元测试代码就能发现这个问题,而我在开始编写组件测试代码之前就已经完成了这条单元测试的编写。

我第一次编写测试代码时并没有编写那个测试用例。现在我才把它写出来,同时也为每一个重要的功能都编写了相应的测试用例。其实整个关键就在于这一点:并不是说进行测试本身有什么复杂之处,而是这四种类型的测试各自都在回答其他三种类型无法解决的问题。

相关文章

技术实践

如何从大型语言模型中获取可靠的结构化数据

大多数关于如何调用语言模型的教程都会在 JSON.parse(response.content) 这行代码处结束。这段代码在处理前十个测试用例时确实可以有效运行。但当你开始实际应用时,会在第400次左右的一次请求中遇到问题:模型可能会返回一个它自己编造出来的日期,或者当你的数据结构应该包含5个元素时却返回8个数组项,又或者返回一个格式完全正确但实际上缺少某个字段的JSON对象。 我在开发Temploracraft这个简历工具时遇到了这样的问题。这个工具会接收用户上传的文档,并将其转换成应用程序可以编辑的结构化数据。 输入的数据确实具有很大的不可预测性:有些是两列结构的PDF文件,有些表格其实并

阅读全文
技术实践

使用OpenTelemetry实现Claude Code的可观测性

像 Claude Code 、 OpenAI Codex 、 Google Antigravity 以及 Cursor 这样的代理编码工具,在日常软件开发中已经变得无处不在。 随着代理系统的不断发展,开发者让这些系统完成的大部分工作都是通过逐个分配子任务来实现的。许多团队也在探索并使用共享的、多租户式的代理基础设施,这种架构的成本不会与某个特定的所有者挂钩。在这种情况下,可观测性就成为了监控基础设施成本的关键因素。 在本指南中,您将了解可观测性的工作原理,然后学习如何启用Claude Code内置的遥测功能,运行后端程序来收集数据,并读取该系统生成的各类指标、日志及追踪信息。这些内容将帮助您更

阅读全文
技术实践

如何在重构旧代码之前设计相应的特性测试用例

许多工程师在继承遗留代码后,首先想要做的就是改进这些代码。 你会遇到一些难以理解的函数,或者看到重复的逻辑、深度嵌套的条件语句、混合了业务规则的数据库调用,以及那些使得测试几乎无法进行的依赖关系。 你知道这些代码本可以做得更好,于是开始对其进行优化。 然而,随后就会出现问题。并不是因为新的实现方式存在明显的错误,而是因为旧的实现方式在背后做了某些人们之前根本不知道它在做的事情。 这就是在遗留代码现代化过程中最常出现的风险之一。 在修改代码之前,你需要有一种方法来回答一个简单的问题: 我是否保留了那些原本就重要的功能行为? 这时,特性测试就派上了用场。 特性测试并不是从“软件应该做什么”这个角度

阅读全文
技术实践

了解人工智能软件开发生命周期流程——构建智能代理功能的完整指南

也许你可以理解这样的场景:本周,你用了同样的说明四次向别人解释人工智能模型的使用方法。 你反复讲解过团队是如何构建演示文稿的框架的,哪些检查步骤需要在部署之前完成,以及为什么测试数据库并不是文档中提到的那个。 每次你都要把这些内容重新写一遍,每次智能助手也能完成得不错,但每次新的会话开始时,一切都得从零开始。 而这正是 智能助手技能 所要解决的问题。 技能 实际上就是一个文件夹,其中只包含一个名为 Skill.md 的文件。智能助手在启动时会阅读其中的一行总结内容,而只有当真正需要时才会打开完整的说明文件。你只需把解释内容编写一次,将其与代码一起提交,那么团队中的每个智能助手就能访问这些信息,

阅读全文