如何测试内部含API调用的函数并保证单元测试覆盖率
正在对内部调用外部API的fetchTelehealthDetail方法编写单元测试,对应业务类代码如下:
class TelehealthController extends GetxController { final RxBool loadingDetails = true.obs; TelehealthController(); TelehealthsClient physician = Get.put(TelehealthsClient(Dio())); late TelehealthResponse details; late List<TelehealthData>? datadetails = details.data; int? get id => details.data!.first.physicianId; String get telehealth => details.data!.first.defaulttelehealthinstructions.toString(); @override void onInit() async { super.onInit(); //Here each API call needs to Initialize await fetchTelehealthDetail(); } Future<TelehealthResponse> fetchTelehealthDetail() async { loadingDetails.value = true; final response = await physician.getTelehealthData(); //mock loadingDetails.value = false; details = TelehealthResponse(data: response); int count = details.data!.length; loadingDetails.value = false; return details; } }
按照单元测试规范,所有外部API、数据库调用都需要Mock,避免触发真实网络请求。当前遇到的核心问题是:调用controller实例的fetchTelehealthDetail方法时总会触发真实API请求,无法正确Mockphysician.getTelehealthData()调用。
之前尝试直接Mock整个TelehealthController类,为fetchTelehealthDetail返回模拟响应,但这种方式下真实业务代码不会执行,flutter test --coverage生成的覆盖率报告仅为4%,也无法验证id、telehealth等getter的逻辑正确性。
原有测试Mock代码如下:
class MockTelehealthClients extends Mock implements TelehealthsClient {} class MockTelehealthController extends Mock implements TelehealthController {} void main() async { TestWidgetsFlutterBinding.ensureInitialized(); List<TelehealthData> _listofMockTelehealthData = [ TelehealthData( physicianId: 12345, defaulttelehealthinstructions: "At the time of your appointment, click this link to join your video visit: https://zoom.us/j/a-random-zoom-link"), TelehealthData( physicianId: 890, defaulttelehealthinstructions: "At the time of your appointment, click this link to join your video visit: https://zoom.us/another-zoom-link"), ]; var _telehealthResponse = TelehealthResponse(data: _listofMockTelehealthData); var _mockTelehealthClient = MockTelehealthClients(); var sut = TelehealthController(); var _mockTelehealthController = MockTelehealthController(); //mocking using Mocktail when(() => _mockTelehealthClient.getTelehealthData()).thenAnswer( (invocation) => Future.delayed(Duration(seconds: 1), () async { return _listofMockTelehealthData; })); when((() => _mockTelehealthController.fetchTelehealthDetail())) .thenAnswer((invocation) { return Future.delayed(Duration(seconds: 1), () { return _telehealthResponse; }); }); }
需要可行的解决方案,在正确Mock外部API调用的同时保证代码覆盖率统计准确。
原有测试代码存在两个核心错误:
- 实例化真实
TelehealthController时,构造函数内部直接通过Get.put注入了真实的TelehealthsClient实例,没有替换成Mock对象,后续所有对Mock客户端的Stub都不会作用到真实Controller持有的客户端实例上 - 额外Mock整个
TelehealthController的操作完全多余,Mock后的Controller不会执行真实业务逻辑,自然无法统计覆盖率、验证内部getter逻辑 - 原业务代码存在时序隐患:
late List<TelehealthData>? datadetails = details.data;在Controller初始化时就会访问还未赋值的late变量details,运行时会直接抛出异常
第一步:改造Controller支持依赖注入
不要在Controller内部硬编码初始化依赖,改为通过构造函数传入依赖,方便测试时替换Mock对象,同时修复原有变量初始化的时序问题,改造后Controller代码如下:
class TelehealthController extends GetxController { final RxBool loadingDetails = true.obs; // 依赖通过构造函数注入,不再内部硬编码初始化 final TelehealthsClient physician; TelehealthController({required this.physician}); late TelehealthResponse details; late List<TelehealthData>? datadetails; int? get id => details.data!.first.physicianId; String get telehealth => details.data!.first.defaulttelehealthinstructions.toString(); @override void onInit() async { super.onInit(); datadetails = []; await fetchTelehealthDetail(); } Future<TelehealthResponse> fetchTelehealthDetail() async { loadingDetails.value = true; final response = await physician.getTelehealthData(); loadingDetails.value = false; details = TelehealthResponse(data: response); datadetails = details.data; int count = details.data!.length; loadingDetails.value = false; return details; } }
生产环境初始化Controller时,传入真实的客户端实例即可:Get.put(TelehealthController(physician: TelehealthsClient(Dio())))
第二步:编写正确的测试代码
不需要Mock被测的Controller本身,只需要Mock外部依赖TelehealthsClient,在初始化Controller时传入Mock客户端实例,再对Mock客户端的方法做Stub即可,测试代码示例:
import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:get/get.dart'; // 只Mock外部依赖,不Mock被测类 class MockTelehealthClients extends Mock implements TelehealthsClient {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); late MockTelehealthClients mockTelehealthClient; late TelehealthController sut; // 每个测试用例执行前重置环境 setUp(() { Get.reset(); // 重置GetX容器,避免前序测试的实例残留 mockTelehealthClient = MockTelehealthClients(); // 初始化被测Controller时,传入Mock的客户端实例 sut = TelehealthController(physician: mockTelehealthClient); Get.put<TelehealthController>(sut); }); test('fetchTelehealthDetail should call api, update state and getters correctly', () async { // 准备Mock返回数据 final List<TelehealthData> mockTelehealthData = [ TelehealthData( physicianId: 12345, defaulttelehealthinstructions: "At the time of your appointment, click this link to join your video visit: https://zoom.us/j/a-random-zoom-link"), TelehealthData( physicianId: 890, defaulttelehealthinstructions: "At the time of your appointment, click this link to join your video visit: https://zoom.us/another-zoom-link"), ]; // Stub Mock客户端的方法,单元测试不需要加多余延迟模拟网络等待 when(() => mockTelehealthClient.getTelehealthData()) .thenAnswer((_) async => mockTelehealthData); // 初始状态校验 expect(sut.loadingDetails.value, true); // 执行被测方法 final result = await sut.fetchTelehealthDetail(); // 校验外部API仅被调用1次 verify(() => mockTelehealthClient.getTelehealthData()).called(1); // 校验loading状态正确变更 expect(sut.loadingDetails.value, false); // 校验返回结果正确 expect(result.data, mockTelehealthData); expect(sut.details.data, mockTelehealthData); expect(sut.datadetails, mockTelehealthData); // 校验getter逻辑正确 expect(sut.id, 12345); expect(sut.telehealth, "At the time of your appointment, click this link to join your video visit: https://zoom.us/j/a-random-zoom-link"); }); }
关键注意点
- 单元测试永远不要Mock被测类本身,只Mock被测类的外部依赖,否则真实代码不会执行,覆盖率和逻辑验证都会失效
- 依赖注入是可测试性的核心,不要在类内部硬编码初始化外部依赖,通过构造函数传入依赖可以方便测试时无缝替换Mock对象
- 测试用例执行前后要重置GetX的依赖容器,避免不同测试用例之间的实例污染
- 单元测试中的Mock方法返回不需要加多余的延迟,否则会拖慢测试执行速度
内容的提问来源于stack exchange,提问作者Sanjay Sanjel

