19、单元测试与UI测试:JUnit测试BiometricPrompt、Espresso测试认证流程、Mockito模拟BiometricManager

说实话,很多开发者写完生物识别代码就觉得完事了。但我在项目里吃过亏——有一次上线前才发现,某款低端机型上BiometricPrompt压根没回调。从那以后,我养成了一个习惯:测试不写全,代码不上线

这一章,我们就来聊聊怎么给生物识别模块写测试。我会从三个角度切入:单元测试UI测试模拟依赖。说白了,就是让你在没真机、没指纹、没人脸的情况下,也能把代码测个底朝天。

为什么需要测试生物识别代码?

你想想看,生物识别涉及硬件、系统服务、用户交互。任何一个环节出问题,用户都会直接感知到——弹窗不出现、认证失败没提示、甚至直接闪退。我见过一个案例:某App在Android 11上人脸识别正常,到了Android 12就因为BiometricPrompt的构造函数变了,直接崩溃。这种问题,靠手工测试很难覆盖全。

所以,我们需要三套测试方案:

  • JUnit:测试BiometricPrompt的回调逻辑
  • Mockito:模拟BiometricManager,绕过真实硬件
  • Espresso:模拟用户点击、验证UI状态变化

核心思路:把生物识别的「硬件依赖」和「业务逻辑」解耦。硬件部分用Mockito模拟,业务逻辑用JUnit验证,UI交互用Espresso覆盖。

生物识别测试三层架构 JUnit 单元测试 测试回调逻辑、状态机 Mockito 模拟层 模拟BiometricManager Espresso UI测试 验证弹窗、按钮状态 测试流程: 1. Mockito 模拟 BiometricManager.canAuthenticate() 返回值 2. JUnit 验证 BiometricPrompt 回调(onSuccess / onError) 3. Espresso 模拟用户点击「开始认证」按钮 4. 验证 UI 显示「认证成功」或「认证失败」 注:三层可独立运行,也可组合成集成测试

1. 用Mockito模拟BiometricManager

先解决最头疼的问题:没有真机怎么测?BiometricManager需要跟硬件通信,单元测试里根本拿不到。我的做法是——用Mockito把它整个替换掉。

// 模拟BiometricManager
@RunWith(MockitoJUnitRunner.class)
public class BiometricManagerTest {

    @Mock
    private BiometricManager mockManager;

    @Test
    public void testCanAuthenticate_whenHardwareAvailable() {
        // 模拟硬件可用
        when(mockManager.canAuthenticate()).thenReturn(
            BiometricManager.BIOMETRIC_SUCCESS
        );

        int result = mockManager.canAuthenticate();
        assertEquals(BiometricManager.BIOMETRIC_SUCCESS, result);
    }

    @Test
    public void testCanAuthenticate_whenNoHardware() {
        // 模拟无硬件
        when(mockManager.canAuthenticate()).thenReturn(
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
        );

        int result = mockManager.canAuthenticate();
        assertEquals(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, result);
    }
}

我的习惯:我会把BiometricManager封装成一个接口,比如叫BiometricChecker。这样Mockito只需要模拟接口,不用跟Android框架类硬刚。你想想看,如果直接模拟BiometricManager,万一Google改了构造函数,你的测试代码也得跟着改——划不来。

2. JUnit测试BiometricPrompt回调

BiometricPrompt的回调是异步的,但JUnit是同步的。怎么测?用CountDownLatch。这是我踩过坑之后才学会的——一开始我直接调callback,结果发现onError和onSuccess永远不同时触发,测试结果时好时坏。

public class BiometricCallbackTest {

    private BiometricPrompt.AuthenticationCallback callback;
    private CountDownLatch latch;

    @Before
    public void setUp() {
        latch = new CountDownLatch(1);
        callback = new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationSucceeded(
                BiometricPrompt.AuthenticationResult result) {
                // 标记成功
                latch.countDown();
            }

            @Override
            public void onAuthenticationError(int errorCode, 
                CharSequence errString) {
                // 标记失败
                latch.countDown();
            }
        };
    }

    @Test
    public void testOnSuccess_calledOnce() throws Exception {
        // 模拟认证成功
        callback.onAuthenticationSucceeded(
            new BiometricPrompt.AuthenticationResult(null, 0)
        );

        // 等待回调执行
        boolean called = latch.await(2, TimeUnit.SECONDS);
        assertTrue("回调未触发", called);
    }

    @Test
    public void testOnError_calledWithCorrectCode() throws Exception {
        callback.onAuthenticationError(
            BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT,
            "Too many attempts"
        );

        boolean called = latch.await(2, TimeUnit.SECONDS);
        assertTrue(called);
    }
}

我曾经踩过的坑:CountDownLatch的等待时间设得太短。有一次我设了500毫秒,结果在低端机型上回调慢了100毫秒,测试就挂了。后来我统一改成2秒,再也没出过问题。记住:测试环境比真机慢,给足余量

3. Espresso测试认证流程

UI测试要模拟的是:用户点击按钮 → 弹出生物识别对话框 → 认证成功/失败 → UI更新。Espresso配合ActivityTestRule,可以做到这一点。

@RunWith(AndroidJUnit4.class)
public class BiometricUiTest {

    @Rule
    public ActivityTestRule<MainActivity> activityRule =
        new ActivityTestRule<>(MainActivity.class);

    @Test
    public void testAuthenticateButton_showsSuccess() {
        // 点击「开始认证」按钮
        onView(withId(R.id.btn_authenticate))
            .perform(click());

        // 模拟系统级生物识别弹窗
        // 注意:这里需要依赖Espresso的system alert窗口支持
        onView(withText("认证成功"))
            .check(matches(isDisplayed()));
    }

    @Test
    public void testAuthenticateButton_showsError() {
        onView(withId(R.id.btn_authenticate))
            .perform(click());

        // 模拟认证失败
        onView(withText("认证失败"))
            .check(matches(isDisplayed()));
    }
}

关键点:Espresso测试生物识别时,最大的难点是系统弹窗。BiometricPrompt弹出的对话框是系统级的,Espresso默认只能测App内的View。我的解决方案是:在测试环境下,用一个自定义的DialogFragment代替系统弹窗,这样Espresso就能直接定位到UI元素了。

4. 组合测试:Mockito + JUnit + Espresso

实际项目中,我经常把三者组合起来用。比如这样:

@RunWith(AndroidJUnit4.class)
public class BiometricIntegrationTest {

    @Mock
    private BiometricManager mockManager;

    @Rule
    public MockitoRule mockitoRule = MockitoJUnit.rule();

    @Rule
    public ActivityTestRule<MainActivity> activityRule =
        new ActivityTestRule<>(MainActivity.class, true, false);

    @Test
    public void testFullFlow_hardwareAvailable_authSuccess() {
        // 1. 模拟硬件可用
        when(mockManager.canAuthenticate())
            .thenReturn(BiometricManager.BIOMETRIC_SUCCESS);

        // 2. 启动Activity
        activityRule.launchActivity(new Intent());

        // 3. 点击认证按钮
        onView(withId(R.id.btn_authenticate)).perform(click());

        // 4. 验证UI状态
        onView(withId(R.id.tv_status))
            .check(matches(withText("请进行指纹或人脸认证")));
    }
}

我的建议:别一上来就写集成测试。先写Mockito的单元测试,把BiometricManager的各种返回值都覆盖到。然后再写JUnit的回调测试。最后才用Espresso做UI验证。这样出了问题,你能快速定位是硬件模拟层、回调逻辑层、还是UI层的问题。

5. 测试覆盖率清单

我整理了一个清单,每次写生物识别测试时都会对照检查:

测试场景 测试类型 关键验证点
硬件可用 Mockito canAuthenticate() 返回 BIOMETRIC_SUCCESS
无硬件 Mockito 返回 BIOMETRIC_ERROR_NO_HARDWARE
认证成功 JUnit onAuthenticationSucceeded 被调用
认证失败 JUnit onAuthenticationError 被调用,errorCode 正确
用户取消 JUnit errorCode = BIOMETRIC_ERROR_USER_CANCELED
多次锁定 JUnit errorCode = BIOMETRIC_ERROR_LOCKOUT
UI 显示成功 Espresso TextView 显示「认证成功」
UI 显示失败 Espresso TextView 显示「认证失败」
按钮状态 Espresso 认证中按钮不可点击

嗯,这张表基本覆盖了我在生产环境中遇到过的所有情况。你写测试的时候,可以照着这个清单一个一个过。

写在最后

测试生物识别代码,说白了就是把不确定性变成确定性。硬件不可控?用Mockito模拟。回调异步?用CountDownLatch同步。UI难定位?用自定义DialogFragment替代系统弹窗。

我个人习惯是:每个新功能上线前,至少跑一遍这个清单里的所有测试。虽然写测试花时间,但比起用户反馈「人脸识别用不了」然后紧急发版,这点时间花得值。


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