测试指南
测试是保证代码质量的重要手段。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.0dart
// 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 buildWidget 测试
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: fluttermy_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.dart4. 使用 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%+ 是一个合理的起点
- 不要为覆盖率而写测试,测试要有实际价值
