Kodokon kodokon.com

测试:Dart 单元测试与 widget 测试

用手写的假实现、模拟的时钟和正确的 pump 直觉来测试逻辑与 widget。

11 分钟 · 3 题

在 Kodokon 中打开本课

Flutter 的测试金字塔:一个由纯 Dart 单元测试构成的宽阔底座、一层 widget 测试、以及少数几个集成测试。单元测试绝不应该导入 material.dart,如果你的逻辑需要它,那问题出在架构上,而非技术上。要把代码与它的依赖隔离开,相比生成的 mock,更应优先使用手写的假实现:一个 fake 是对契约的一个简化但确实能工作的实现(内存中的仓库、可控制的时钟);它能在重构中幸存下来,读起来也和普通代码一样。而 mock 用于验证交互("这个方法被调用了两次"),会把测试与实现细节耦合起来,把它们留给那些交互本身就是行为的协议吧。

DART
import 'package:flutter/foundation.dart';

abstract interface class ScoreRepository {
  Future<int> fetchBest();
}

class ScoreController extends ChangeNotifier {
  ScoreController(this._repository);

  final ScoreRepository _repository;

  int? best;
  Object? error;

  Future<void> load() async {
    try {
      best = await _repository.fetchBest();
      error = null;
    } on Exception catch (e) {
      error = e;
    }
    notifyListeners();
  }
}
被测代码:没有导入 material.dart。

setUp 会在每一个测试之前重新创建这些对象:没有共享状态,因而也就没有任何测试会依赖于执行顺序,这正是它们可靠性的前提。可组合的匹配器(isA<Exception>()throwsAisNull)会产出精确的失败信息,远比一个布尔值有用。请注意下面的第二个测试:它先验证失败,紧接着再验证随后的恢复。错误路径和其他代码一样都是代码,如果它们没有被测试过,就当它们是坏的。

DART
import 'package:flutter_test/flutter_test.dart';

class FakeScoreRepository implements ScoreRepository {
  int calls = 0;
  bool failNextCall = false;

  @override
  Future<int> fetchBest() async {
    calls++;
    if (failNextCall) {
      failNextCall = false;
      throw Exception('network unavailable');
    }
    return 42;
  }
}

void main() {
  late FakeScoreRepository repository;
  late ScoreController controller;

  setUp(() {
    repository = FakeScoreRepository();
    controller = ScoreController(repository);
  });

  test('exposes the best score', () async {
    await controller.load();

    expect(controller.best, 42);
    expect(controller.error, isNull);
    expect(repository.calls, 1);
  });

  test('captures an error then recovers', () async {
    repository.failNextCall = true;

    await controller.load();
    expect(controller.error, isA<Exception>());

    await controller.load();
    expect(controller.best, 42);
    expect(controller.error, isNull);
  });

  test('notifies its listeners', () async {
    var notifications = 0;
    controller.addListener(() => notifications++);

    await controller.load();

    expect(notifications, 1);
  });
}
一个十五行的 fake 就取代了一整套 mock 库。

一个 widget 测试不会向屏幕渲染任何东西:它运行在一个专门的 binding(AutomatedTestWidgetsFlutterBinding)里,在那里时间是被模拟的,每一帧都是按需产生的。pumpWidget 挂载树;pump() 推进这个假的时钟并产生一帧pump(const Duration(milliseconds: 150)) 则在时间上向前跳跃,非常适合冻结某个中间的动画状态并加以检查。各种 Finderfind.textfind.byTypefind.byKey)查询的是真实的 Element 树,而不是一张截图。因为没有什么是真正异步的,无论时钟还是网络,所以这些测试是确定性的:一次失败总是可以复现。

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

class BadgeScreen extends StatefulWidget {
  const BadgeScreen({super.key});

  @override
  State<BadgeScreen> createState() => _BadgeScreenState();
}

class _BadgeScreenState extends State<BadgeScreen> {
  bool _visible = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          if (_visible) const Text('New'),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () =>
                setState(() => _visible = false),
          ),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('hides the badge after the tap',
      (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: BadgeScreen()),
    );
    expect(find.text('New'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.close));
    await tester.pump();

    expect(find.text('New'), findsNothing);
  });
}
tap 不会触发任何帧:紧随其后的 pump 是必需的。
BASH
flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens
针对某个文件运行、测量覆盖率、重新生成 golden 文件。

知识检测

确认你已牢记本课的重点内容。

  1. 为什么 pumpAndSettle 面对一个用 repeat() 启动的动画会失败?
    • 它会一直 pump 帧,只要还有帧被排上日程:一个无限的动画永远不会稳定,于是超时
    • 测试 binding 不支持 repeat()
    • pumpAndSettle 只能处理隐式动画
  2. fake 和 mock 之间有什么区别?
    • fake 是一个简化但能工作的实现;mock 则主要用来验证交互
    • mock 是手写的,fake 是由工具生成的
    • 没有区别:这两个词是同义词
    • fake 无法实现一个 Dart 接口
  3. 在一个 widget 测试中,await tester.pump() 究竟做了什么?
    • 它推进模拟的时钟并产生单独的一帧新画面
    • 它等待所有正在进行的动画结束
    • 它把测试从头重新开始
    • 它等待真实的网络请求结束