Biometric Authentication Testing: Automation Strategies for Touch ID, Face ID, and Windows Hello
Biometric authentication — Touch ID, Face ID, Windows Hello, fingerprint readers — powers the most seamless login experiences on the web and in mobile apps. It also creates one of the hardest testing challenges: you can't script a fingerprint or a face scan. But biometric auth sits on top of FIDO2/WebAuthn, which means you can test the full authentication flow using virtual authenticators and platform-level mocking strategies. This guide covers all the approaches.
Why Biometric Auth is Hard to Test
Biometric authentication involves three layers:
- Platform biometric layer — OS-level sensor interaction (Touch ID, Windows Hello, fingerprint reader)
- FIDO2/WebAuthn layer — cryptographic credential management
- Application layer — your registration flow, authentication flow, and fallback logic
Layer 1 can't be automated directly — the OS controls the biometric sensor dialog. The solution is to test layers 2 and 3 thoroughly using virtual authenticators that simulate the "user verification succeeded" outcome, while ensuring your fallback paths are tested separately.
Virtual Authenticator Approach (Playwright/CDP)
For web applications, Chrome DevTools Protocol virtual authenticators simulate biometric authentication without real hardware:
// helpers/biometric-auth.js
async function setupBiometricAuthenticator(page, options = {}) {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
// 'internal' transport = platform authenticator (Touch ID, Face ID, Windows Hello)
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
// This is the key setting — simulates successful biometric verification
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
return { client, authenticatorId };
}
// Simulate biometric failure
async function setupFailingBiometric(page) {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: false, // Biometric check fails
},
});
return { client };
}
module.exports = { setupBiometricAuthenticator, setupFailingBiometric };Testing Touch ID / Face ID in Web Apps
Registration Flow
import { test, expect } from '@playwright/test';
import { setupBiometricAuthenticator } from '../helpers/biometric-auth';
test('Touch ID registration flow', async ({ page }) => {
await setupBiometricAuthenticator(page);
await page.goto('/settings/security');
await page.click('[data-testid="add-touch-id-btn"]');
// The biometric dialog is simulated by the virtual authenticator
await expect(page.locator('[data-testid="biometric-success"]')).toBeVisible({
timeout: 10000,
});
// Verify passkey appears in the list
await expect(page.locator('[data-testid="passkey-list"]')).toContainText(
'Touch ID'
);
});Authentication with Biometrics
test('Face ID login flow', async ({ page }) => {
const { client } = await setupBiometricAuthenticator(page);
// Register first
await registerWithBiometric(page, 'user@example.com');
await page.click('[data-testid="logout"]');
// Authenticate using biometric
await page.goto('/login');
await page.click('[data-testid="use-face-id-btn"]');
// Virtual authenticator auto-provides biometric confirmation
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible({
timeout: 10000,
});
});Testing Biometric Failure
test('handles Touch ID failure gracefully', async ({ page }) => {
// Register with working biometric first
await setupBiometricAuthenticator(page);
await registerWithBiometric(page, 'user@example.com');
await page.click('[data-testid="logout"]');
// Now simulate failed biometric
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: false, // Fingerprint rejected
},
});
await page.goto('/login');
await page.click('[data-testid="use-touch-id-btn"]');
// Should show retry or fallback option
await expect(page.locator('[data-testid="biometric-failed"]')).toBeVisible();
await expect(page.locator('[data-testid="try-password-instead"]')).toBeVisible();
});Testing Mobile Biometrics (Native Apps)
For native iOS/Android apps with biometric auth, the approach differs by platform.
iOS: XCTest with BiometricID Simulation
Xcode's XCUITest framework supports simulating Touch ID:
// BiometricAuthTests.swift
import XCTest
class BiometricAuthTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testSuccessfulTouchIDLogin() {
// Navigate to login
app.buttons["Sign in with Touch ID"].tap()
// Simulate successful Touch ID
let result = XCUIDevice.shared.perform(NSSelectorFromString("setBiometricEnrollment:"), with: true)
// Simulate matching fingerprint
XCUIDevice.shared.perform(NSSelectorFromString("fingerTouchShouldSucceed:"), with: true)
// Verify login success
XCTAssertTrue(app.staticTexts["Welcome back"].waitForExistence(timeout: 5))
}
func testFailedTouchIDShowsFallback() {
app.buttons["Sign in with Touch ID"].tap()
// Simulate failed fingerprint
XCUIDevice.shared.perform(NSSelectorFromString("fingerTouchShouldSucceed:"), with: false)
// Should show fallback option
XCTAssertTrue(app.buttons["Use Password Instead"].waitForExistence(timeout: 5))
}
}Android: Espresso with BiometricPrompt Testing
// BiometricAuthTest.kt
@RunWith(AndroidJUnit4::class)
class BiometricAuthTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun biometricLogin_success() {
// Click login button
onView(withId(R.id.btn_biometric_login)).perform(click())
// Inject successful biometric result
val authResult = BiometricPrompt.AuthenticationResult(
BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC
)
// Test double / mock BiometricPrompt
activityRule.scenario.onActivity { activity ->
activity.biometricCallback.onAuthenticationSucceeded(authResult)
}
// Verify navigation to main screen
onView(withId(R.id.main_screen)).check(matches(isDisplayed()))
}
@Test
fun biometricLogin_failure_showsFallback() {
onView(withId(R.id.btn_biometric_login)).perform(click())
activityRule.scenario.onActivity { activity ->
activity.biometricCallback.onAuthenticationFailed()
}
onView(withId(R.id.btn_use_password)).check(matches(isDisplayed()))
}
}Testing Windows Hello (Browser)
Windows Hello uses the internal transport via WebAuthn. The virtual authenticator approach works identically:
test('Windows Hello authentication', async ({ page }) => {
// Same setup as Touch ID — transport: 'internal' covers all platform authenticators
await setupBiometricAuthenticator(page);
await page.goto('/login');
await page.click('[data-testid="sign-in-with-windows-hello"]');
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible({
timeout: 10000,
});
});Unit Testing Biometric Auth Business Logic
Separate business logic from the biometric ceremony so unit tests can cover edge cases:
// auth-service.ts
class AuthService {
constructor(private readonly webAuthn: WebAuthnService) {}
async loginWithBiometric(userId: string): Promise<AuthResult> {
try {
const challenge = await this.generateChallenge(userId);
const credential = await this.webAuthn.authenticate(challenge);
return await this.verifyAndCreateSession(credential);
} catch (err) {
if (err.name === 'NotAllowedError') {
return { success: false, reason: 'user_cancelled' };
}
if (err.name === 'InvalidStateError') {
return { success: false, reason: 'no_credential' };
}
throw err;
}
}
}
// auth-service.test.ts
describe('AuthService.loginWithBiometric', () => {
const mockWebAuthn = {
authenticate: jest.fn(),
};
it('returns user_cancelled on NotAllowedError', async () => {
mockWebAuthn.authenticate.mockRejectedValue(
Object.assign(new Error(), { name: 'NotAllowedError' })
);
const service = new AuthService(mockWebAuthn);
const result = await service.loginWithBiometric('user-123');
expect(result.success).toBe(false);
expect(result.reason).toBe('user_cancelled');
});
it('returns no_credential when authenticator has no credential', async () => {
mockWebAuthn.authenticate.mockRejectedValue(
Object.assign(new Error(), { name: 'InvalidStateError' })
);
const service = new AuthService(mockWebAuthn);
const result = await service.loginWithBiometric('user-123');
expect(result.success).toBe(false);
expect(result.reason).toBe('no_credential');
});
});Testing Biometric Enrollment Status Checks
Some applications check if biometrics are available before offering the option:
test('biometric option not shown when platform authenticator unavailable', async ({ page }) => {
// Override PublicKeyCredential to simulate no platform authenticator
await page.addInitScript(() => {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable =
() => Promise.resolve(false);
});
await page.goto('/login');
// Should not show biometric option
await expect(page.locator('[data-testid="use-touch-id-btn"]')).not.toBeVisible();
// Password should be the primary option
await expect(page.locator('[name="password"]')).toBeVisible();
});
test('biometric option shown when platform authenticator available', async ({ page }) => {
await page.addInitScript(() => {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable =
() => Promise.resolve(true);
});
await page.goto('/login');
await expect(page.locator('[data-testid="use-touch-id-btn"]')).toBeVisible();
});Production Monitoring with HelpMeTest
Biometric auth flows are high-stakes. A broken biometric flow means users can't log in. Set up HelpMeTest to run your authentication flow on a schedule:
- Register a test account with a virtual passkey
- Run a login flow every 5 minutes
- Alert on failure within seconds
This catches regressions from server config changes, certificate issues, or RP ID mismatches — all of which can silently break biometric login without throwing obvious server errors.
Summary
Testing biometric authentication requires: CDP virtual authenticators with isUserVerified: true for successful flows and isUserVerified: false for failure scenarios, XCTest biometric injection for iOS, Espresso/BiometricPrompt mocks for Android, unit tests for error handling logic (NotAllowedError, InvalidStateError), availability check tests for the biometric enrollment status, and production monitoring with scheduled E2E tests. The most critical test is the failure path — biometric auth will fail for some users, and your fallback must always work.