← 返回学习路线
🏗️

架构模式与测试

掌握 Flutter 应用的架构设计、依赖注入、单元测试、Widget 测试与集成测试,编写可维护、可测试的高质量代码

🧱 第一章:为什么需要架构

1.1 没有架构会怎样?

在小型项目中,把所有逻辑写在 Widget 里似乎可以正常工作。但随着项目规模增长,问题很快暴露出来:

代码耦合严重

UI 逻辑、业务逻辑、数据获取混杂在同一个 Widget 中,修改一个功能可能影响其他功能,牵一发而动全身。

难以测试

当业务逻辑嵌入 Widget 时,必须启动整个 UI 才能测试一段计算逻辑,测试成本极高。

团队协作困难

多人同时修改同一个文件导致频繁冲突,没有明确的职责划分,代码审查也变得困难。

难以复用

逻辑与特定 UI 绑定,无法在不同页面或项目中复用业务逻辑和数据处理代码。

1.2 关注点分离

关注点分离(Separation of Concerns)是架构设计的核心原则。每个模块只负责一件事情:

  • 表现层(Presentation):负责 UI 渲染和用户交互
  • 业务逻辑层(Domain):负责核心业务规则,不依赖任何框架
  • 数据层(Data):负责数据获取、缓存和持久化

1.3 可测试性

良好的架构让每一层都可以独立测试。业务逻辑不依赖 Flutter 框架,可以用纯 Dart 单元测试快速验证;UI 层只负责展示,可以用 Widget 测试验证界面行为。

反例:所有逻辑堆在 Widget 中
// 这段代码将网络请求、状态管理、UI 全部耦合在一起
class UserPage extends StatefulWidget {
  @override
  State<UserPage> createState() => _UserPageState();
}

class _UserPageState extends State<UserPage> {
  List<User> users = [];
  bool isLoading = false;

  @override
  void initState() {
    super.initState();
    _loadUsers();  // 网络请求直接写在 Widget 中
  }

  Future<void> _loadUsers() async {
    setState(() => isLoading = true);
    // 直接发起 HTTP 请求 —— 无法独立测试
    final response = await http.get(Uri.parse('https://api.example.com/users'));
    setState(() {
      users = jsonDecode(response.body);
      isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) { /* ... */ }
}
核心原则:好的架构不是一成不变的模板,而是根据项目规模和团队情况选择合适的方案。小项目不需要过度设计,大项目必须有清晰的层次划分。

📐 第二章:MVVM 模式

2.1 MVVM 概念

MVVM(Model-View-ViewModel)是 Flutter 中最常用的架构模式之一。它将应用分为三个部分:

Model

数据模型,表示应用的核心数据结构。纯 Dart 类,不依赖任何框架。

View

UI 层,即 Flutter Widget。只负责展示数据和接收用户输入,不包含业务逻辑。

ViewModel

连接 Model 和 View 的桥梁。持有状态、处理业务逻辑,通过通知机制更新 View。

2.2 使用 ChangeNotifier 实现 ViewModel

Flutter 原生的 ChangeNotifier 配合 Provider 是实现 MVVM 最简单的方式:

Model - user.dart
class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'],
      name: json['name'],
      email: json['email'],
    );
  }
}
ViewModel - user_view_model.dart
import 'package:flutter/foundation.dart';

class UserViewModel extends ChangeNotifier {
  final UserRepository _repository;

  List<User> _users = [];
  List<User> get users => _users;

  bool _isLoading = false;
  bool get isLoading => _isLoading;

  String? _errorMessage;
  String? get errorMessage => _errorMessage;

  UserViewModel(this._repository);

  /// 加载用户列表
  Future<void> loadUsers() async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();

    try {
      _users = await _repository.getUsers();
    } catch (e) {
      _errorMessage = '加载失败:$e';
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  /// 删除用户
  Future<void> deleteUser(String id) async {
    await _repository.deleteUser(id);
    _users.removeWhere((u) => u.id == id);
    notifyListeners();
  }
}
View - user_page.dart
class UserPage extends StatelessWidget {
  const UserPage({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => UserViewModel(getIt<UserRepository>())..loadUsers(),
      child: Scaffold(
        appBar: AppBar(title: const Text('用户列表')),
        body: Consumer<UserViewModel>(
          builder: (context, vm, child) {
            if (vm.isLoading) {
              return const Center(child: CircularProgressIndicator());
            }
            if (vm.errorMessage != null) {
              return Center(child: Text(vm.errorMessage!));
            }
            return ListView.builder(
              itemCount: vm.users.length,
              itemBuilder: (context, index) {
                final user = vm.users[index];
                return ListTile(
                  title: Text(user.name),
                  subtitle: Text(user.email),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete),
                    onPressed: () => vm.deleteUser(user.id),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}
提示:ViewModel 不应持有任何 BuildContext 或 Widget 引用。它是纯 Dart 类(继承 ChangeNotifier),可以被完全独立地单元测试。

🏛️ 第三章:Clean Architecture

3.1 三层架构

Clean Architecture 由 Robert C. Martin 提出,其核心思想是让业务逻辑独立于框架、UI 和数据源。在 Flutter 中通常分为三层:

Presentation 层

Widget、Page、ViewModel/Bloc。负责 UI 渲染,依赖 Domain 层获取数据。

Domain 层

Entity、UseCase、Repository 接口。纯 Dart 代码,不依赖任何外部库。

Data 层

Repository 实现、DataSource、Model(DTO)。负责与 API、数据库等外部系统交互。

3.2 依赖规则

依赖方向只能从外向内:Presentation → Domain ← Data。Domain 层是最内层,不依赖任何其他层。Data 层通过实现 Domain 层定义的 Repository 接口来提供数据。

3.3 目录结构

项目目录结构
lib/
├── core/                       # 公共工具
│   ├── error/                  # 异常和失败类
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/                # 网络相关工具
│   │   └── network_info.dart
│   └── usecases/               # UseCase 基类
│       └── usecase.dart
├── features/                   # 功能模块
│   └── user/
│       ├── data/               # 数据层
│       │   ├── datasources/
│       │   │   ├── user_remote_data_source.dart
│       │   │   └── user_local_data_source.dart
│       │   ├── models/
│       │   │   └── user_model.dart
│       │   └── repositories/
│       │       └── user_repository_impl.dart
│       ├── domain/             # 领域层
│       │   ├── entities/
│       │   │   └── user.dart
│       │   ├── repositories/
│       │   │   └── user_repository.dart
│       │   └── usecases/
│       │       ├── get_users.dart
│       │       └── delete_user.dart
│       └── presentation/      # 表现层
│           ├── viewmodels/
│           │   └── user_viewmodel.dart
│           ├── pages/
│           │   └── user_page.dart
│           └── widgets/
│               └── user_card.dart
└── injection_container.dart    # 依赖注入配置

3.4 Domain 层代码

domain/entities/user.dart
// Entity:纯业务数据,不包含序列化逻辑
class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}
domain/repositories/user_repository.dart
import 'package:dartz/dartz.dart';

// Repository 接口:定义数据操作契约
abstract class UserRepository {
  Future<Either<Failure, List<User>>> getUsers();
  Future<Either<Failure, User>> getUserById(String id);
  Future<Either<Failure, void>> deleteUser(String id);
}
core/usecases/usecase.dart
import 'package:dartz/dartz.dart';

// UseCase 基类
abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

// 无参数时使用
class NoParams {
  const NoParams();
}
domain/usecases/get_users.dart
class GetUsers extends UseCase<List<User>, NoParams> {
  final UserRepository repository;

  GetUsers(this.repository);

  @override
  Future<Either<Failure, List<User>>> call(NoParams params) {
    return repository.getUsers();
  }
}

3.5 Data 层代码

data/models/user_model.dart
// Model(DTO):继承 Entity,添加序列化逻辑
class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> toJson() {
    return {'id': id, 'name': name, 'email': email};
  }
}
data/datasources/user_remote_data_source.dart
abstract class UserRemoteDataSource {
  Future<List<UserModel>> getUsers();
  Future<void> deleteUser(String id);
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final Dio dio;

  UserRemoteDataSourceImpl({required this.dio});

  @override
  Future<List<UserModel>> getUsers() async {
    final response = await dio.get('/users');
    return (response.data as List)
        .map((json) => UserModel.fromJson(json))
        .toList();
  }

  @override
  Future<void> deleteUser(String id) async {
    await dio.delete('/users/$id');
  }
}
data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;
  final UserLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  UserRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<User>>> getUsers() async {
    if (await networkInfo.isConnected) {
      try {
        final users = await remoteDataSource.getUsers();
        await localDataSource.cacheUsers(users);
        return Right(users);
      } on ServerException {
        return Left(ServerFailure());
      }
    } else {
      try {
        final cachedUsers = await localDataSource.getCachedUsers();
        return Right(cachedUsers);
      } on CacheException {
        return Left(CacheFailure());
      }
    }
  }

  @override
  Future<Either<Failure, void>> deleteUser(String id) async {
    try {
      await remoteDataSource.deleteUser(id);
      return const Right(null);
    } on ServerException {
      return Left(ServerFailure());
    }
  }
}
提示:使用 dartz 包的 Either 类型来处理成功和失败两种情况,避免在业务逻辑中使用 try-catch。Left 表示失败,Right 表示成功。

🗂️ 第四章:Repository Pattern

4.1 抽象 Repository

Repository 模式的核心是定义抽象接口,将数据访问细节与业务逻辑隔离。上层代码只依赖接口,不关心数据来自网络还是本地缓存。

抽象 Repository 接口
abstract class ArticleRepository {
  /// 获取文章列表,支持分页
  Future<Either<Failure, List<Article>>> getArticles({int page = 1});

  /// 获取文章详情
  Future<Either<Failure, Article>> getArticleById(String id);

  /// 收藏文章
  Future<Either<Failure, void>> favoriteArticle(String id);

  /// 获取本地收藏列表
  Future<Either<Failure, List<Article>>> getFavorites();
}

4.2 Remote 和 Local DataSource

远程数据源
abstract class ArticleRemoteDataSource {
  Future<List<ArticleModel>> getArticles(int page);
  Future<ArticleModel> getArticleById(String id);
  Future<void> favoriteArticle(String id);
}

class ArticleRemoteDataSourceImpl implements ArticleRemoteDataSource {
  final Dio dio;
  ArticleRemoteDataSourceImpl({required this.dio});

  @override
  Future<List<ArticleModel>> getArticles(int page) async {
    final response = await dio.get('/articles', queryParameters: {'page': page});
    return (response.data['data'] as List)
        .map((e) => ArticleModel.fromJson(e))
        .toList();
  }

  @override
  Future<ArticleModel> getArticleById(String id) async {
    final response = await dio.get('/articles/$id');
    return ArticleModel.fromJson(response.data);
  }

  @override
  Future<void> favoriteArticle(String id) async {
    await dio.post('/articles/$id/favorite');
  }
}
本地数据源 - 使用 Hive 缓存
abstract class ArticleLocalDataSource {
  Future<List<ArticleModel>> getCachedArticles();
  Future<void> cacheArticles(List<ArticleModel> articles);
  Future<List<ArticleModel>> getFavorites();
  Future<void> saveFavorite(ArticleModel article);
}

class ArticleLocalDataSourceImpl implements ArticleLocalDataSource {
  final Box<ArticleModel> articlesBox;
  final Box<ArticleModel> favoritesBox;

  ArticleLocalDataSourceImpl({
    required this.articlesBox,
    required this.favoritesBox,
  });

  @override
  Future<List<ArticleModel>> getCachedArticles() async {
    if (articlesBox.isEmpty) throw CacheException();
    return articlesBox.values.toList();
  }

  @override
  Future<void> cacheArticles(List<ArticleModel> articles) async {
    await articlesBox.clear();
    for (final article in articles) {
      await articlesBox.put(article.id, article);
    }
  }

  @override
  Future<List<ArticleModel>> getFavorites() async {
    return favoritesBox.values.toList();
  }

  @override
  Future<void> saveFavorite(ArticleModel article) async {
    await favoritesBox.put(article.id, article);
  }
}

4.3 带缓存策略的 Repository 实现

repository 实现 - 离线优先策略
class ArticleRepositoryImpl implements ArticleRepository {
  final ArticleRemoteDataSource remote;
  final ArticleLocalDataSource local;
  final NetworkInfo networkInfo;

  ArticleRepositoryImpl({
    required this.remote,
    required this.local,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Article>>> getArticles({int page = 1}) async {
    if (await networkInfo.isConnected) {
      try {
        final articles = await remote.getArticles(page);
        if (page == 1) {
          await local.cacheArticles(articles); // 仅缓存第一页
        }
        return Right(articles);
      } on ServerException catch (e) {
        return Left(ServerFailure(message: e.message));
      }
    } else {
      try {
        final cached = await local.getCachedArticles();
        return Right(cached);
      } on CacheException {
        return Left(CacheFailure(message: '无网络且无缓存数据'));
      }
    }
  }
}

4.4 错误处理

统一错误处理体系
// 异常类 —— 由 DataSource 抛出
class ServerException implements Exception {
  final String message;
  final int? statusCode;
  ServerException({this.message = '服务器异常', this.statusCode});
}

class CacheException implements Exception {
  final String message;
  CacheException({this.message = '缓存异常'});
}

// 失败类 —— 由 Repository 返回,供上层使用
abstract class Failure {
  final String message;
  const Failure({this.message = '未知错误'});
}

class ServerFailure extends Failure {
  const ServerFailure({super.message = '服务器错误'});
}

class CacheFailure extends Failure {
  const CacheFailure({super.message = '缓存错误'});
}

class NetworkFailure extends Failure {
  const NetworkFailure({super.message = '网络连接失败'});
}
设计原则:Exception 是技术层面的异常(由 DataSource 抛出),Failure 是业务层面的错误(由 Repository 返回给上层)。这样上层代码不需要处理异常,只需要处理 Either 的 Left/Right 分支。

💉 第五章:依赖注入

5.1 get_it 服务定位器

get_it 是 Flutter 中最常用的依赖注入库,它是一个简单的服务定位器(Service Locator),支持单例、懒加载单例和工厂模式。

pubspec.yaml
dependencies:
  get_it: ^7.6.0
  injectable: ^2.3.0

dev_dependencies:
  injectable_generator: ^2.4.0
  build_runner: ^2.4.0

5.2 注册类型

injection_container.dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

Future<void> configureDependencies() async {
  // ============ 外部依赖 ============
  final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
  getIt.registerSingleton<Dio>(dio);

  // ============ 核心服务 ============
  // registerSingleton:立即创建,全局唯一
  getIt.registerSingleton<NetworkInfo>(
    NetworkInfoImpl(),
  );

  // registerLazySingleton:首次使用时创建,全局唯一
  getIt.registerLazySingleton<UserRemoteDataSource>(
    () => UserRemoteDataSourceImpl(dio: getIt()),
  );

  getIt.registerLazySingleton<UserLocalDataSource>(
    () => UserLocalDataSourceImpl(),
  );

  // Repository
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(
      remoteDataSource: getIt(),
      localDataSource: getIt(),
      networkInfo: getIt(),
    ),
  );

  // UseCases
  getIt.registerLazySingleton(() => GetUsers(getIt()));

  // registerFactory:每次请求创建新实例
  getIt.registerFactory(
    () => UserViewModel(repository: getIt()),
  );
}

5.3 在 main 中初始化

main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await configureDependencies();
  runApp(const MyApp());
}

// 在任意位置使用
final userRepo = getIt<UserRepository>();
final vm = getIt<UserViewModel>();

5.4 使用 injectable 自动生成

手动注册依赖容易遗漏和出错。injectable 通过注解自动生成注册代码:

使用注解标记类
import 'package:injectable/injectable.dart';

@singleton   // 单例
class NetworkInfoImpl implements NetworkInfo {
  // ...
}

@lazySingleton   // 懒加载单例
class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final Dio dio;
  UserRemoteDataSourceImpl(this.dio);
}

@injectable   // 工厂模式,每次创建新实例
class UserViewModel extends ChangeNotifier {
  final UserRepository repository;
  UserViewModel(this.repository);
}

// 运行代码生成
// dart run build_runner build --delete-conflicting-outputs
提示:registerSingleton 在注册时立即创建实例,适合必须提前初始化的服务;registerLazySingleton 延迟到首次使用时创建,适合大部分场景;registerFactory 每次获取都创建新实例,适合 ViewModel 等需要独立状态的对象。

🧪 第六章:单元测试

6.1 test 包基础

Flutter 的 test 包提供了编写单元测试的核心 API。单元测试用于验证单个函数、方法或类的行为是否正确。

pubspec.yaml
dev_dependencies:
  test: ^1.24.0
  mocktail: ^1.0.0    # 或者 mockito + build_runner
test/unit/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_app/calculator.dart';

void main() {
  // group 将相关测试分组
  group('Calculator', () {
    late Calculator calculator;

    // setUp 在每个测试前运行
    setUp(() {
      calculator = Calculator();
    });

    test('两个正数相加', () {
      expect(calculator.add(2, 3), equals(5));
    });

    test('除以零抛出异常', () {
      expect(
        () => calculator.divide(10, 0),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('浮点数近似匹配', () {
      expect(calculator.divide(10, 3), closeTo(3.333, 0.01));
    });
  });
}

6.2 常用 Matchers

常用断言匹配器
// 相等
expect(value, equals(42));
expect(value, isNot(equals(0)));

// 类型检查
expect(value, isA<String>());
expect(value, isNull);
expect(value, isNotNull);

// 集合
expect(list, contains('hello'));
expect(list, hasLength(3));
expect(list, isEmpty);
expect(list, containsAll(['a', 'b']));

// 比较
expect(value, greaterThan(10));
expect(value, lessThanOrEqualTo(100));
expect(value, inInclusiveRange(1, 10));

// 字符串
expect(str, startsWith('hello'));
expect(str, endsWith('world'));
expect(str, matches(RegExp(r'\d+')));

// 异常
expect(() => doSomething(), throwsException);
expect(() => doSomething(), throwsA(isA<FormatException>()));

6.3 使用 mocktail 模拟依赖

test/unit/user_viewmodel_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

// 创建 Mock 类
class MockUserRepository extends Mock implements UserRepository {}

void main() {
  late MockUserRepository mockRepo;
  late UserViewModel viewModel;

  setUp(() {
    mockRepo = MockUserRepository();
    viewModel = UserViewModel(mockRepo);
  });

  final testUsers = [
    const User(id: '1', name: '张三', email: 'zhang@test.com'),
    const User(id: '2', name: '李四', email: 'li@test.com'),
  ];

  group('loadUsers', () {
    test('成功加载用户列表', () async {
      // Arrange - 设置 Mock 行为
      when(() => mockRepo.getUsers())
          .thenAnswer((_) async => Right(testUsers));

      // Act - 执行操作
      await viewModel.loadUsers();

      // Assert - 验证结果
      expect(viewModel.users, equals(testUsers));
      expect(viewModel.isLoading, isFalse);
      expect(viewModel.errorMessage, isNull);

      // 验证方法被调用了一次
      verify(() => mockRepo.getUsers()).called(1);
    });

    test('加载失败设置错误信息', () async {
      when(() => mockRepo.getUsers())
          .thenAnswer((_) async => Left(ServerFailure()));

      await viewModel.loadUsers();

      expect(viewModel.users, isEmpty);
      expect(viewModel.errorMessage, isNotNull);
    });
  });

  group('deleteUser', () {
    test('删除后从列表中移除', () async {
      // 先加载数据
      when(() => mockRepo.getUsers())
          .thenAnswer((_) async => Right(testUsers));
      when(() => mockRepo.deleteUser('1'))
          .thenAnswer((_) async => const Right(null));

      await viewModel.loadUsers();
      await viewModel.deleteUser('1');

      expect(viewModel.users.length, equals(1));
      expect(viewModel.users.first.name, equals('李四'));
    });
  });
}

6.4 异步测试

异步测试技巧
test('异步操作测试', () async {
  // 使用 async/await
  final result = await service.fetchData();
  expect(result, isNotNull);
});

test('Stream 测试', () {
  // 使用 emitsInOrder 验证 Stream 输出顺序
  expect(
    counterStream,
    emitsInOrder([1, 2, 3]),
  );
});

test('超时测试', () async {
  // 设置超时时间
  await service.slowOperation();
}, timeout: Timeout(const Duration(seconds: 10)));

test('使用 fake async 控制时间', () {
  fakeAsync((async) {
    var completed = false;
    Future.delayed(const Duration(seconds: 5), () {
      completed = true;
    });

    // 快进 5 秒
    async.elapse(const Duration(seconds: 5));
    expect(completed, isTrue);
  });
});
运行测试:使用 flutter test 运行所有测试,flutter test test/unit/ 运行特定目录,flutter test --coverage 生成覆盖率报告。

🖼️ 第七章:Widget 测试

7.1 testWidgets 基础

Widget 测试在模拟环境中渲染 Widget,不需要真实设备。它比单元测试多了 UI 渲染能力,比集成测试快得多。

test/widget/counter_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_page.dart';

void main() {
  testWidgets('计数器初始值为 0', (WidgetTester tester) async {
    // 构建 Widget
    await tester.pumpWidget(
      const MaterialApp(home: CounterPage()),
    );

    // 查找并验证
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);
  });

  testWidgets('点击加号按钮计数器加一', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: CounterPage()),
    );

    // 点击加号按钮
    await tester.tap(find.byIcon(Icons.add));

    // 触发重建
    await tester.pump();

    // 验证结果
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);
  });
}

7.2 Finder 查找方式

常用 Finder
// 按文本查找
find.text('Hello');

// 按 Widget 类型查找
find.byType(ElevatedButton);

// 按 Icon 查找
find.byIcon(Icons.add);

// 按 Key 查找
find.byKey(const Key('submit_button'));

// 按 Widget 实例查找
find.byWidget(myWidget);

// 后代查找 —— 在特定 Widget 下查找
find.descendant(
  of: find.byType(AppBar),
  matching: find.text('标题'),
);

// 匹配数量断言
expect(finder, findsOneWidget);       // 恰好一个
expect(finder, findsNothing);          // 零个
expect(finder, findsNWidgets(3));      // 恰好 N 个
expect(finder, findsAtLeastNWidgets(1)); // 至少 N 个

7.3 用户交互操作

WidgetTester 交互方法
// 点击
await tester.tap(find.byType(ElevatedButton));

// 输入文本
await tester.enterText(find.byType(TextField), 'Hello World');

// 长按
await tester.longPress(find.byType(ListTile));

// 拖拽
await tester.drag(find.byType(ListView), const Offset(0, -300));

// 重建 Widget(无动画)
await tester.pump();

// 等待所有动画完成
await tester.pumpAndSettle();

// 等待异步操作
await tester.pump(const Duration(seconds: 1));

7.4 完整 Widget 测试示例

test/widget/login_page_test.dart
void main() {
  testWidgets('登录页面完整交互测试', (WidgetTester tester) async {
    // 构建登录页面
    await tester.pumpWidget(
      MaterialApp(home: LoginPage()),
    );

    // 验证初始状态:登录按钮存在但禁用
    final loginButton = find.byKey(const Key('login_button'));
    expect(loginButton, findsOneWidget);

    // 输入用户名
    await tester.enterText(
      find.byKey(const Key('email_field')),
      'user@example.com',
    );

    // 输入密码
    await tester.enterText(
      find.byKey(const Key('password_field')),
      'password123',
    );

    await tester.pump();

    // 点击登录按钮
    await tester.tap(loginButton);
    await tester.pumpAndSettle();

    // 验证显示加载指示器
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // 等待异步操作完成
    await tester.pumpAndSettle();

    // 验证登录成功后导航到首页
    expect(find.byType(HomePage), findsOneWidget);
  });

  testWidgets('输入无效邮箱显示错误信息', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(home: LoginPage()),
    );

    // 输入无效邮箱
    await tester.enterText(
      find.byKey(const Key('email_field')),
      'invalid-email',
    );

    // 点击登录
    await tester.tap(find.byKey(const Key('login_button')));
    await tester.pump();

    // 验证显示错误提示
    expect(find.text('请输入有效的邮箱地址'), findsOneWidget);
  });
}
提示:pump() 只触发一帧的重建,适合立即生效的状态变化;pumpAndSettle() 等待所有动画和异步帧完成,适合有动画的场景。对于网络请求等真正的异步操作,需要配合 Mock 使用。

📱 第八章:集成测试

8.1 integration_test 包

集成测试在真实设备或模拟器上运行,测试完整的应用流程。它能验证多个模块协同工作的表现。

pubspec.yaml
dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

8.2 编写集成测试

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('完整登录流程', (WidgetTester tester) async {
      // 启动完整应用
      app.main();
      await tester.pumpAndSettle();

      // 验证在登录页面
      expect(find.text('登录'), findsOneWidget);

      // 输入凭证
      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();

      // 验证登录成功,进入首页
      expect(find.text('首页'), findsOneWidget);

      // 点击用户列表
      await tester.tap(find.text('用户管理'));
      await tester.pumpAndSettle();

      // 验证用户列表加载
      expect(find.byType(ListView), findsOneWidget);
    });
  });
}

8.3 在设备上运行集成测试

运行集成测试
# 在连接的设备上运行
flutter test integration_test/app_test.dart

# 在指定设备上运行
flutter test integration_test/app_test.dart -d <device_id>

# 在 Chrome 上运行(Web)
flutter test integration_test/app_test.dart -d chrome

# 运行所有集成测试
flutter test integration_test/

# 截图(用于 CI 报告)
# 在测试代码中:
# await binding.takeScreenshot('login_page');
注意:集成测试运行速度较慢,建议只为核心业务流程编写集成测试。日常开发中以单元测试和 Widget 测试为主,集成测试在 CI/CD 流水线中运行。

📏 第九章:代码规范

9.1 analysis_options.yaml

Dart 的静态分析工具通过 analysis_options.yaml 配置,它定义了代码规范规则,帮助团队保持一致的代码风格。

analysis_options.yaml - 基础配置
# 使用 Flutter 官方推荐规则
include: package:flutter_lints/flutter.yaml

analyzer:
  errors:
    # 将某些警告提升为错误
    missing_return: error
    dead_code: warning
    unused_import: warning
  exclude:
    - "**/*.g.dart"          # 排除生成的代码
    - "**/*.freezed.dart"

linter:
  rules:
    # 代码风格
    - prefer_const_constructors
    - prefer_const_declarations
    - prefer_final_locals
    - prefer_single_quotes
    - sort_constructors_first
    - sort_unnamed_constructors_first

    # 类型安全
    - always_declare_return_types
    - avoid_dynamic_calls
    - prefer_typing_uninitialized_variables

    # 可读性
    - avoid_print            # 禁止使用 print,使用 log
    - require_trailing_commas

9.2 flutter_lints 与 very_good_analysis

pubspec.yaml
# 方案一:Flutter 官方规则(默认)
dev_dependencies:
  flutter_lints: ^3.0.0

# 方案二:Very Good Ventures 严格规则(推荐)
dev_dependencies:
  very_good_analysis: ^5.1.0
使用 very_good_analysis
# analysis_options.yaml
include: package:very_good_analysis/analysis_options.yaml

# 可以覆盖某些规则
linter:
  rules:
    public_member_api_docs: false   # 关闭强制文档注释

9.3 运行分析和格式化

命令行工具
# 静态分析
flutter analyze
dart analyze

# 代码格式化
dart format .
dart format --set-exit-if-changed .  # CI 中使用,有未格式化代码则失败

# 自动修复 lint 问题
dart fix --apply

# 查看可修复的问题
dart fix --dry-run
最佳实践:在 CI/CD 流水线中同时运行 dart format --set-exit-if-changed .flutter analyze --fatal-infos,确保所有提交的代码都符合团队规范。建议使用 very_good_analysis 作为基础规则,它比默认的 flutter_lints 更严格,有助于提升代码质量。

✏️ 第十章:实践练习

练习 1:Clean Architecture 笔记应用

使用 Clean Architecture 搭建一个笔记管理应用,练习完整的三层架构设计。

  1. 按照 features/note/ 目录结构组织代码,包含 data、domain、presentation 三层
  2. 定义 Note Entity 和 NoteModel(带 JSON 序列化)
  3. 创建 NoteRepository 抽象接口,实现增删改查四个方法
  4. 使用 Hive 作为本地数据源,实现 NoteLocalDataSource
  5. 使用 get_it 注册所有依赖
  6. 编写 ViewModel 并通过 Provider 连接 UI

练习 2:为 ViewModel 编写完整单元测试

为练习 1 中的 NoteViewModel 编写全覆盖的单元测试。

  1. 使用 mocktail 创建 MockNoteRepository
  2. 测试 loadNotes:成功加载、加载失败、空列表三种情况
  3. 测试 addNote:成功添加后列表更新
  4. 测试 deleteNote:删除后从列表移除
  5. 验证每个操作中 isLoading 状态的变化(先变 true 再变 false)
  6. 确保测试覆盖率达到 90% 以上

练习 3:Widget 测试与集成测试

为笔记应用的核心页面编写 Widget 测试和一个端到端集成测试。

  1. 编写 Widget 测试:验证笔记列表页正确渲染、空状态显示提示文字
  2. 编写 Widget 测试:验证新建笔记对话框的输入和提交功能
  3. 编写 Widget 测试:验证滑动删除笔记的交互
  4. 编写一个集成测试:从打开应用到新建笔记到查看笔记详情的完整流程
  5. 配置 analysis_options.yaml 使用 very_good_analysis 规则
  6. 确保 flutter analyzedart format 全部通过