Obtainium单元测试模拟:Mockito框架实践
引言:为什么需要单元测试模拟?
在Obtainium这样的Android应用更新管理器开发中,你是否经常遇到这些痛点:
- 依赖第三方API(如GitHub、GitLab)导致测试不稳定
- 网络请求、文件系统操作难以复现异常场景
- 测试覆盖率低,核心业务逻辑不敢重构
本文将通过6个实战案例,展示如何使用Mockito框架为Obtainium项目编写可靠的单元测试,解决上述问题。读完本文你将掌握:
- Dart/Flutter项目中集成Mockito的完整流程
- 模拟HTTP请求、文件系统、第三方插件的核心技巧
- 为AppSource系列类编写隔离测试的方法论
- 测试覆盖率提升30%的实用策略
环境准备:搭建Mockito测试环境
添加依赖
首先在pubspec.yaml中添加测试相关依赖:
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.4.0
mocktail: ^1.0.0 # 可选,用于更简洁的模拟语法
flutter_lints: ^6.0.0
test: ^1.24.0
执行依赖安装命令:
flutter pub get
项目测试结构
建议采用与lib目录镜像的测试结构:
test/
├── app_sources/
│ ├── github_test.dart
│ ├── fdroid_test.dart
│ └── ...
├── providers/
│ ├── apps_provider_test.dart
│ └── ...
└── utils/
└── test_helpers.dart
创建test/utils/test_helpers.dart工具类,封装通用测试配置:
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
class MockClient extends Mock implements http.Client {}
// 通用的测试初始化函数
void setupTest() {
// 初始化测试环境
}
Mockito核心概念与基础语法
核心概念图解
基础语法速查表
| 操作 | 语法示例 | 说明 |
|---|---|---|
| 创建模拟 | final mockClient = MockClient(); | 实例化模拟对象 |
| 设置存根 | when(mockClient.get(any)).thenAnswer((_) async => http.Response('{}', 200)); | 定义方法调用返回值 |
| 验证交互 | verify(mockClient.get(Uri.parse('https://api.github.com/repos/test/repo'))).called(1); | 检查方法是否被调用 |
| 参数匹配 | when(mockClient.get(argThat(contains('github.com')))).thenAnswer(...); | 匹配符合条件的参数 |
| 抛出异常 | when(mockClient.get(any)).thenThrow(http.ClientException('Network error')); | 模拟异常场景 |
实战案例1:模拟HTTP请求测试GitHub数据源
测试场景分析
GitHub类的getLatestAPKDetails方法依赖GitHub API,我们需要模拟:
- HTTP请求返回不同状态码(200/404/429)
- 不同的API响应内容(正常数据/空数据/格式错误)
- 网络异常情况
测试代码实现
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
import 'package:obtainium/app_sources/github.dart';
import 'package:test/test.dart';
import 'package:obtainium/custom_errors.dart';
import '../utils/test_helpers.dart';
void main() {
late GitHub githubSource;
late MockClient mockClient;
setUp(() {
setupTest();
mockClient = MockClient();
githubSource = GitHub();
// 替换默认的HTTP客户端为模拟客户端
githubSource.httpClient = mockClient;
});
group('getLatestAPKDetails', () {
const testUrl = 'https://github.com/test/repo';
test('returns APKDetails when API call succeeds', () async {
// Arrange
final responseBody = '''[
{
"tag_name": "v1.0.0",
"assets": [
{"name": "app-release.apk", "browser_download_url": "https://example.com/app.apk"}
]
}
]''';
when(mockClient.get(any, headers: anyNamed('headers')))
.thenAnswer((_) async => http.Response(responseBody, 200));
// Act
final result = await githubSource.getLatestAPKDetails(
testUrl,
{'includePrereleases': false},
);
// Assert
expect(result.version, 'v1.0.0');
expect(result.apkUrls.length, 1);
expect(result.apkUrls.first.value, 'https://example.com/app.apk');
verify(mockClient.get(Uri.parse(
'https://api.github.com/repos/test/repo/releases?per_page=100'
))).called(1);
});
test('throws NoReleasesError when API returns empty list', () async {
// Arrange
when(mockClient.get(any))
.thenAnswer((_) async => http.Response('[]', 200));
// Act & Assert
expect(
githubSource.getLatestAPKDetails(testUrl, {}),
throwsA(isA<NoReleasesError>()),
);
});
test('throws RateLimitError when API returns 429', () async {
// Arrange
when(mockClient.get(any))
.thenAnswer((_) async => http.Response(
'{"message": "Rate limit exceeded"}',
429,
headers: {'x-ratelimit-reset': '1620000000'}
));
// Act & Assert
expect(
githubSource.getLatestAPKDetails(testUrl, {}),
throwsA(isA<RateLimitError>()),
);
});
});
}
测试覆盖率分析
实战案例2:模拟文件系统操作
场景与解决方案
AppsProvider中的downloadFile方法涉及文件系统操作,我们需要模拟:
- 文件创建与写入
- 目录访问权限
- 文件删除操作
使用mockito结合path_provider的测试替代方案:
import 'package:mockito/mockito.dart';
import 'package:path_provider/path_provider.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart' as mocktail;
import '../utils/test_helpers.dart';
// 使用mocktail模拟Directory类
class MockDirectory extends mocktail.Mock implements Directory {
@override
Future<File> create(
{bool recursive = false, bool exclusive = false}) async {
return super.noSuchMethod(Invocation.method(#create, [], {
#recursive: recursive,
#exclusive: exclusive,
}), returnValue: Future.value(MockFile())) as Future<File>;
}
}
class MockFile extends mocktail.Mock implements File {}
void main() {
late AppsProvider appsProvider;
late MockDirectory mockDir;
setUp(() {
setupTest();
appsProvider = AppsProvider(isBg: true);
mockDir = MockDirectory();
// 覆盖路径提供器
when(() => mockDir.path).thenReturn('/test/path');
when(() => mockDir.create(recursive: any(named: 'recursive')))
.thenAnswer((_) async => mockDir);
// 注入模拟目录
appsProvider.APKDir = mockDir;
});
test('downloadFile creates correct file path', () async {
// Arrange
final mockFile = MockFile();
when(() => mockDir.file(any())).thenReturn(mockFile);
when(() => mockFile.path).thenReturn('/test/path/test.apk');
when(() => mockFile.create(recursive: true))
.thenAnswer((_) async => mockFile);
// Act
final result = await appsProvider.downloadFile(
'https://example.com/test.apk',
'test',
false,
null,
'/test/path',
);
// Assert
expect(result.path, '/test/path/test.apk');
verify(() => mockDir.file('test.apk')).called(1);
});
}
实战案例3:模拟第三方插件
模拟AndroidPackageManager
import 'package:mockito/mockito.dart';
import 'package:android_package_manager/android_package_manager.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:test/test.dart';
import '../utils/test_helpers.dart';
class MockPackageManager extends Mock implements AndroidPackageManager {
@override
Future<PackageInfo?> getPackageInfo({required String packageName}) async {
return super.noSuchMethod(Invocation.method(#getPackageInfo, [], {
#packageName: packageName,
}), returnValue: Future.value(PackageInfo(
packageName: packageName,
versionName: '1.0.0',
versionCode: 1,
))) as Future<PackageInfo?>;
}
}
void main() {
late AppsProvider appsProvider;
late MockPackageManager mockPackageManager;
setUp(() {
setupTest();
appsProvider = AppsProvider(isBg: true);
mockPackageManager = MockPackageManager();
// 替换全局pm实例
appsProvider.pm = mockPackageManager;
});
test('getInstalledInfo returns correct version', () async {
// Arrange
when(mockPackageManager.getPackageInfo(packageName: 'com.example.app'))
.thenAnswer((_) async => PackageInfo(
packageName: 'com.example.app',
versionName: '1.0.0',
versionCode: 1,
));
// Act
final info = await appsProvider.getInstalledInfo('com.example.app');
// Assert
expect(info?.versionName, '1.0.0');
expect(info?.versionCode, 1);
});
}
高级技巧:依赖注入与测试替身策略
依赖注入模式
重构GitHub类以支持依赖注入:
// 修改github.dart以支持依赖注入
class GitHub extends AppSource {
// 允许注入HTTP客户端,便于测试
http.Client httpClient;
GitHub({hostChanged = false, http.Client? client}) :
httpClient = client ?? http.Client(),
super(hostChanged: hostChanged);
// ...
}
测试替身策略对比
| 策略 | 适用场景 | 实现复杂度 | 测试真实性 |
|---|---|---|---|
| 模拟(Mock) | 外部服务交互 | 中 | 低 |
| 存根(Stub) | 简单数据返回 | 低 | 低 |
| 假实现(Fake) | 复杂业务逻辑 | 高 | 中 |
| 集成测试 | 端到端验证 | 高 | 高 |
测试最佳实践与避坑指南
最佳实践清单
- 单一职责原则:每个测试只验证一个行为
- AAA模式:Arrange(准备)、Act(执行)、Assert(断言)
- 测试隔离:不同测试间不共享状态
- 模拟最小化:只模拟必要的外部依赖
- 有意义的断言:不只验证返回值,还要验证副作用
常见问题与解决方案
| 问题 | 解决方案 | 示例 |
|---|---|---|
| 测试不稳定 | 确保测试间完全隔离 | 使用setUp/tearDown重置状态 |
| 模拟过于复杂 | 引入测试替身层级 | 创建专用的测试工具类 |
| 测试执行缓慢 | 并行执行测试,减少IO操作 | 使用flutter test --concurrency=4 |
| 难以调试 | 详细日志和明确的错误信息 | 使用print或调试器断点 |
总结与下一步
通过Mockito框架,我们可以为Obtainium项目编写可靠的单元测试,覆盖各种场景:
- 模拟HTTP请求测试第三方API交互
- 模拟文件系统操作验证下载逻辑
- 模拟Android系统服务测试安装流程
后续学习路径
- 测试驱动开发(TDD):尝试为新功能先编写测试
- 行为驱动开发(BDD):使用
cucumber等工具定义业务规则 - 持续集成:配置GitHub Actions自动运行测试
- 高级模拟技术:探索
mocktail等更现代的模拟库
行动号召
- 为你负责的Obtainium模块添加单元测试,目标覆盖率>70%
- 在测试中应用本文介绍的依赖注入模式
- 分享你的测试经验,帮助完善项目测试策略
下一篇预告:《Obtainium集成测试实战:从UI到API的端到端验证》
关于作者:Obtainium核心贡献者,专注于移动应用测试与质量保障。欢迎在项目仓库提交issue或PR讨论测试相关问题。
项目地址:https://gitcode.com/GitHub_Trending/ob/Obtainium
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



