Skip to content

测试指南

测试是保证代码质量的重要手段。Flutter 提供了完善的测试框架,涵盖单元测试、Widget 测试和集成测试三个层次。

测试金字塔

        ┌──────────┐
        │ 集成测试  │   少量 → 验证完整用户流程
        │ (E2E)    │
        ├──────────┤
        │ Widget   │   适量 → 验证 UI 交互
        │ 测试     │
        ├──────────┤
        │ 单元测试  │   大量 → 验证业务逻辑
        └──────────┘
测试类型测试对象运行速度推荐比例
单元测试函数、方法、类极快(毫秒级)70%
Widget 测试单个 Widget 的渲染和交互快(百毫秒级)20%
集成测试完整应用流程慢(秒级)10%

快速开始

测试文件位置

my_app/
├── lib/
│   └── utils/validators.dart      # 源码
└── test/                           # 测试目录
    └── utils/validators_test.dart  # 测试文件(镜像 lib/ 结构)

测试文件命名规则:xxx_test.dart(在源文件名后加 _test

运行测试

bash
# 运行所有测试
flutter test

# 运行指定文件
flutter test test/utils/validators_test.dart

# 运行并查看覆盖率
flutter test --coverage

单元测试

单元测试用于验证独立的函数和类,是最基础也是性价比最高的测试。

基本用法

dart
// lib/utils/validators.dart
class Validators {
  static bool isEmail(String value) {
    return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value);
  }

  static bool isNotEmpty(String? value) {
    return value != null && value.trim().isNotEmpty;
  }
}
dart
// test/utils/validators_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/utils/validators.dart';

void main() {
  group('isEmail', () {
    test('有效邮箱返回 true', () {
      expect(Validators.isEmail('test@example.com'), isTrue);
      expect(Validators.isEmail('user.name@domain.co'), isTrue);
    });

    test('无效邮箱返回 false', () {
      expect(Validators.isEmail(''), isFalse);
      expect(Validators.isEmail('not-email'), isFalse);
      expect(Validators.isEmail('@domain.com'), isFalse);
    });
  });

  group('isNotEmpty', () {
    test('非空字符串返回 true', () {
      expect(Validators.isNotEmpty('hello'), isTrue);
    });

    test('空字符串或 null 返回 false', () {
      expect(Validators.isNotEmpty(''), isFalse);
      expect(Validators.isNotEmpty('   '), isFalse);
      expect(Validators.isNotEmpty(null), isFalse);
    });
  });
}

测试 ChangeNotifier

dart
// lib/controllers/counter_controller.dart
class CounterController extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  void decrement() {
    _count--;
    notifyListeners();
  }

  void reset() {
    _count = 0;
    notifyListeners();
  }
}
dart
// test/controllers/counter_controller_test.dart
void main() {
  late CounterController controller;

  setUp(() {
    controller = CounterController();
  });

  tearDown(() {
    controller.dispose();
  });

  test('初始值为 0', () {
    expect(controller.count, 0);
  });

  test('increment 递增', () {
    controller.increment();
    expect(controller.count, 1);
  });

  test('decrement 递减', () {
    controller.decrement();
    expect(controller.count, -1);
  });

  test('reset 归零', () {
    controller.increment();
    controller.increment();
    controller.reset();
    expect(controller.count, 0);
  });

  test('操作时通知监听者', () {
    int notifyCount = 0;
    controller.addListener(() => notifyCount++);

    controller.increment();
    controller.increment();
    controller.decrement();

    expect(notifyCount, 3);
  });
}

Mock 外部依赖

yaml
# pubspec.yaml
dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0
dart
// lib/services/user_service.dart
class UserService {
  final ApiClient apiClient;
  UserService(this.apiClient);

  Future<String> getUserName() async {
    final user = await apiClient.fetchUser();
    return user.name;
  }
}
dart
// test/services/user_service_test.dart
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

// 生成 Mock 类
@GenerateMocks([ApiClient])
import 'user_service_test.mocks.dart';

void main() {
  late UserService service;
  late MockApiClient mockApi;

  setUp(() {
    mockApi = MockApiClient();
    service = UserService(mockApi);
  });

  test('getUserName 返回用户名', () async {
    // 安排(Arrange)
    when(mockApi.fetchUser()).thenAnswer((_) async => User(name: '张三'));

    // 执行(Act)
    final name = await service.getUserName();

    // 断言(Assert)
    expect(name, '张三');
    verify(mockApi.fetchUser()).called(1);
  });

  test('getUserName 请求失败抛出异常', () async {
    when(mockApi.fetchUser()).thenThrow(Exception('网络错误'));

    expect(() => service.getUserName(), throwsException);
  });
}

运行代码生成:

bash
flutter pub run build_runner build

Widget 测试

Widget 测试验证单个组件的渲染和交互,不需要启动模拟器。

基本用法

dart
// test/widgets/counter_display_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('显示计数器文本', (tester) async {
    // 1. 渲染 Widget
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: Text('Count: 5'),
        ),
      ),
    );

    // 2. 查找 Widget
    final textFinder = find.text('Count: 5');

    // 3. 断言
    expect(textFinder, findsOneWidget);
  });
}

测试交互

dart
// test/widgets/add_button_test.dart
testWidgets('点击按钮增加计数', (tester) async {
  int count = 0;

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Text('Count: $count'),
        floatingActionButton: FloatingActionButton(
          onPressed: () => count++,
          child: const Icon(Icons.add),
        ),
      ),
    ),
  );

  // 初始状态
  expect(find.text('Count: 0'), findsOneWidget);

  // 点击按钮
  await tester.tap(find.byType(FloatingActionButton));
  await tester.pump();  // 触发重建

  // 注意:由于 count 是外部变量,Text 不会自动更新
  // 实际测试中应使用 StatefulWidget 或 Provider
});

测试 StatefulWidget

dart
testWidgets('StatefulWidget 状态变化', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: CounterWidget()));

  // 初始状态
  expect(find.text('0'), findsOneWidget);

  // 点击增加按钮
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();  // 等待重建完成

  expect(find.text('1'), findsOneWidget);
});

常用查找器

查找器用途示例
find.text('Hello')按文本查找查找包含指定文本的 Widget
find.byType(Text)按类型查找查找所有 Text Widget
find.byKey(Key('myKey'))按 Key 查找需要给 Widget 设置 key
find.byIcon(Icons.add)按图标查找查找包含指定图标的 Widget
find.widgetWithText(AppBar, '标题')按父类型+文本查找精确查找 AppBar 中的标题

常用匹配器

匹配器含义
findsOneWidget找到恰好一个
findsNothing没找到
findsNWidgets(3)找到恰好 N 个
findsWidgets找到至少一个

集成测试

集成测试验证完整用户流程,在真机或模拟器上运行。

配置

yaml
# pubspec.yaml
dev_dependencies:
  integration_test:
    sdk: flutter
my_app/
├── integration_test/          # 集成测试目录
│   └── app_test.dart
└── test_driver/
    └── integration_test.dart  # 测试驱动
dart
// test_driver/integration_test.dart
import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();

编写集成测试

dart
// integration_test/app_test.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();

  group('登录流程', () {
    testWidgets('输入账号密码后可以登录', (tester) async {
      // 启动应用
      app.main();
      await tester.pumpAndSettle();

      // 输入账号
      await tester.enterText(find.byKey(const Key('email_field')), 'test@example.com');

      // 输入密码
      await tester.enterText(find.byKey(const Key('password_field')), '123456');

      // 点击登录
      await tester.tap(find.byKey(const Key('login_button')));
      await tester.pumpAndSettle();

      // 验证跳转到首页
      expect(find.text('首页'), findsOneWidget);
    });
  });
}

运行集成测试

bash
# 在连接的设备上运行
flutter test integration_test/app_test.dart

# 指定设备
flutter test integration_test/app_test.dart -d chrome

测试最佳实践

1. 给需要测试的 Widget 加 Key

dart
// ✅ 通过 Key 精准定位
TextField(key: const Key('email_field'))
ElevatedButton(key: const Key('login_button'))

// 测试时
await tester.enterText(find.byKey(const Key('email_field')), 'test@test.com');
await tester.tap(find.byKey(const Key('login_button')));

2. 使用 pumpAndSettle 等待动画完成

dart
// ❌ pump 只触发一帧,动画可能还没完成
await tester.pump();

// ✅ pumpAndSettle 等待所有动画完成
await tester.pumpAndSettle();

3. 测试文件镜像源码结构

lib/
├── utils/validators.dart
├── services/user_service.dart
└── controllers/counter_controller.dart

test/
├── utils/validators_test.dart          # 与 lib/ 结构一一对应
├── services/user_service_test.dart
└── controllers/counter_controller_test.dart

4. 使用 AAA 模式组织测试

dart
test('getUserName 返回用户名', () async {
  // Arrange(安排)- 准备数据和环境
  when(mockApi.fetchUser()).thenAnswer((_) async => User(name: '张三'));

  // Act(执行)- 调用被测试的方法
  final name = await service.getUserName();

  // Assert(断言)- 验证结果
  expect(name, '张三');
});

5. 优先测试核心逻辑

不要追求 100% 覆盖率,优先测试:

  • 业务逻辑(计算、验证、数据处理)
  • 关键交互(登录、支付、表单提交)
  • 边界情况(空值、极端输入、网络错误)

测试覆盖率

bash
# 生成覆盖率报告
flutter test --coverage

# 查看覆盖率(需要安装 lcov)
# macOS
brew install lcov
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

# Linux
sudo apt install lcov
genhtml coverage/lcov.info -o coverage/html

合理目标

  • 核心业务逻辑:80%+ 覆盖率
  • 整体项目:50%+ 是一个合理的起点
  • 不要为覆盖率而写测试,测试要有实际价值

下一步

基于 Flutter 官方文档整理