23、单元测试:Mock BiometricPrompt、测试认证回调、测试错误处理

单元测试这件事,说实话,很多做生物识别的同学容易忽略。我早期做虹膜项目时也踩过这个坑——代码写完了,真机一跑,认证回调死活不触发,排查了半天才发现是 mock 没写好。嗯,今天我们就来把这部分彻底讲清楚。

为什么要 Mock BiometricPrompt?

BiometricPrompt 是 Android 系统提供的类,它依赖硬件和系统服务。在单元测试环境里,你不可能真的去扫描虹膜。所以我们需要 mock 掉它,模拟出「认证成功」「认证失败」「发生错误」这三种场景。

说白了,我们要让测试代码以为自己在跟真正的 BiometricPrompt 交互,但实际上我们在控制它的行为。

核心原则: 只测试你自己的逻辑,不测试系统 API。

搭建测试环境

首先,在 build.gradle 里加上必要的依赖。我个人习惯用 Mockito 配合 AndroidX Test:

testImplementation 'org.mockito:mockito-core:4.11.0'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.biometric:biometric:1.2.0-alpha05'

注意,biometric 库本身也要加进来,因为我们需要引用 BiometricPrompt 的类型定义。

Mock BiometricPrompt 实例

这里有个关键点:BiometricPrompt 的构造函数需要 Activity 和 Executor。我们不能直接 new 一个出来,得用 Mockito 去 mock:

@Mock
private BiometricPrompt mockBiometricPrompt;

@Mock
private BiometricPrompt.AuthenticationCallback mockCallback;

@Before
public void setUp() {
    MockitoAnnotations.openMocks(this);
    // 模拟认证回调的各个方法
    doAnswer(invocation -> {
        BiometricPrompt.AuthenticationResult result = invocation.getArgument(0);
        // 这里可以自定义 result 的内容
        return null;
    }).when(mockCallback).onAuthenticationSucceeded(any());
}

我曾经在项目里犯过一个低级错误——忘记调用 MockitoAnnotations.openMocks(this),结果跑测试时一直报 NullPointerException。排查了半小时才发现是注解没生效。你想想看,这种低级错误多耽误时间。

测试认证成功回调

当用户虹膜匹配成功时,系统会调用 onAuthenticationSucceeded。我们要验证自己的业务逻辑是否正确响应:

@Test
public void testAuthenticationSuccess() {
    // 模拟认证成功
    BiometricPrompt.AuthenticationResult result = mock(BiometricPrompt.AuthenticationResult.class);
    mockCallback.onAuthenticationSucceeded(result);

    // 验证我们的 ViewModel 或 Presenter 收到了成功事件
    verify(viewModel).onBiometricSuccess();
    // 验证 UI 状态更新
    assertEquals(ViewState.SUCCESS, uiState.getValue());
}

这里要注意,AuthenticationResult 里的 CryptoObject 可能为 null。如果你的业务逻辑依赖它,记得在 mock 时设置好。

测试认证失败回调

认证失败对应 onAuthenticationFailed。这个回调没有参数,但我们需要确保 UI 给出正确提示:

@Test
public void testAuthenticationFailed() {
    mockCallback.onAuthenticationFailed();

    verify(viewModel).onBiometricFailed();
    // 检查错误提示是否展示
    assertTrue(errorMessage.isVisible());
    assertEquals("虹膜识别失败,请重试", errorMessage.getText());
}

嗯,这里有个小细节:onAuthenticationFailedonAuthenticationError 是两回事。前者只是匹配不成功,用户可以立即重试;后者是系统出问题了,需要等待一段时间。我在项目中见过不少同学把这两个混为一谈,导致用户体验很差。

测试错误处理

错误处理是最容易出 bug 的地方。系统会通过 onAuthenticationError 返回错误码和错误信息。常见的错误码有:

错误码 含义 处理方式
BIOMETRIC_ERROR_HW_UNAVAILABLE 硬件不可用 提示用户稍后再试
BIOMETRIC_ERROR_NO_BIOMETRICS 未录入虹膜 引导用户去设置
BIOMETRIC_ERROR_LOCKOUT 尝试次数过多被锁定 等待一段时间或使用密码
BIOMETRIC_ERROR_USER_CANCELED 用户主动取消 不做特殊处理

测试代码可以这样写:

@Test
public void testHardwareUnavailableError() {
    int errorCode = BiometricPrompt.BIOMETRIC_ERROR_HW_UNAVAILABLE;
    String errorMsg = "虹膜硬件暂时不可用";

    mockCallback.onAuthenticationError(errorCode, errorMsg);

    verify(viewModel).onBiometricError(errorCode, errorMsg);
    // 验证弹窗提示
    assertEquals("硬件不可用,请稍后重试", dialogMessage.getValue());
}

@Test
public void testLockoutError() {
    int errorCode = BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT;
    String errorMsg = "尝试次数过多";

    mockCallback.onAuthenticationError(errorCode, errorMsg);

    verify(viewModel).onBiometricError(errorCode, errorMsg);
    // 验证是否显示倒计时或密码选项
    assertTrue(showPasswordOption.isVisible());
}
避坑指南: 我曾经在测试 lockout 场景时,忘记 mock 掉系统的锁定时间。结果测试跑完后,真机调试时发现设备真的被锁了 30 秒。所以,测试环境一定要和真机环境隔离干净。

用 SVG 梳理测试流程

下面这张图展示了单元测试的核心逻辑:

BiometricPrompt 单元测试流程 单元测试入口 Mock BiometricPrompt + Callback 认证成功 onAuthenticationSucceeded 认证失败 onAuthenticationFailed 错误处理 onAuthenticationError 验证 UI 状态更新 验证错误提示展示 验证错误码处理

一些实用技巧

  • 使用 ArgumentCaptor: 当回调参数比较复杂时,用 ArgumentCaptor 捕获参数再断言,比直接 verify 更灵活。
  • 测试边界情况: 比如错误码为 0 的情况、错误信息为 null 的情况。我遇到过厂商返回空字符串的错误信息,导致 app 崩溃。
  • 不要 mock 所有东西: 你的业务逻辑类(比如 ViewModel)应该用真实实例,只 mock 系统 API 和外部依赖。
个人建议: 写测试时,先写「认证成功」这个 happy path,再写「认证失败」,最后写各种错误码。这样循序渐进,不容易遗漏。

单元测试写好了,后续的回归测试会轻松很多。每次修改代码后跑一遍测试,就能快速知道有没有破坏已有的认证逻辑。嗯,这部分内容就到这里,希望对你有所帮助。


公众号:蓝海资料掘金营,微信deep3321