You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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。

修复步骤

  1. 移除对控制器方法的Stub
    控制器是你要测试的对象,不能Stub它的方法,否则会跳过真实逻辑。删除这行代码:

    when(() => controller.submitPhoneNumber(phoneNumber, (e) {}, () {})).thenAnswer((_) => Future.value(true));
    
  2. 正确Stub依赖的仓库方法
    应该StubauthRepository.registerWithPhoneNumber,让它返回成功结果,这样控制器的逻辑才能正常执行:

    when(() => authRepository.registerWithPhoneNumber(any(), any(), any()))
        .thenAnswer((_) => Future.value(null)); // 根据你的仓库方法返回值调整,这里假设成功时返回null
    
  3. 解决闭包参数匹配问题
    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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 01:20:35