15_测试
Flutter 测试框架和测试方法
单元测试、Widget 测试、集成测试,对应 Jest / Vitest 的测试体系。
测试类型
| 类型 | 测试对象 | 速度 | 类比 |
|---|---|---|---|
| 单元测试 | 函数 / 类 / 逻辑 | ⚡ 最快 | Jest unit test |
| Widget 测试 | 单个 Widget 的 UI 和交互 | 快 | RTL / Vue Test Utils |
| 集成测试 | 多个组件 + 真实设备 | 慢 | Cypress / Playwright |
单元测试
// test/models/cart_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/models/cart_model.dart';
void main() {
group('CartModel', () {
late CartModel cart;
setUp(() {
cart = CartModel(); // 每个测试前重建(类似 beforeEach)
});
tearDown(() {
// 每个测试后清理(类似 afterEach)
});
test('初始状态为空', () {
expect(cart.count, 0);
expect(cart.items, isEmpty);
});
test('添加商品', () {
cart.add('苹果');
expect(cart.count, 1);
expect(cart.items, contains('苹果'));
});
test('删除商品', () {
cart.add('香蕉');
cart.remove('香蕉');
expect(cart.count, 0);
});
test('清空购物车', () {
cart.add('苹果');
cart.add('香蕉');
cart.clear();
expect(cart.items, isEmpty);
});
});
group('ApiService', () {
test('解析 Post 模型', () {
final json = {'id': 1, 'title': '标题', 'body': '内容'};
final post = Post.fromJson(json);
expect(post.id, 1);
expect(post.title, '标题');
});
});
}
常用 Matchers(断言方法):
expect(value, equals(expected)) // 相等
expect(value, isNull) // 为 null
expect(value, isNotNull) // 不为 null
expect(value, isTrue / isFalse) // 布尔值
expect(value, isEmpty) // 空集合
expect(list, contains('item')) // 包含元素
expect(list, hasLength(3)) // 长度
expect(value, greaterThan(0)) // 大于
expect(value, isA<String>()) // 类型检查
expect(() => fn(), throwsException) // 抛出异常
Widget 测试
// test/widgets/user_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/user_card.dart';
void main() {
testWidgets('UserCard 正确显示用户信息', (WidgetTester tester) async {
// 构建 Widget
await tester.pumpWidget(
const MaterialApp(
home: UserCard(name: 'Alice', followerCount: 1024),
),
);
// 查找并验证
expect(find.text('Alice'), findsOneWidget);
expect(find.text('1024 followers'), findsOneWidget);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byType(Card), findsOneWidget);
});
testWidgets('按钮点击触发回调', (WidgetTester tester) async {
int tapCount = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ElevatedButton(
onPressed: () => tapCount++,
child: const Text('点我'),
),
),
),
);
await tester.tap(find.text('点我'));
await tester.pump(); // 触发重建
expect(tapCount, 1);
});
testWidgets('表单验证', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginForm()));
// 点击提交(不填写任何内容)
await tester.tap(find.text('登录'));
await tester.pump();
// 应该显示验证错误
expect(find.text('邮箱不能为空'), findsOneWidget);
expect(find.text('密码不能为空'), findsOneWidget);
// 填写内容
await tester.enterText(find.byType(TextFormField).first, 'test@example.com');
await tester.enterText(find.byType(TextFormField).last, '123456');
await tester.tap(find.text('登录'));
await tester.pump();
// 验证错误应该消失
expect(find.text('邮箱不能为空'), findsNothing);
});
testWidgets('加载异步数据', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: PostsPage()));
// 初始显示 loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 等待异步操作完成
await tester.pumpAndSettle();
// 数据加载后,loading 消失
expect(find.byType(CircularProgressIndicator), findsNothing);
});
}
常用 Finder:
find.text('文字') // 通过文字查找
find.byType(ElevatedButton) // 通过 Widget 类型
find.byKey(const Key('xxx')) // 通过 Key(推荐用于测试)
find.byIcon(Icons.search) // 通过图标
find.ancestor(of: ..., matching: ...) // 父节点查找
find.descendant(of: ..., matching: ...)// 子节点查找
// 验证数量
findsOneWidget // 恰好一个
findsNothing // 不存在
findsNWidgets(3) // 恰好 N 个
findsAtLeastNWidgets(2) // 至少 N 个
Mock(模拟依赖)
dev_dependencies:
mockito: ^5.4.0
build_runner: ^2.4.0
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
@GenerateMocks([ApiService]) // 自动生成 Mock 类
void main() {}
// 运行生成:dart run build_runner build
// test/services/api_service_test.dart
void main() {
late MockApiService mockApi;
setUp(() {
mockApi = MockApiService();
});
test('fetchPosts 成功', () async {
when(mockApi.get('/posts')).thenAnswer((_) async => [
{'id': 1, 'title': '测试文章', 'body': '内容'},
]);
final posts = await mockApi.get('/posts');
expect(posts, isA<List>());
verify(mockApi.get('/posts')).called(1);
});
test('fetchPosts 网络错误', () async {
when(mockApi.get('/posts')).thenThrow(ApiException('网络错误'));
expect(() => mockApi.get('/posts'), throwsA(isA<ApiException>()));
});
}
运行测试
# 运行所有测试
flutter test
# 运行指定文件
flutter test test/models/cart_model_test.dart
# 运行指定目录
flutter test test/models/
# 带覆盖率(生成 coverage/ 目录)
flutter test --coverage
# 查看覆盖率报告(需要安装 lcov)
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
# 监听模式(文件变化时自动重跑)
flutter test --watch