使用Mocktail进行单元测试时函数Stub失败问题排查
使用mocktail为单元测试Mock仓库时,多个控制器测试均通过,但测试submitPhoneNumber方法时遇到错误。原本Stub了controller.submitPhoneNumber使其返回Future.value(true),期望authRepository.registerWithPhoneNumber被调用1次,且debugState.value.hasError为false,但出现如下错误:
No matching calls. All calls: MockFirebaseAuthRepository.registerWithPhoneNumber(+15551234567, Closure: (String) => void, Closure: () => void) (If you called `verify(...).called(0);`, please instead use `verifyNever(...);`.) package:test_api fail package:mocktail/src/mocktail.dart 722:7 _VerifyCall._checkWith package:mocktail/src/mocktail.dart 515:18 _makeVerify.<fn> test/src/features/authentication/presentation/sign_in/phone_sign_in/sign_in_controller_test.dart 41:13 main.<fn>.<fn>
实际情况是controller.submitPhoneNumber未按预期触发对应调用,且debugState.value.hasError为true,但该控制器在模拟器和真机上运行正常,怀疑是函数Stub方式有误,附上测试代码与控制器代码:
测试代码
void main() { group('controller submit phone number test', () { final controller = SignInController( formType: SignInFormType.register, authRepository: authRepository); test('submit phonumber success', () async { const phoneNumber = '+15551234567'; ///this is where the error is comming from when(() => controller.submitPhoneNumber(phoneNumber, (e) {}, () {})) .thenAnswer((_) => Future.value(true)); await controller.submitPhoneNumber(phoneNumber, (e) {}, () {}); verify(() => authRepository.registerWithPhoneNumber( phoneNumber, (e) {}, () {})).called(1); expect(controller.debugState.value.hasError, false); expect(controller.debugState.formType, SignInFormType.register); expect(controller.debugState.value, isA<AsyncValue>()); }, timeout: const Timeout(Duration(milliseconds: 500))); }
控制器代码
class SignInController extends StateNotifier<SignInState> { SignInController({ required SignInFormType formType, required this.authRepository, }) : super(SignInState(formType: formType)); final AuthRepository authRepository; Future<bool> submitPhoneNumber(String phoneNumber, void Function(String e) verificationFailed, VoidCallback codeSent) async { state = state.copyWith(value: const AsyncValue.loading()); final value = await AsyncValue.guard(() => authRepository.registerWithPhoneNumber( phoneNumber, verificationFailed, codeSent, )); state = state.copyWith(value: value); return value.hasError == false; } } final signInControllerProvider = StateNotifierProvider.autoDispose .family<SignInController, SignInState, SignInFormType>((ref, formType) { final authRepository = ref.watch(authRepositoryProvider); return SignInController( authRepository: authRepository, formType: formType, ); });
核心问题
你错误地Stub了控制器自身的submitPhoneNumber方法,这会直接跳过控制器内部的逻辑(包括调用authRepository.registerWithPhoneNumber的代码),导致verify时找不到对应调用,同时控制器的state也不会按预期更新,所以hasError为true。
修复步骤
移除对控制器方法的Stub
控制器是你要测试的对象,不能Stub它的方法,否则会跳过真实逻辑。删除这行代码:when(() => controller.submitPhoneNumber(phoneNumber, (e) {}, () {})).thenAnswer((_) => Future.value(true));正确Stub依赖的仓库方法
应该StubauthRepository.registerWithPhoneNumber,让它返回成功结果,这样控制器的逻辑才能正常执行:when(() => authRepository.registerWithPhoneNumber(any(), any(), any())) .thenAnswer((_) => Future.value(null)); // 根据你的仓库方法返回值调整,这里假设成功时返回null解决闭包参数匹配问题
mocktail默认严格匹配所有参数,匿名闭包每次创建都是不同实例,直接用(e) {}和() {}会导致verify失败。需要用any()来匹配闭包参数:verify(() => authRepository.registerWithPhoneNumber(phoneNumber, any(), any())).called(1);
修改后的完整测试代码
void main() { group('controller submit phone number test', () { // 注意:这里的authRepository应该是你创建的Mock实例,比如MockFirebaseAuthRepository() final authRepository = MockFirebaseAuthRepository(); final controller = SignInController( formType: SignInFormType.register, authRepository: authRepository); test('submit phonumber success', () async { const phoneNumber = '+15551234567'; // Stub仓库的方法,模拟成功场景 when(() => authRepository.registerWithPhoneNumber(any(), any(), any())) .thenAnswer((_) => Future.value(null)); await controller.submitPhoneNumber(phoneNumber, (e) {}, () {}); // 使用any()匹配闭包参数 verify(() => authRepository.registerWithPhoneNumber(phoneNumber, any(), any())).called(1); expect(controller.debugState.value.hasError, false); expect(controller.debugState.formType, SignInFormType.register); expect(controller.debugState.value, isA<AsyncValue>()); }, timeout: const Timeout(Duration(milliseconds: 500))); }); }
补充说明
真机/模拟器运行正常是因为此时控制器会真实调用仓库的实现,而测试中你Stub了控制器方法,跳过了内部逻辑,导致测试与真实行为脱节。测试的核心是验证控制器的逻辑是否正确调用依赖,所以必须让控制器执行完整逻辑,只Mock它的依赖。
内容的提问来源于stack exchange,提问作者john

