Testing Biometric SDKs on Mobile: A Practical Guide

Testing Biometric SDKs on Mobile: A Practical Guide

Biometric authentication is now table stakes for mobile apps. Fingerprint unlock, Face ID, iris scanning — users expect it, security teams demand it, and product managers ship it. But testing it is a different matter entirely. The OS owns the biometric dialog, mocking hardware sensors in CI is non-trivial, and a flaky biometric test is worse than no test at all.

This guide covers practical strategies for testing biometric SDKs on Android and iOS — from unit-level mocking through UI automation to running reliably in CI pipelines.

Why Biometric Testing Is Hard

Before the strategies, it helps to understand the constraints:

  • The OS owns the dialog. Neither Espresso nor XCTest can interact with system-owned alert sheets the same way they interact with app UI. The biometric prompt renders outside your app's process.
  • Hardware is absent in CI. Emulators and simulators have no fingerprint sensor or Face ID camera. You must trigger biometric events programmatically.
  • State is stateful. Lockout after five failed attempts is real behavior that must be tested, but recovering from lockout requires device credential fallback — another system-owned flow.
  • SDKs wrap OS APIs. If you're shipping a biometric SDK yourself, you need to test the SDK contract, not just the happy path from the app layer.

Let's work through each platform.

Android: BiometricPrompt with Espresso

The Testing Surface

Android's BiometricPrompt API (androidx.biometric) centralizes fingerprint, face, and device credential auth. Your app code typically looks like this:

class BiometricAuthManager(private val activity: FragmentActivity) {

    private val executor = ContextCompat.getMainExecutor(activity)

    fun authenticate(
        onSuccess: () -> Unit,
        onError: (Int, CharSequence) -> Unit,
        onFailed: () -> Unit
    ) {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Verify your identity")
            .setSubtitle("Use biometrics to continue")
            .setNegativeButtonText("Cancel")
            .build()

        val biometricPrompt = BiometricPrompt(
            activity,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    onSuccess()
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errorCode, errString)
                }

                override fun onAuthenticationFailed() {
                    onFailed()
                }
            }
        )

        biometricPrompt.authenticate(promptInfo)
    }
}

Abstracting Behind an Interface

The key to testable biometric code is the same as testable anything: depend on an abstraction, not the concrete API.

interface BiometricAuthenticator {
    fun authenticate(
        onSuccess: () -> Unit,
        onError: (Int, CharSequence) -> Unit,
        onFailed: () -> Unit
    )
}

class RealBiometricAuthenticator(activity: FragmentActivity) : BiometricAuthenticator {
    private val manager = BiometricAuthManager(activity)

    override fun authenticate(
        onSuccess: () -> Unit,
        onError: (Int, CharSequence) -> Unit,
        onFailed: () -> Unit
    ) = manager.authenticate(onSuccess, onError, onFailed)
}

class FakeBiometricAuthenticator(
    private val result: BiometricResult
) : BiometricAuthenticator {

    enum class BiometricResult { SUCCESS, FAILURE, LOCKOUT, HARDWARE_UNAVAILABLE }

    override fun authenticate(
        onSuccess: () -> Unit,
        onError: (Int, CharSequence) -> Unit,
        onFailed: () -> Unit
    ) {
        when (result) {
            BiometricResult.SUCCESS -> onSuccess()
            BiometricResult.FAILURE -> onFailed()
            BiometricResult.LOCKOUT -> onError(
                BiometricPrompt.ERROR_LOCKOUT,
                "Too many attempts. Try again later."
            )
            BiometricResult.HARDWARE_UNAVAILABLE -> onError(
                BiometricPrompt.ERROR_HW_UNAVAILABLE,
                "Biometric hardware not available."
            )
        }
    }
}

Unit Tests with the Fake

class LoginViewModelTest {

    @Test
    fun `successful biometric auth transitions to authenticated state`() {
        val authenticator = FakeBiometricAuthenticator(
            FakeBiometricAuthenticator.BiometricResult.SUCCESS
        )
        val viewModel = LoginViewModel(authenticator)

        viewModel.triggerBiometricAuth()

        assertEquals(AuthState.Authenticated, viewModel.authState.value)
    }

    @Test
    fun `lockout error shows fallback prompt`() {
        val authenticator = FakeBiometricAuthenticator(
            FakeBiometricAuthenticator.BiometricResult.LOCKOUT
        )
        val viewModel = LoginViewModel(authenticator)

        viewModel.triggerBiometricAuth()

        assertEquals(AuthState.FallbackRequired, viewModel.authState.value)
        assertTrue(viewModel.showFallbackPrompt.value)
    }

    @Test
    fun `hardware unavailable shows informational error`() {
        val authenticator = FakeBiometricAuthenticator(
            FakeBiometricAuthenticator.BiometricResult.HARDWARE_UNAVAILABLE
        )
        val viewModel = LoginViewModel(authenticator)

        viewModel.triggerBiometricAuth()

        val state = viewModel.authState.value as AuthState.Error
        assertEquals(BiometricPrompt.ERROR_HW_UNAVAILABLE, state.code)
    }
}

Espresso Integration Tests on Emulator

For emulator-based integration tests, use the adb command to simulate fingerprint input. The Android emulator accepts fingerprint events via the extended control panel — and via ADB:

@RunWith(AndroidJUnit4::class)
class BiometricIntegrationTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun biometricSuccess_navigatesToHome() {
        // Tap the biometric button
        onView(withId(R.id.btn_biometric_login)).perform(click())

        // Emulator: simulate accepted fingerprint (finger ID 1)
        simulateFingerprint(accepted = true)

        // Assert navigation
        onView(withId(R.id.home_screen)).check(matches(isDisplayed()))
    }

    @Test
    fun biometricFailure_showsRetryMessage() {
        onView(withId(R.id.btn_biometric_login)).perform(click())
        simulateFingerprint(accepted = false)

        onView(withText("Fingerprint not recognized")).check(matches(isDisplayed()))
    }

    private fun simulateFingerprint(accepted: Boolean) {
        val fingerId = if (accepted) 1 else -1
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        // Telnet to emulator console to send fingerprint event
        Runtime.getRuntime().exec(
            arrayOf("adb", "emu", "finger", "touch", fingerId.toString())
        ).waitFor()
        Thread.sleep(500) // Allow BiometricPrompt callback to fire
    }
}

Note: adb emu finger touch <id> works on Android emulators with the fingerprint sensor enabled. You must first enroll a fingerprint in emulator settings (or do so programmatically in your CI setup script).

iOS: LocalAuthentication with XCTest

The Testing Surface

iOS biometrics go through LocalAuthentication.LAContext. A typical implementation:

import LocalAuthentication

class BiometricService {

    var context: LAContextProtocol = LAContext()

    func authenticate(completion: @escaping (Result<Void, BiometricError>) -> Void) {
        var error: NSError?

        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            let biometricError = mapError(error)
            completion(.failure(biometricError))
            return
        }

        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Verify your identity"
        ) { success, authError in
            DispatchQueue.main.async {
                if success {
                    completion(.success(()))
                } else {
                    completion(.failure(self.mapError(authError as NSError?)))
                }
            }
        }
    }

    private func mapError(_ error: NSError?) -> BiometricError {
        switch error?.code {
        case LAError.biometryLockout.rawValue: return .lockout
        case LAError.biometryNotEnrolled.rawValue: return .notEnrolled
        case LAError.biometryNotAvailable.rawValue: return .notAvailable
        case LAError.userCancel.rawValue: return .userCancelled
        default: return .unknown
        }
    }
}

enum BiometricError: Error, Equatable {
    case lockout, notEnrolled, notAvailable, userCancelled, unknown
}

Protocol-Based Mocking

protocol LAContextProtocol {
    func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
    func evaluatePolicy(
        _ policy: LAPolicy,
        localizedReason: String,
        reply: @escaping (Bool, Error?) -> Void
    )
}

extension LAContext: LAContextProtocol {}

class MockLAContext: LAContextProtocol {

    enum Scenario {
        case success
        case failure(LAError.Code)
        case cannotEvaluate(LAError.Code)
    }

    var scenario: Scenario

    init(scenario: Scenario) {
        self.scenario = scenario
    }

    func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
        if case .cannotEvaluate(let code) = scenario {
            error?.pointee = NSError(
                domain: LAErrorDomain,
                code: code.rawValue
            )
            return false
        }
        return true
    }

    func evaluatePolicy(
        _ policy: LAPolicy,
        localizedReason: String,
        reply: @escaping (Bool, Error?) -> Void
    ) {
        switch scenario {
        case .success:
            reply(true, nil)
        case .failure(let code):
            reply(false, NSError(domain: LAErrorDomain, code: code.rawValue))
        case .cannotEvaluate:
            reply(false, NSError(domain: LAErrorDomain, code: LAError.biometryNotAvailable.rawValue))
        }
    }
}

XCTest Unit Tests

import XCTest
@testable import YourApp

class BiometricServiceTests: XCTestCase {

    func testSuccessfulAuthentication() {
        let service = BiometricService()
        service.context = MockLAContext(scenario: .success)

        let expectation = expectation(description: "auth completes")
        service.authenticate { result in
            XCTAssertEqual(result, .success(()))
            expectation.fulfill()
        }

        waitForExpectations(timeout: 1)
    }

    func testLockoutReturnsCorrectError() {
        let service = BiometricService()
        service.context = MockLAContext(scenario: .failure(.biometryLockout))

        let expectation = expectation(description: "lockout error")
        service.authenticate { result in
            if case .failure(let error) = result {
                XCTAssertEqual(error, .lockout)
            } else {
                XCTFail("Expected failure, got success")
            }
            expectation.fulfill()
        }

        waitForExpectations(timeout: 1)
    }

    func testNotEnrolledPreventsEvaluation() {
        let service = BiometricService()
        service.context = MockLAContext(scenario: .cannotEvaluate(.biometryNotEnrolled))

        let expectation = expectation(description: "not enrolled error")
        service.authenticate { result in
            if case .failure(let error) = result {
                XCTAssertEqual(error, .notEnrolled)
            } else {
                XCTFail("Expected failure")
            }
            expectation.fulfill()
        }

        waitForExpectations(timeout: 1)
    }
}

Simulator Biometric Simulation

Xcode simulators support Face ID simulation via the menu (Features > Face ID > Matching Face) or via xcrun simctl:

# Enroll Face ID on simulator
xcrun simctl spawn booted notifyutil -p com.apple.BiometricKit.enrollmentChanged

# Simulate matching biometric (success)
xcrun simctl spawn booted notifyutil -p com.apple.LocalAuthentication.ui.biometric.accepted

# Simulate non-matching (failure)
xcrun simctl spawn booted notifyutil -p com.apple.LocalAuthentication.ui.biometric.rejected

In XCUITest, you can trigger these via XCUIDevice:

class BiometricUITests: XCTestCase {

    let app = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func testFaceIDSuccessNavigatesToDashboard() throws {
        app.buttons["Sign in with Face ID"].tap()

        // Simulator: approve Face ID prompt
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let faceIDPrompt = springboard.alerts.firstMatch
        if faceIDPrompt.waitForExistence(timeout: 3) {
            XCUIDevice.shared.perform(NSSelectorFromString("_matchBiometric"))
        }

        XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 3))
    }
}

Appium: Cross-Platform Biometric Testing in Python

For teams running cross-platform test suites, Appium provides biometric simulation APIs that work on both platforms.

from appium import webdriver
from appium.options import XCUITestOptions, UiAutomator2Options
import pytest

class TestBiometricAuth:

    @pytest.fixture
    def ios_driver(self):
        options = XCUITestOptions()
        options.platform_name = "iOS"
        options.device_name = "iPhone 15 Pro"
        options.platform_version = "17.0"
        options.bundle_id = "com.yourcompany.app"
        options.simulator = True
        # Enable biometric simulation
        options.set_capability("allowTouchIdEnroll", True)

        driver = webdriver.Remote("http://localhost:4723", options=options)
        # Enroll biometric on simulator
        driver.execute_script("mobile: enrollBiometric", {"isEnabled": True})
        yield driver
        driver.quit()

    @pytest.fixture
    def android_driver(self):
        options = UiAutomator2Options()
        options.platform_name = "Android"
        options.device_name = "emulator-5554"
        options.app_package = "com.yourcompany.app"
        options.app_activity = ".MainActivity"

        driver = webdriver.Remote("http://localhost:4723", options=options)
        yield driver
        driver.quit()

    def test_ios_biometric_success(self, ios_driver):
        driver = ios_driver

        # Navigate to login
        driver.find_element("accessibility id", "Sign in with Face ID").click()

        # Simulate successful Face ID
        driver.execute_script("mobile: sendBiometricMatch", {"match": True, "type": "faceId"})

        # Assert authenticated state
        dashboard = driver.find_element("accessibility id", "Dashboard")
        assert dashboard.is_displayed(), "Dashboard should be visible after successful auth"

    def test_ios_biometric_failure_shows_retry(self, ios_driver):
        driver = ios_driver

        driver.find_element("accessibility id", "Sign in with Face ID").click()
        driver.execute_script("mobile: sendBiometricMatch", {"match": False, "type": "faceId"})

        error_text = driver.find_element("xpath", "//*[@label='Face not recognized']")
        assert error_text.is_displayed()

    def test_android_fingerprint_success(self, android_driver):
        driver = android_driver

        driver.find_element("id", "com.yourcompany.app:id/btn_biometric").click()

        # Simulate fingerprint via Appium fingerprint API
        driver.execute_script("mobile: fingerprint", {"fingerprintId": 1})

        home = driver.find_element("id", "com.yourcompany.app:id/home_container")
        assert home.is_displayed()

    def test_android_fingerprint_lockout_fallback(self, android_driver):
        driver = android_driver

        driver.find_element("id", "com.yourcompany.app:id/btn_biometric").click()

        # Five failed attempts triggers lockout
        for _ in range(5):
            driver.execute_script("mobile: fingerprint", {"fingerprintId": -1})

        fallback_btn = driver.find_element(
            "id", "com.yourcompany.app:id/btn_use_password"
        )
        assert fallback_btn.is_displayed(), "Fallback button should appear after lockout"

CI/CD Setup

Android Emulator in CI

# .github/workflows/biometric-tests.yml
jobs:
  android-biometric:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Start emulator with fingerprint support
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          target: google_apis
          arch: x86_64
          profile: Nexus6
          script: |
            # Enroll fingerprint (fingerprint ID 1)
            adb emu finger touch 1
            ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.yourcompany.BiometricIntegrationTest

  ios-biometric:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Boot simulator
        run: |
          UDID=$(xcrun simctl create "Test iPhone" "iPhone 15 Pro" "iOS17-0")
          xcrun simctl boot $UDID
          echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV

      - name: Run XCTest biometric suite
        run: |
          xcodebuild test \
            -scheme YourAppScheme \
            -destination "id=$SIMULATOR_UDID" \
            -testPlan BiometricTests \
            CODE_SIGN_IDENTITY="" \
            CODE_SIGNING_REQUIRED=NO

Key CI Considerations

Emulator cold start and enrollment. The first adb emu finger touch 1 after a fresh emulator boot enrolls fingerprint ID 1. Do this before launching your app under test — some devices require the fingerprint to be enrolled before BiometricPrompt will display.

Simulator Face ID enrollment. On macOS CI runners, xcrun simctl can enroll Face ID headlessly. Add allowTouchIdEnroll: true in your Appium capabilities or call device.setBiometricEnrollment(true) in your XCUITest setup.

Lockout recovery. If a test leaves the device in lockout state, subsequent tests will fail. Always reset between test cases: on Android use adb shell locksettings clear --old <pin> or reboot the emulator; on iOS simulator use xcrun simctl shutdown + boot.

Parallelism. Running multiple emulators in parallel for biometric tests requires distinct AVD names and separate ADB ports. Use the emulator's -port flag and set ANDROID_SERIAL per test worker.

What to Actually Test

Unit tests cover logic; integration tests cover the seam between your code and the OS. Here's a complete coverage checklist:

Scenario Layer Notes
Successful auth → correct callback Unit Mock returns success
Failed attempt → retry shown Unit Mock returns failure
Lockout → fallback offered Unit + Integration Check both UI and state
Not enrolled → enrollment prompt Unit canEvaluatePolicy returns false
Hardware unavailable → graceful error Unit Error code HW_UNAVAILABLE
User cancels dialog Unit Error code USER_CANCEL
App backgrounded during prompt Integration Verify prompt dismisses cleanly
Screen reader / accessibility UI VoiceOver/TalkBack can activate prompt

Biometric auth is a security boundary. Treat it like one in your test suite: cover every failure mode, verify fallbacks exist, and make lockout recovery a first-class test case — not an afterthought discovered in production.

Read more

Start now free