Flutter+HarmonyOS跨端实战—第15篇:测试与部署
保障代码质量,顺利发布应用
前言
测试是保障代码质量的关键环节。一个完善的测试体系能够:
- 提前发现 Bug:在开发阶段发现问题
- 保障重构安全:重构时不破坏现有功能
- 提升代码质量:测试驱动设计改进
- 加快迭代速度:自动化测试减少手动测试时间
CleanMark AI 需要覆盖单元测试、Widget 测试和集成测试。这篇文章我会带你构建完整的测试体系,并实现自动化部署。
一、测试体系设计
1.1 测试金字塔
集成测试(少量)
↗
Widget 测试(适量)
↗
单元测试(大量)
测试比例建议:
- 单元测试:70%
- Widget 测试:20%
- 集成测试:10%
1.2 测试目录结构
test/
├── unit/ # 单元测试
│ ├── core/
│ │ ├── utils/
│ │ └── network/
│ ├── features/
│ │ ├── auth/
│ │ │ ├── domain/
│ │ │ │ └── usecases/
│ │ │ └── data/
│ │ │ └── repositories/
│ │ └── inpaint/
│ └── shared/
├── widget/ # Widget 测试
│ ├── features/
│ │ ├── auth/
│ │ │ └── pages/
│ │ └── home/
│ └── shared/
│ └── widgets/
├── integration/ # 集成测试
│ └── app_test.dart
└── helpers/ # 测试辅助工具
├── mock_data.dart
├── test_helpers.dart
└── pump_app.dart
二、单元测试
2.1 UseCase 测试
// test/unit/features/auth/domain/usecases/login_usecase_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dartz/dartz.dart';
import 'package:ai_clean_mark/core/errors/failures.dart';
import 'package:ai_clean_mark/features/auth/domain/entities/user_entity.dart';
import 'package:ai_clean_mark/features/auth/domain/repositories/auth_repository.dart';
import 'package:ai_clean_mark/features/auth/domain/usecases/login_usecase.dart';
// Mock Repository
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late LoginUseCase useCase;
late MockAuthRepository mockRepository;
setUp(() {
mockRepository = MockAuthRepository();
useCase = LoginUseCase(mockRepository);
});
group('LoginUseCase', () {
const testEmail = 'test@example.com';
const testPassword = 'password123';
const testUser = UserEntity(
id: '1',
email: testEmail,
credits: 5,
);
test('登录成功时,应返回用户实体', () async {
// Arrange
when(() => mockRepository.login(testEmail, testPassword))
.thenAnswer((_) async => const Right(testUser));
// Act
final result = await useCase(testEmail, testPassword);
// Assert
expect(result, const Right(testUser));
verify(() => mockRepository.login(testEmail, testPassword)).called(1);
});
test('邮箱格式错误时,应返回 ValidationFailure', () async {
// Arrange
const invalidEmail = 'invalid-email';
// Act
final result = await useCase(invalidEmail, testPassword);
// Assert
expect(result.isLeft(), true);
result.fold(
(failure) => expect(failure, isA<ValidationFailure>()),
(_) => fail('应该返回 Failure'),
);
verifyNever(() => mockRepository.login(any(), any()));
});
test('密码为空时,应返回 ValidationFailure', () async {
// Arrange
const emptyPassword = '';
// Act
final result = await useCase(testEmail, emptyPassword);
// Assert
expect(result.isLeft(), true);
result.fold(
(failure) => expect(failure, isA<ValidationFailure>()),
(_) => fail('应该返回 Failure'),
);
});
test('网络错误时,应返回 NetworkFailure', () async {
// Arrange
when(() => mockRepository.login(testEmail, testPassword))
.thenAnswer((_) async => const Left(NetworkFailure()));
// Act
final result = await useCase(testEmail, testPassword);
// Assert
expect(result, const Left(NetworkFailure()));
});
test('认证失败时,应返回 AuthFailure', () async {
// Arrange
when(() => mockRepository.login(testEmail, testPassword))
.thenAnswer((_) async => const Left(AuthFailure('邮箱或密码错误')));
// Act
final result = await useCase(testEmail, testPassword);
// Assert
expect(result.isLeft(), true);
result.fold(
(failure) {
expect(failure, isA<AuthFailure>());
expect(failure.message, '邮箱或密码错误');
},
(_) => fail('应该返回 Failure'),
);
});
});
}
2.2 Repository 测试
// test/unit/features/auth/data/repositories/auth_repository_impl_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dartz/dartz.dart';
import 'package:ai_clean_mark/core/errors/failures.dart';
import 'package:ai_clean_mark/features/auth/data/datasources/auth_remote_datasource.dart';
import 'package:ai_clean_mark/features/auth/data/models/user_model.dart';
import 'package:ai_clean_mark/features/auth/data/repositories/auth_repository_impl.dart';
// Mock DataSource
class MockAuthRemoteDataSource extends Mock implements AuthRemoteDataSource {}
void main() {
late AuthRepositoryImpl repository;
late MockAuthRemoteDataSource mockDataSource;
setUp(() {
mockDataSource = MockAuthRemoteDataSource();
repository = AuthRepositoryImpl(mockDataSource);
});
group('AuthRepositoryImpl - login', () {
const testEmail = 'test@example.com';
const testPassword = 'password123';
const testUserModel = UserModel(
id: '1',
email: testEmail,
credits: 5,
);
test('登录成功时,应返回用户实体', () async {
// Arrange
when(() => mockDataSource.login(testEmail, testPassword))
.thenAnswer((_) async => testUserModel);
// Act
final result = await repository.login(testEmail, testPassword);
// Assert
expect(result.isRight(), true);
result.fold(
(_) => fail('应该返回用户实体'),
(user) {
expect(user.id, testUserModel.id);
expect(user.email, testUserModel.email);
expect(user.credits, testUserModel.credits);
},
);
});
test('DataSource 抛出 NetworkException 时,应返回 NetworkFailure', () async {
// Arrange
when(() => mockDataSource.login(testEmail, testPassword))
.thenThrow(NetworkException('网络连接失败'));
// Act
final result = await repository.login(testEmail, testPassword);
// Assert
expect(result, const Left(NetworkFailure('网络连接失败')));
});
test('DataSource 抛出 ServerException 时,应返回 ServerFailure', () async {
// Arrange
when(() => mockDataSource.login(testEmail, testPassword))
.thenThrow(ServerException('服务器错误'));
// Act
final result = await repository.login(testEmail, testPassword);
// Assert
expect(result, const Left(ServerFailure('服务器错误')));
});
});
}
2.3 工具类测试
// test/unit/core/utils/validator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:ai_clean_mark/core/utils/validator.dart';
void main() {
group('Validator - isValidEmail', () {
test('有效邮箱应返回 true', () {
expect(Validator.isValidEmail('test@example.com'), true);
expect(Validator.isValidEmail('user.name@domain.co.uk'), true);
expect(Validator.isValidEmail('user+tag@example.com'), true);
});
test('无效邮箱应返回 false', () {
expect(Validator.isValidEmail(''), false);
expect(Validator.isValidEmail('invalid'), false);
expect(Validator.isValidEmail('invalid@'), false);
expect(Validator.isValidEmail('@example.com'), false);
expect(Validator.isValidEmail('user@'), false);
});
});
group('Validator - isValidPassword', () {
test('有效密码应返回 true', () {
expect(Validator.isValidPassword('password123'), true);
expect(Validator.isValidPassword('12345678'), true);
});
test('密码长度小于 6 应返回 false', () {
expect(Validator.isValidPassword('12345'), false);
expect(Validator.isValidPassword(''), false);
});
});
}
本篇小结(第一部分)
这部分我们完成了:
- ✅ 测试体系设计(测试金字塔)
- ✅ 测试目录结构
- ✅ UseCase 单元测试(使用 mocktail)
- ✅ Repository 单元测试
- ✅ 工具类单元测试
下一部分我会继续讲解 Widget 测试。
三、Widget 测试
3.1 测试辅助工具
// test/helpers/pump_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 测试辅助函数,包装 Widget 并提供必要的依赖
Future<void> pumpApp(
WidgetTester tester,
Widget widget, {
List<Override>? overrides,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: overrides ?? [],
child: MaterialApp(
home: widget,
),
),
);
}
3.2 登录页面测试
// test/widget/features/auth/pages/login_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dartz/dartz.dart';
import 'package:ai_clean_mark/features/auth/presentation/pages/login_page.dart';
import 'package:ai_clean_mark/features/auth/domain/usecases/login_usecase.dart';
import '../../../helpers/pump_app.dart';
class MockLoginUseCase extends Mock implements LoginUseCase {}
void main() {
late MockLoginUseCase mockLoginUseCase;
setUp(() {
mockLoginUseCase = MockLoginUseCase();
});
group('LoginPage Widget 测试', () {
testWidgets('应该显示邮箱和密码输入框', (tester) async {
// Arrange & Act
await pumpApp(tester, const LoginPage());
// Assert
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('邮箱'), findsOneWidget);
expect(find.text('密码'), findsOneWidget);
});
testWidgets('应该显示登录按钮', (tester) async {
// Arrange & Act
await pumpApp(tester, const LoginPage());
// Assert
expect(find.text('登录'), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);
});
testWidgets('点击登录按钮时,应该调用登录用例', (tester) async {
// Arrange
const testEmail = 'test@example.com';
const testPassword = 'password123';
when(() => mockLoginUseCase(testEmail, testPassword))
.thenAnswer((_) async => const Right(testUser));
await pumpApp(tester, const LoginPage());
// Act
await tester.enterText(
find.byKey(const Key('email_field')),
testEmail,
);
await tester.enterText(
find.byKey(const Key('password_field')),
testPassword,
);
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
// Assert
verify(() => mockLoginUseCase(testEmail, testPassword)).called(1);
});
testWidgets('邮箱格式错误时,应该显示错误提示', (tester) async {
// Arrange
await pumpApp(tester, const LoginPage());
// Act
await tester.enterText(
find.byKey(const Key('email_field')),
'invalid-email',
);
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
// Assert
expect(find.text('请输入有效的邮箱地址'), findsOneWidget);
});
testWidgets('登录失败时,应该显示 SnackBar', (tester) async {
// Arrange
when(() => mockLoginUseCase(any(), any()))
.thenAnswer((_) async => const Left(AuthFailure('邮箱或密码错误')));
await pumpApp(tester, const LoginPage());
// Act
await tester.enterText(
find.byKey(const Key('email_field')),
'test@example.com',
);
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
// Assert
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('邮箱或密码错误'), findsOneWidget);
});
});
}
本篇小结(第二部分 - 上)
这部分我们完成了:
- ✅ 测试辅助工具(pumpApp 封装)
- ✅ 登录页面 Widget 测试(输入、点击、错误提示)
下一部分我会继续讲解列表测试和导航测试。
3.3 列表滚动测试
// test/widget/features/history/pages/history_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:ai_clean_mark/features/history/presentation/pages/history_page.dart';
import 'package:ai_clean_mark/features/history/models/task_model.dart';
import '../../../helpers/pump_app.dart';
void main() {
group('HistoryPage Widget 测试', () {
testWidgets('空列表时,应该显示空状态提示', (tester) async {
// Arrange & Act
await pumpApp(tester, const HistoryPage());
await tester.pumpAndSettle();
// Assert
expect(find.text('暂无历史记录'), findsOneWidget);
expect(find.byIcon(Icons.history), findsOneWidget);
});
testWidgets('应该显示任务列表', (tester) async {
// Arrange
final tasks = [
TaskModel(
id: '1',
userId: 'user1',
type: TaskType.image,
status: TaskStatus.completed,
inputFile: 'input1.jpg',
outputFile: 'output1.jpg',
creditsUsed: 1,
createdAt: DateTime.now(),
),
TaskModel(
id: '2',
userId: 'user1',
type: TaskType.video,
status: TaskStatus.processing,
inputFile: 'input2.mp4',
creditsUsed: 3,
createdAt: DateTime.now(),
),
];
await pumpApp(tester, const HistoryPage());
await tester.pumpAndSettle();
// Assert
expect(find.byType(ListTile), findsNWidgets(2));
});
testWidgets('应该支持下拉刷新', (tester) async {
// Arrange
await pumpApp(tester, const HistoryPage());
await tester.pumpAndSettle();
// Act
await tester.drag(
find.byType(RefreshIndicator),
const Offset(0, 300),
);
await tester.pumpAndSettle();
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('滑动删除任务时,应该显示确认对话框', (tester) async {
// Arrange
await pumpApp(tester, const HistoryPage());
await tester.pumpAndSettle();
// Act
await tester.drag(
find.byType(Dismissible).first,
const Offset(-500, 0),
);
await tester.pumpAndSettle();
// Assert
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('确认删除'), findsOneWidget);
});
});
}
3.4 导航测试
// test/widget/features/home/pages/home_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:ai_clean_mark/features/home/presentation/pages/home_page.dart';
import '../../../helpers/pump_app.dart';
void main() {
group('HomePage 导航测试', () {
testWidgets('点击图片去水印卡片,应该跳转到图片处理页', (tester) async {
// Arrange
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/image-inpaint',
builder: (context, state) => const Scaffold(
body: Text('图片处理页'),
),
),
],
);
await tester.pumpWidget(
MaterialApp.router(
routerConfig: router,
),
);
await tester.pumpAndSettle();
// Act
await tester.tap(find.byKey(const Key('image_inpaint_card')));
await tester.pumpAndSettle();
// Assert
expect(find.text('图片处理页'), findsOneWidget);
});
testWidgets('点击视频去水印卡片,应该跳转到视频处理页', (tester) async {
// Arrange
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/video-inpaint',
builder: (context, state) => const Scaffold(
body: Text('视频处理页'),
),
),
],
);
await tester.pumpWidget(
MaterialApp.router(
routerConfig: router,
),
);
await tester.pumpAndSettle();
// Act
await tester.tap(find.byKey(const Key('video_inpaint_card')));
await tester.pumpAndSettle();
// Assert
expect(find.text('视频处理页'), findsOneWidget);
});
});
}
3.5 Riverpod Provider 测试
// test/widget/features/auth/providers/auth_provider_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dartz/dartz.dart';
import 'package:ai_clean_mark/features/auth/presentation/providers/auth_provider.dart';
import 'package:ai_clean_mark/features/auth/domain/usecases/login_usecase.dart';
class MockLoginUseCase extends Mock implements LoginUseCase {}
void main() {
late MockLoginUseCase mockLoginUseCase;
setUp(() {
mockLoginUseCase = MockLoginUseCase();
});
group('AuthProvider 测试', () {
test('初始状态应该是 null', () {
// Arrange
final container = ProviderContainer(
overrides: [
loginUseCaseProvider.overrideWithValue(mockLoginUseCase),
],
);
// Act
final state = container.read(authProvider);
// Assert
expect(state, null);
});
test('登录成功时,应该更新用户状态', () async {
// Arrange
const testUser = UserEntity(
id: '1',
email: 'test@example.com',
credits: 5,
);
when(() => mockLoginUseCase(any(), any()))
.thenAnswer((_) async => const Right(testUser));
final container = ProviderContainer(
overrides: [
loginUseCaseProvider.overrideWithValue(mockLoginUseCase),
],
);
// Act
await container.read(authProvider.notifier).login(
'test@example.com',
'password123',
);
// Assert
final state = container.read(authProvider);
expect(state, testUser);
});
test('登录失败时,状态应该保持 null', () async {
// Arrange
when(() => mockLoginUseCase(any(), any()))
.thenAnswer((_) async => const Left(AuthFailure()));
final container = ProviderContainer(
overrides: [
loginUseCaseProvider.overrideWithValue(mockLoginUseCase),
],
);
// Act
await container.read(authProvider.notifier).login(
'test@example.com',
'wrong_password',
);
// Assert
final state = container.read(authProvider);
expect(state, null);
});
});
}
本篇小结(第二部分)
这部分我们完成了:
- ✅ 测试辅助工具(pumpApp 封装)
- ✅ 登录页面 Widget 测试(输入、点击、错误提示)
- ✅ 列表滚动测试(下拉刷新、滑动删除)
- ✅ 导航测试(GoRouter 路由跳转)
- ✅ Riverpod Provider 测试(状态变化验证)
下一部分我会继续讲解集成测试和测试覆盖率。
四、集成测试
4.1 完整流程测试
// test/integration/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:ai_clean_mark/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('CleanMark AI 集成测试', () {
testWidgets('完整的登录到图片处理流程', (tester) async {
// 启动应用
app.main();
await tester.pumpAndSettle();
// 1. 验证启动页
expect(find.text('CleanMark AI'), findsOneWidget);
// 2. 跳转到登录页
await tester.tap(find.text('登录'));
await tester.pumpAndSettle();
// 3. 输入登录信息
await tester.enterText(
find.byKey(const Key('email_field')),
'test@example.com',
);
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
// 4. 点击登录
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle(const Duration(seconds: 3));
// 5. 验证跳转到首页
expect(find.text('首页'), findsOneWidget);
expect(find.text('我的积分'), findsOneWidget);
// 6. 点击图片去水印
await tester.tap(find.byKey(const Key('image_inpaint_card')));
await tester.pumpAndSettle();
// 7. 验证进入图片处理页
expect(find.text('图片去水印'), findsOneWidget);
expect(find.text('选择图片'), findsOneWidget);
});
testWidgets('积分不足时的提示流程', (tester) async {
// 启动应用
app.main();
await tester.pumpAndSettle();
// 登录(假设用户积分为 0)
await tester.tap(find.text('登录'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('email_field')),
'no_credits@example.com',
);
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle(const Duration(seconds: 3));
// 尝试处理图片
await tester.tap(find.byKey(const Key('image_inpaint_card')));
await tester.pumpAndSettle();
// 选择图片(模拟)
await tester.tap(find.text('选择图片'));
await tester.pumpAndSettle();
// 点击开始处理
await tester.tap(find.text('开始处理'));
await tester.pumpAndSettle();
// 验证积分不足提示
expect(find.text('积分不足'), findsOneWidget);
expect(find.text('充值'), findsOneWidget);
});
});
}
4.2 运行集成测试
# Android
flutter test integration_test/app_test.dart
# iOS
flutter test integration_test/app_test.dart --device-id=<iOS设备ID>
# 使用真机运行
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/app_test.dart
五、测试覆盖率
5.1 生成覆盖率报告
# 运行测试并生成覆盖率数据
flutter test --coverage
# 查看覆盖率报告(需要安装 lcov)
# macOS/Linux
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
# Windows
perl C:\ProgramData\chocolatey\lib\lcov\tools\bin\genhtml coverage/lcov.info -o coverage/html
start coverage/html/index.html
5.2 覆盖率配置
# test/coverage_helper_test.dart
// 导入所有需要统计覆盖率的文件
import 'package:ai_clean_mark/main.dart';
import 'package:ai_clean_mark/features/auth/domain/usecases/login_usecase.dart';
import 'package:ai_clean_mark/features/auth/data/repositories/auth_repository_impl.dart';
// ... 导入其他文件
void main() {
// 空测试,仅用于触发覆盖率统计
}
5.3 覆盖率目标
| 层级 | 目标覆盖率 | 说明 |
|---|---|---|
| Domain 层 | ≥ 90% | 核心业务逻辑必须高覆盖 |
| Data 层 | ≥ 80% | Repository 和 DataSource |
| Presentation 层 | ≥ 60% | UI 层覆盖关键交互 |
| 整体 | ≥ 70% | 项目整体覆盖率 |
本篇小结(第三部分)
这部分我们完成了:
- ✅ 集成测试实现(完整流程测试)
- ✅ 集成测试运行命令
- ✅ 测试覆盖率生成和查看
- ✅ 覆盖率目标设定
下一部分我会继续讲解 CI/CD 配置和部署流程。
六、CI/CD 配置
6.1 GitHub Actions 配置
# .github/workflows/test.yml
name: Flutter 测试
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置 Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: 安装依赖
run: flutter pub get
- name: 代码格式检查
run: flutter format --set-exit-if-changed .
- name: 静态分析
run: flutter analyze
- name: 运行单元测试
run: flutter test --coverage
- name: 上传覆盖率报告
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
build-android:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v3
- name: 设置 Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: 安装依赖
run: flutter pub get
- name: 构建 APK
run: flutter build apk --release
- name: 上传 APK
uses: actions/upload-artifact@v3
with:
name: app-release.apk
path: build/app/outputs/flutter-apk/app-release.apk
build-ios:
runs-on: macos-latest
needs: test
steps:
- uses: actions/checkout@v3
- name: 设置 Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: 安装依赖
run: flutter pub get
- name: 构建 iOS
run: flutter build ios --release --no-codesign
- name: 上传 IPA
uses: actions/upload-artifact@v3
with:
name: Runner.app
path: build/ios/iphoneos/Runner.app
6.2 自动化部署配置
# .github/workflows/deploy.yml
name: 自动部署
on:
push:
tags:
- 'v*'
jobs:
deploy-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置 Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: 配置签名
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore.jks
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=keystore.jks" >> android/key.properties
- name: 构建 APK
run: flutter build apk --release
- name: 创建 Release
uses: softprops/action-gh-release@v1
with:
files: build/app/outputs/flutter-apk/app-release.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
七、应用部署
7.1 Android 部署
1. 生成签名密钥
keytool -genkey -v -keystore ~/keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias cleanmark
2. 配置签名
# android/key.properties
storePassword=你的密钥库密码
keyPassword=你的密钥密码
keyAlias=cleanmark
storeFile=../keystore.jks
// android/app/build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
3. 构建发布版本
# 构建 APK
flutter build apk --release
# 构建 App Bundle(推荐用于 Google Play)
flutter build appbundle --release
7.2 iOS 部署
1. 配置证书和描述文件
- 在 Apple Developer 创建 App ID
- 创建发布证书(Distribution Certificate)
- 创建描述文件(Provisioning Profile)
2. 配置 Xcode
# 打开 Xcode 项目
open ios/Runner.xcworkspace
# 在 Xcode 中配置:
# - Signing & Capabilities → Team
# - Bundle Identifier
# - Provisioning Profile
3. 构建发布版本
# 构建 iOS
flutter build ios --release
# 打包 IPA(在 Xcode 中)
# Product → Archive → Distribute App
7.3 HarmonyOS 部署
1. 配置签名
# 在 DevEco Studio 中配置签名
# File → Project Structure → Signing Configs
2. 构建发布版本
# 使用 DevEco Studio 构建
# Build → Build Hap(s)/App(s) → Build Hap(s)
本篇小结(第四部分)
这部分我们完成了:
- ✅ GitHub Actions CI/CD 配置
- ✅ 自动化测试和构建流程
- ✅ Android 签名和部署
- ✅ iOS 证书配置和部署
- ✅ HarmonyOS 构建流程
下一部分我会总结本篇的关键要点。
八、完整小结
本篇完整小结
这篇文章我们完成了:
- ✅ 测试体系设计(测试金字塔 70%-20%-10%)
- ✅ 单元测试(UseCase、Repository、Utils)
- ✅ Widget 测试(交互、导航、Provider)
- ✅ 集成测试(完整流程测试)
- ✅ 测试覆盖率(生成和目标设定)
- ✅ CI/CD 配置(GitHub Actions)
- ✅ 三端部署(Android、iOS、HarmonyOS)
关键要点:
测试金字塔原则
集成测试(10%)
↗
Widget 测试(20%)
↗
单元测试(70%)
为什么这样分配?
- 单元测试:快速、稳定、易维护
- Widget 测试:覆盖 UI 交互,成本适中
- 集成测试:覆盖关键流程,成本高
单元测试最佳实践
1. AAA 模式(Arrange-Act-Assert)
test('登录成功时,应返回用户实体', () async {
// Arrange - 准备测试数据和 Mock
when(() => mockRepository.login(testEmail, testPassword))
.thenAnswer((_) async => const Right(testUser));
// Act - 执行被测试的方法
final result = await useCase(testEmail, testPassword);
// Assert - 验证结果
expect(result, const Right(testUser));
verify(() => mockRepository.login(testEmail, testPassword)).called(1);
});
2. 使用 mocktail 而非 mockito
// ✅ mocktail - 无需代码生成
class MockAuthRepository extends Mock implements AuthRepository {}
// ❌ mockito - 需要 build_runner
([AuthRepository])
3. 测试描述使用中文
// ✅ 清晰易懂
test('邮箱格式错误时,应返回 ValidationFailure', () async { ... });
// ❌ 英文描述
test('should return ValidationFailure when email is invalid', () async { ... });
Widget 测试最佳实践
1. 封装测试辅助函数
// test/helpers/pump_app.dart
Future<void> pumpApp(
WidgetTester tester,
Widget widget, {
List<Override>? overrides,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: overrides ?? [],
child: MaterialApp(home: widget),
),
);
}
2. 使用 Key 定位 Widget
// ✅ 使用 Key,稳定可靠
await tester.tap(find.byKey(const Key('login_button')));
// ❌ 使用文本,容易因文案变更而失败
await tester.tap(find.text('登录'));
3. pumpAndSettle 等待动画完成
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle(); // 等待所有动画和异步操作完成
集成测试要点
1. 只测试关键流程
testWidgets('完整的登录到图片处理流程', (tester) async {
// 启动 → 登录 → 首页 → 图片处理
// 覆盖核心业务流程
});
2. 使用真实数据和网络
- 集成测试应该使用真实的 API(测试环境)
- 避免 Mock,测试真实的集成情况
3. 运行时间控制
- 集成测试较慢,不要过多
- 关键流程 3-5 个即可
测试覆盖率策略
| 层级 | 目标 | 优先级 |
|---|---|---|
| Domain(UseCase) | ≥ 90% | 最高 |
| Data(Repository) | ≥ 80% | 高 |
| Presentation(UI) | ≥ 60% | 中 |
| 整体 | ≥ 70% | - |
覆盖率不是越高越好:
- 追求 100% 覆盖率会浪费时间
- 重点覆盖核心业务逻辑
- UI 层覆盖关键交互即可
CI/CD 最佳实践
1. 多阶段流水线
代码提交
↓
格式检查(flutter format)
↓
静态分析(flutter analyze)
↓
单元测试(flutter test)
↓
构建(flutter build)
↓
部署(发布到应用商店)
2. 并行构建
jobs:
test:
runs-on: ubuntu-latest
build-android:
needs: test
runs-on: ubuntu-latest
build-ios:
needs: test
runs-on: macos-latest
3. 缓存依赖
- name: 缓存 Flutter 依赖
uses: actions/cache@v3
with:
path: |
~/.pub-cache
.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
部署流程
Android 部署清单:
- ✅ 生成签名密钥(keystore.jks)
- ✅ 配置 key.properties
- ✅ 修改 build.gradle 添加签名配置
- ✅ 构建 App Bundle(flutter build appbundle)
- ✅ 上传到 Google Play Console
iOS 部署清单:
- ✅ 创建 App ID(Apple Developer)
- ✅ 创建发布证书(Distribution Certificate)
- ✅ 创建描述文件(Provisioning Profile)
- ✅ 在 Xcode 配置签名
- ✅ Archive 并上传到 App Store Connect
HarmonyOS 部署清单:
- ✅ 在 DevEco Studio 配置签名
- ✅ 构建 HAP(Build Hap(s))
- ✅ 上传到华为应用市场
常见问题
Q1: 测试运行很慢怎么办?
# 并行运行测试
flutter test --concurrency=4
# 只运行特定测试
flutter test test/unit/features/auth/
Q2: Widget 测试找不到元素?
// 使用 pumpAndSettle 等待动画
await tester.pumpAndSettle();
// 使用 pump 指定等待时间
await tester.pump(const Duration(seconds: 1));
Q3: 如何测试需要权限的功能?
// 使用 Mock 模拟权限请求
class MockPermissionHandler extends Mock implements PermissionHandler {}
when(() => mockPermissionHandler.request(Permission.camera))
.thenAnswer((_) async => PermissionStatus.granted);
Q4: 集成测试在 CI 中失败?
- 检查是否有网络依赖(使用测试环境 API)
- 检查是否有文件系统依赖(使用临时目录)
- 增加超时时间
测试驱动开发(TDD)
推荐流程:
1. 编写失败的测试
↓
2. 编写最少代码让测试通过
↓
3. 重构代码
↓
4. 重复
适用场景:
- 核心业务逻辑(UseCase、Repository)
- 复杂算法和工具函数
- Bug 修复(先写复现测试)
不适用场景:
- UI 原型阶段(需求不明确)
- 简单的 CRUD 操作
- 第三方库封装
思考题
- 为什么单元测试要占 70%,而不是集成测试?
- 如何测试一个需要相机权限的功能?
- CI/CD 中如何保护签名密钥不泄露?
系列完结
至此,《Flutter + HarmonyOS 跨平台开发实战》系列教程全部完成!
我们从零开始,完整实现了 CleanMark AI 图片/视频去水印应用,涵盖了:
- 项目架构设计(Clean Architecture)
- 状态管理(Riverpod)
- 网络请求封装
- 文件上传下载
- 图片/视频处理
- 积分系统
- 广告集成
- 历史记录
- 性能优化
- 测试与部署
希望这个系列能帮助你掌握 Flutter 跨平台开发,特别是 HarmonyOS 适配的实战技巧!
更多推荐




所有评论(0)