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.rejectedIn 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=NOKey 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.