保障代码质量,顺利发布应用

前言

测试是保障代码质量的关键环节。一个完善的测试体系能够:

  • 提前发现 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);
    });
  });
}

本篇小结(第一部分)

这部分我们完成了:

  1. ✅ 测试体系设计(测试金字塔)
  2. ✅ 测试目录结构
  3. ✅ UseCase 单元测试(使用 mocktail)
  4. ✅ Repository 单元测试
  5. ✅ 工具类单元测试

下一部分我会继续讲解 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);
    });
  });
}

本篇小结(第二部分 - 上)

这部分我们完成了:

  1. ✅ 测试辅助工具(pumpApp 封装)
  2. ✅ 登录页面 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);
    });
  });
}

本篇小结(第二部分)

这部分我们完成了:

  1. ✅ 测试辅助工具(pumpApp 封装)
  2. ✅ 登录页面 Widget 测试(输入、点击、错误提示)
  3. ✅ 列表滚动测试(下拉刷新、滑动删除)
  4. ✅ 导航测试(GoRouter 路由跳转)
  5. ✅ 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% 项目整体覆盖率

本篇小结(第三部分)

这部分我们完成了:

  1. ✅ 集成测试实现(完整流程测试)
  2. ✅ 集成测试运行命令
  3. ✅ 测试覆盖率生成和查看
  4. ✅ 覆盖率目标设定

下一部分我会继续讲解 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)

本篇小结(第四部分)

这部分我们完成了:

  1. ✅ GitHub Actions CI/CD 配置
  2. ✅ 自动化测试和构建流程
  3. ✅ Android 签名和部署
  4. ✅ iOS 证书配置和部署
  5. ✅ HarmonyOS 构建流程

下一部分我会总结本篇的关键要点。


八、完整小结

本篇完整小结

这篇文章我们完成了:

  1. ✅ 测试体系设计(测试金字塔 70%-20%-10%)
  2. ✅ 单元测试(UseCase、Repository、Utils)
  3. ✅ Widget 测试(交互、导航、Provider)
  4. ✅ 集成测试(完整流程测试)
  5. ✅ 测试覆盖率(生成和目标设定)
  6. ✅ CI/CD 配置(GitHub Actions)
  7. ✅ 三端部署(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 部署清单:

  1. ✅ 生成签名密钥(keystore.jks)
  2. ✅ 配置 key.properties
  3. ✅ 修改 build.gradle 添加签名配置
  4. ✅ 构建 App Bundle(flutter build appbundle)
  5. ✅ 上传到 Google Play Console

iOS 部署清单:

  1. ✅ 创建 App ID(Apple Developer)
  2. ✅ 创建发布证书(Distribution Certificate)
  3. ✅ 创建描述文件(Provisioning Profile)
  4. ✅ 在 Xcode 配置签名
  5. ✅ Archive 并上传到 App Store Connect

HarmonyOS 部署清单:

  1. ✅ 在 DevEco Studio 配置签名
  2. ✅ 构建 HAP(Build Hap(s))
  3. ✅ 上传到华为应用市场

常见问题

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 操作
  • 第三方库封装

思考题

  1. 为什么单元测试要占 70%,而不是集成测试?
  2. 如何测试一个需要相机权限的功能?
  3. CI/CD 中如何保护签名密钥不泄露?

系列完结

至此,《Flutter + HarmonyOS 跨平台开发实战》系列教程全部完成!

我们从零开始,完整实现了 CleanMark AI 图片/视频去水印应用,涵盖了:

  • 项目架构设计(Clean Architecture)
  • 状态管理(Riverpod)
  • 网络请求封装
  • 文件上传下载
  • 图片/视频处理
  • 积分系统
  • 广告集成
  • 历史记录
  • 性能优化
  • 测试与部署

希望这个系列能帮助你掌握 Flutter 跨平台开发,特别是 HarmonyOS 适配的实战技巧!


Logo

一站式 AI 云服务平台

更多推荐