关于 flutter-testing
flutter-testing 是一项 Flutter 测试技能,优先覆盖业务逻辑与状态,再进行可选的界面和视觉测试。它提供测试模板、运行方法及验证步骤。
主要功能
- 01Repository 与数据源测试
- 02BLoC 和 Provider 状态测试
- 03可选的 Widget 测试
- 04Golden 测试指南
- 05测试命令与依赖项
适用场景
- 01不调用真实 API 测试 Repository
- 02检查状态转换与错误处理
- 03发现设计组件的视觉变化
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ vp-k/ flutter-craft --skill flutter-testing安装后在 Claude Code 中输入/flutter-testing确认已加载。
详细介绍
Flutter 测试指南
概览
实现完成后,按优先级顺序编写测试。优先关注业务逻辑,最后再测试 UI。
开始时声明:「我正在使用 flutter-testing 技能编写测试。」
测试优先级顺序
Priority 1: Repository & DataSource Unit Tests
├── Business logic correctness
├── API integration
└── Data transformation
Priority 2: State Management Unit Tests
├── BLoC/Cubit event handling
├── Provider state transitions
└── Error state handling
Priority 3: Widget Tests (Optional)
├── User interactions
├── Widget rendering
└── Navigation
Priority 4: Golden Tests (Visual Regression)
├── Design system component snapshots
├── Pixel-perfect comparison
└── CI integration
Optional: Integration Tests
└── Full app flow testing
优先级 1:Repository 和 DataSource 测试
Repository 测试模板
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRemoteDataSource extends Mock implements UserRemoteDataSource {}
class MockUserLocalDataSource extends Mock implements UserLocalDataSource {}
void main() {
late UserRepositoryImpl repository;
late MockUserRemoteDataSource mockRemoteDataSource;
late MockUserLocalDataSource mockLocalDataSource;
setUp(() {
mockRemoteDataSource = MockUserRemoteDataSource();
mockLocalDataSource = MockUserLocalDataSource();
repository = UserRepositoryImpl(
remoteDataSource: mockRemoteDataSource,
localDataSource: mockLocalDataSource,
);
});
group('getUser', () {
const tUserId = '123';
final tUserModel = UserModel(id: '123', name: 'Test', email: '[email protected]');
final tUserEntity = User(id: '123', name: 'Test', email: '[email protected]');
test('should return User when remote data source succeeds', () async {
// Arrange
when(() => mockRemoteDataSource.getUser(any()))
.thenAnswer((_) async => tUserModel);
// Act
final result = await repository.getUser(tUserId);
// Assert
expect(result, equals(tUserEntity));
verify(() => mockRemoteDataSource.getUser(tUserId));
});
test('should throw Exception when remote data source fails', () async {
// Arrange
when(() => mockRemoteDataSource.getUser(any()))
.thenThrow(Exception('Server error'));
// Act & Assert
expect(
() => repository.getUser(tUserId),
throwsException,
);
});
});
}
DataSource 测试模板
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
class MockHttpClient extends Mock implements http.Client {}
void main() {
late UserRemoteDataSourceImpl dataSource;
late MockHttpClient mockHttpClient;
setUpAll(() {
// mocktail needs a fallback for non-primitive matcher types
registerFallbackValue(Uri.parse('https://example.com'));
});
setUp(() {
mockHttpClient = MockHttpClient();
dataSource = UserRemoteDataSourceImpl(client: mockHttpClient);
});
group('getUser', () {
const tUserId = '123';
final tUserJson = '{"id": "123", "name": "Test", "email": "[email protected]"}';
test('should return UserModel when response is 200', () async {
// Arrange
when(() => mockHttpClient.get(any(), headers: any(named: 'headers')))
.thenAnswer((_) async => http.Response(tUserJson, 200));
// Act
final result = await dataSource.getUser(tUserId);
// Assert
expect(result, isA<UserModel>());
expect(result.id, equals('123'));
});
test('should throw ServerException when response is not 200', () async {
// Arrange
when(() => mockHttpClient.get(any(), headers: any(named: 'headers')))
.thenAnswer((_) async => http.Response('Error', 500));
// Act & Assert
expect(
() => dataSource.getUser(tUserId),
throwsA(isA<ServerException>()),
);
});
});
}
优先级 2:状态管理测试
BLoC 测试模板
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late UserBloc bloc;
late MockGetUserUseCase mockGetUser;
setUp(() {
mockGetUser = MockGetUserUseCase();
bloc = UserBloc(getUser: mockGetUser);
});
tearDown(() {
bloc.close();
});
test('initial state should be UserInitial', () {
expect(bloc.state, equals(UserInitial()));
});
blocTest<UserBloc, UserState>(
'should emit [Loading, Loaded] when GetUser succeeds',
build: () {
when(() => mockGetUser(any()))
.thenAnswer((_) async => const User(id: '1', name: 'Test'));
return bloc;
},
act: (bloc) => bloc.add(const GetUserEvent('1')),
expect: () => [
UserLoading(),
const UserLoaded(User(id: '1', name: 'Test')),
],
);
blocTest<UserBloc, UserState>(
'should emit [Loading, Error] when GetUser fails',
build: () {
when(() => mockGetUser(any())).thenThrow(Exception('error'));
return bloc;
},
act: (bloc) => bloc.add(const GetUserEvent('1')),
expect: () => [
UserLoading(),
const UserError('error'),
],
);
}
Provider/Riverpod 测试模板
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late ProviderContainer container;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(mockRepository),
],
);
});
tearDown(() {
container.dispose();
});
test('should return user when fetchUser succeeds', () async {
// Arrange
when(() => mockRepository.getUser(any()))
.thenAnswer((_) async => const User(id: '1', name: 'Test'));
// Act
final result = await container.read(userProvider('1').future);
// Assert
expect(result.name, equals('Test'));
});
}
优先级 3:Widget 测试(可选)
Widget 测试模板
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mocktail/mocktail.dart';
class MockUserBloc extends MockBloc<UserEvent, UserState> implements UserBloc {}
void main() {
late MockUserBloc mockBloc;
setUp(() {
mockBloc = MockUserBloc();
});
Widget createWidgetUnderTest() {
return MaterialApp(
home: BlocProvider<UserBloc>.value(
value: mockBloc,
child: const UserScreen(),
),
);
}
testWidgets('should display loading indicator when state is Loading',
(tester) async {
// Arrange
when(() => mockBloc.state).thenReturn(UserLoading());
// Act
await tester.pumpWidget(createWidgetUnderTest());
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('should display user name when state is Loaded',
(tester) async {
// Arrange
when(() => mockBloc.state).thenReturn(
const UserLoaded(User(id: '1', name: 'John Doe')),
);
// Act
await tester.pumpWidget(createWidgetUnderTest());
// Assert
expect(find.text('John Doe'), findsOneWidget);
});
testWidgets('should call GetUserEvent when button is tapped',
(tester) async {
// Arrange
when(mockBloc.state).thenReturn(UserInitial());
// Act
await tester.pumpWidget(createWidgetUnderTest());
await tester.tap(find.byType(ElevatedButton));
// Assert
verify(mockBloc.add(any)).called(1);
});
}
优先级 4:Golden 测试(视觉回归)
验证设计系统组件的视觉一致性,防止出现非预期的 UI 变更。
Golden 测试模板
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('LoginButton matches golden', (tester) async {
// Fixed size guarantees consistent screenshots
await tester.binding.setSurfaceSize(const Size(400, 200));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: LoginButton(onPressed: () {}),
),
),
),
);
await expectLater(
find.byType(LoginButton),
matchesGoldenFile('goldens/login_button.png'),
);
});
testWidgets('UserCard dark mode matches golden', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 300));
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: UserCard(
user: User(id: '1', name: 'Test User', avatar: 'assets/test/avatar.png'),
),
),
),
);
await expectLater(
find.byType(UserCard),
matchesGoldenFile('goldens/user_card_dark.png'),
);
});
}
Golden 文件管理
# Initial generation / baseline update (after intentional changes)
flutter test --update-goldens
# Comparison run (used in CI/CD)
flutter test --tags golden
# Specific files only
flutter test test/features/auth/presentation/widgets/login_button_golden_test.dart --update-goldens
测试文件命名约定
Golden 测试文件以 _golden_test.dart 后缀区分:
test/features/auth/presentation/widgets/
├── login_button_test.dart # Functional test (Priority 3)
└── login_button_golden_test.dart # Golden test (Priority 4)
基于标签的执行
在 dart_test.yaml 中为 Golden 测试添加标签,以便将其分开执行:
tags:
golden:
# Can be run separately in CI
在测试文件中添加标签:
@Tags(['golden'])
library;
import 'package:flutter_test/flutter_test.dart';
// ...
编写 Golden 测试时的注意事项
- 固定尺寸:使用
setSurfaceSize()设置一致的画布尺寸 - 确定性数据:切勿使用随机数据或依赖时间的数据
- 字体加载:使用
FontLoader预加载自定义字体 - 网络图片:替换为模拟对象(移除对网络的依赖)
- 平台差异:macOS/Linux/Windows 上的渲染可能不同 → 固定 CI 环境
适合编写 Golden 测试的情况
- 设计系统组件(Button、Card、Input、Badge 等)
- 验证主题变更后的视觉效果
- 验证深色模式 / 浅色模式切换
- 验证各个断点下的响应式布局
不适合编写 Golden 测试的情况
- 业务逻辑繁重的屏幕(只有显示的数据不同)
- 频繁变更的屏幕(更新 Golden 文件的成本高于收益)
- 严重依赖外部数据的屏幕
测试文件结构
test/
├── features/
│ └── auth/
│ ├── data/
│ │ ├── datasources/
│ │ │ └── auth_remote_datasource_test.dart
│ │ └── repositories/
│ │ └── auth_repository_impl_test.dart
│ └── presentation/
│ ├── bloc/
│ │ └── auth_bloc_test.dart
│ └── widgets/
│ └── login_button_test.dart
└── helpers/
├── test_helpers.dart
└── pump_app.dart
运行测试
# All tests
flutter test
# Specific feature
flutter test test/features/auth/
# Specific file
flutter test test/features/auth/data/repositories/auth_repository_impl_test.dart
# With coverage
flutter test --coverage
# Generate coverage report
genhtml coverage/lcov.info -o coverage/html
测试依赖项
# Mocking — mocktail (no codegen; all templates in this skill use it)
flutter pub add dev:mocktail
# State management testing
flutter pub add dev:bloc_test # If using BLoC (also provides MockBloc)
# Freezed (if using immutable states)
flutter pub add freezed_annotation
flutter pub add dev:freezed
flutter pub add dev:build_runner # For freezed codegen only
生成代码(Freezed 状态等)
dart run build_runner build --delete-conflicting-outputs
核心原则
- **优先级顺序:**Repository → 状态 → Widget
- **模拟依赖项:**不要测试真实 API 或数据库
- **准备-执行-断言:**保持清晰的测试结构
- **聚焦单个断言:**每个测试只测试一件事
- **描述性名称:**测试名称应描述行为
何时跳过测试
-
以下情况可跳过 Widget 测试:
- 简单的无状态 Widget
- 没有用户交互逻辑
- 纯展示内容(没有业务逻辑)
-
绝不能跳过:
- Repository 测试(业务逻辑)
- 状态管理测试(状态转换)
- 错误处理测试
必需的子技能
编写测试后,必须调用: → flutter-craft:flutter-verification
运行 flutter test 并确认所有测试均通过。
评论
支持 Markdown