跳转至

Dart/Flutter 测试

此文件使用 Dart 和 Flutter 特定内容扩展 common/testing.md。

测试框架

  • flutter_test / dart:test — 内置测试运行器
  • 使用 mockito(带 @GenerateMocks)或 mocktail(无代码生成)进行 Mock
  • bloc_test — BLoC/Cubit 单元测试
  • fake_async — 单元测试中的时间控制
  • integration_test — 端到端设备测试

测试类型

类型 工具 位置 编写时机
单元测试 dart:test test/unit/ 所有领域逻辑、状态管理器、仓库
小部件 flutter_test test/widget/ 所有具有有意义行为的小部件
黄金 flutter_test test/golden/ 设计重要的 UI 组件
集成 integration_test integration_test/ 真机/模拟器上的关键用户流程

单元测试:状态管理器

使用 bloc_test 的 BLoC

group('CartBloc', () {
  late CartBloc bloc;
  late MockCartRepository repository;

  setUp(() {
    repository = MockCartRepository();
    bloc = CartBloc(repository);
  });

  tearDown(() => bloc.close());

  blocTest<CartBloc, CartState>(
    'CartItemAdded 時に更新されたアイテムを emit する',
    build: () => bloc,
    act: (b) => b.add(CartItemAdded(testItem)),
    expect: () => [CartState(items: [testItem])],
  );

  blocTest<CartBloc, CartState>(
    'CartCleared 時に空のカートを emit する',
    seed: () => CartState(items: [testItem]),
    build: () => bloc,
    act: (b) => b.add(CartCleared()),
    expect: () => [const CartState()],
  );
});

使用 ProviderContainer 的 Riverpod

test('usersProvider がリポジトリからユーザーをロードする', () async {
  final container = ProviderContainer(
    overrides: [userRepositoryProvider.overrideWithValue(FakeUserRepository())],
  );
  addTearDown(container.dispose);

  final result = await container.read(usersProvider.future);
  expect(result, isNotEmpty);
});

小部件测试

testWidgets('CartPage がアイテム数バッジを表示する', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        cartNotifierProvider.overrideWith(() => FakeCartNotifier([testItem])),
      ],
      child: const MaterialApp(home: CartPage()),
    ),
  );

  await tester.pump();
  expect(find.text('1'), findsOneWidget);
  expect(find.byType(CartItemTile), findsOneWidget);
});

testWidgets('カートが空のときに空の状態を表示する', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier([]))],
      child: const MaterialApp(home: CartPage()),
    ),
  );

  await tester.pump();
  expect(find.text('Your cart is empty'), findsOneWidget);
});

优先使用 Fake 而非 Mock

对于复杂依赖关系,优先使用手写 Fake:

class FakeUserRepository implements UserRepository {
  final _users = <String, User>{};
  Object? fetchError;

  @override
  Future<User?> getById(String id) async {
    if (fetchError != null) throw fetchError!;
    return _users[id];
  }

  @override
  Future<List<User>> getAll() async {
    if (fetchError != null) throw fetchError!;
    return _users.values.toList();
  }

  @override
  Stream<List<User>> watchAll() => Stream.value(_users.values.toList());

  @override
  Future<void> save(User user) async {
    _users[user.id] = user;
  }

  @override
  Future<void> delete(String id) async {
    _users.remove(id);
  }

  void addUser(User user) => _users[user.id] = user;
}

异步测试

// タイマーと Future を制御するために fake_async を使用
test('300ms 後にデバウンスが発火する', () {
  fakeAsync((async) {
    final debouncer = Debouncer(delay: const Duration(milliseconds: 300));
    var callCount = 0;
    debouncer.run(() => callCount++);
    expect(callCount, 0);
    async.elapse(const Duration(milliseconds: 200));
    expect(callCount, 0);
    async.elapse(const Duration(milliseconds: 200));
    expect(callCount, 1);
  });
});

黄金测试

testWidgets('UserCard ゴールデンテスト', (tester) async {
  await tester.pumpWidget(
    MaterialApp(home: UserCard(user: testUser)),
  );

  await expectLater(
    find.byType(UserCard),
    matchesGoldenFile('goldens/user_card.png'),
  );
});

如果存在有意的视觉变更,请运行 flutter test --update-goldens。

测试命名

使用描述性且聚焦行为的名称:

test('ユーザーが存在しない場合に null を返す', () { ... });
test('id が空文字列の場合に NotFoundException をスローする', () { ... });
testWidgets('フォームが無効な間は送信ボタンを無効にする', (tester) async { ... });

测试结构

test/
├── unit/
│   ├── domain/
│   │   └── usecases/
│   └── data/
│       └── repositories/
├── widget/
│   └── presentation/
│       └── pages/
└── golden/
    └── widgets/

integration_test/
└── flows/
    ├── login_flow_test.dart
    └── checkout_flow_test.dart

覆盖率

  • 目标业务逻辑(领域 + 状态管理器)达到 80% 以上的行覆盖率
  • 所有状态转换都需要测试:加载 → 成功、加载 → 错误、重试
  • 运行 flutter test --coverage,并在覆盖率报告中查看 lcov.info
  • 如果覆盖率低于阈值,则在 CI 中阻止

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 ev/ECC