
Short answer: post the documented Darwin notifications with xcrun simctl spawn … notifyutil from a BiometricSimulator helper inside XCUITest, then assert on the resulting UI and attach a screenshot. Run it on a Figment $29 virtual Intel macOS plan if you need a cheap host for a single simulator and a supported Intel toolchain (Xcode 26 or earlier per Apple's porting guide — see Porting your macOS apps to Apple silicon); move to a Figment M-series Mac from $95/month when you need Xcode 27, visionOS, more RAM or parallel simulators.
Manual Face ID testing in the Simulator is a menu click — Features → Face ID → Matching Face. CI needs the same outcomes without touching the GUI, every build, with evidence. The approach below gives you two XCUITests (match and non-match), a reusable helper, and a reset step that leaves the simulator in a known state between runs.
The Simulator reacts to Darwin notifications posted inside its own sandbox with notifyutil. There is no public xcrun simctl biometric subcommand; the supported path is xcrun simctl spawn <udid> notifyutil. These notification names are undocumented Darwin internals that have worked across recent Xcode releases — verify them against the Xcode you have installed before trusting them in a release pipeline.
# Pick the booted simulator UDID
UDID=$(xcrun simctl list devices booted -j \
| python3 -c "import sys,json;d=json.load(sys.stdin)['devices']; \
print(next(x['udid'] for r in d.values() for x in r if x['state']=='Booted'))")
# Enrol Face ID
xcrun simctl spawn "$UDID" notifyutil \
-s com.apple.BiometricKit.enrollmentChanged 1 \
-p com.apple.BiometricKit.enrollmentChanged
# Matching face -> authentication success
xcrun simctl spawn "$UDID" notifyutil -p com.apple.BiometricKit_Sim.pearl.match
# Non-matching face -> authentication failure
xcrun simctl spawn "$UDID" notifyutil -p com.apple.BiometricKit_Sim.pearl.nomatch
For Touch ID, swap pearl for fingerTouch. These commands are the tool; the test logic is in XCUITest.
Your app calls LAContext.evaluatePolicy to present the biometric prompt. The documentation for LAContext explicitly requires NSFaceIDUsageDescription in Info.plist; without it, authorisation requests may fail. The test launches the app, taps the button that calls evaluatePolicy, then resolves the prompt by posting the pearl.match or pearl.nomatch notification.
// App code (not the test target)
import LocalAuthentication
func authenticate(_ completion: @escaping (Bool, Error?) -> Void) {
let ctx = LAContext()
var err: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else {
completion(false, err); return
}
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Sign in to your account") { ok, e in
DispatchQueue.main.async { completion(ok, e) }
}
}
The unique artefact is a small helper that shells out to notifyutil from the test process, plus one test per outcome that asserts on the UI and captures a named screenshot.
// BiometricSimulator.swift (UI test target)
import Foundation
import XCTest
enum FaceIDOutcome { case match, nonMatch }
struct BiometricSimulator {
@discardableResult
static func trigger(_ outcome: FaceIDOutcome, udid: String) -> Bool {
let name = outcome == .match
? "com.apple.BiometricKit_Sim.pearl.match"
: "com.apple.BiometricKit_Sim.pearl.nomatch"
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
p.arguments = ["simctl", "spawn", udid, "notifyutil", "-p", name]
do {
try p.run(); p.waitUntilExit()
return p.terminationStatus == 0
} catch {
XCTFail("BiometricSimulator: \(error)"); return false
}
}
}
// FaceIDAuthTests.swift (UI test target)
import XCTest
final class FaceIDAuthTests: XCTestCase {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "booted"
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchEnvironment["BIOMETRIC_TEST_MODE"] = "1"
app.launch()
}
func testFaceIDMatchUnlocksApp() {
app.buttons["Sign In with Face ID"].tap()
// The system Face ID sheet is drawn outside the app's a11y tree on some
// simulator builds, so wait briefly rather than asserting on app.alerts.
Thread.sleep(forTimeInterval: 1.0)
XCTAssertTrue(BiometricSimulator.trigger(.match, udid: udid))
let dashboard = app.staticTexts["Welcome back"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 3),
"Dashboard did not appear after match")
attach(name: "face-id-match-success")
}
func testFaceIDNonMatchShowsPasscodeFallback() {
app.buttons["Sign In with Face ID"].tap()
Thread.sleep(forTimeInterval: 1.0)
XCTAssertTrue(BiometricSimulator.trigger(.nonMatch, udid: udid))
let passcode = app.buttons["Use Passcode"]
XCTAssertTrue(passcode.waitForExistence(timeout: 3),
"Passcode fallback did not appear after non-match")
attach(name: "face-id-nonmatch-fallback")
}
private func attach(name: String) {
let a = XCTAttachment(screenshot: app.screenshot())
a.name = "\(name).png"; a.lifetime = .keepAlways
add(a)
}
}
Pass SIMULATOR_UDID from CI when you run several simulators in parallel; fall back to booted for a single-simulator job. Replace the button titles and the success text with the real strings from your app.
Residual enrolment from a previous run makes the prompt appear in a surprising state. Add a pre-test step that flips enrolment off and on so every job starts from the same place.
xcrun simctl boot "$UDID" || true
xcrun simctl spawn "$UDID" notifyutil \
-s com.apple.BiometricKit.enrollmentChanged 0 \
-p com.apple.BiometricKit.enrollmentChanged
xcrun simctl spawn "$UDID" notifyutil \
-s com.apple.BiometricKit.enrollmentChanged 1 \
-p com.apple.BiometricKit.enrollmentChanged
Run this before xcodebuild test so setUp() does not have to manage enrolment. If xcodebuild returns non-zero, fail the job — a failed build is a failure, not a biometric data point.
Face ID simulation runs inside a macOS process. XCUITest, xcrun simctl spawn, and notifyutil only exist on macOS, so a Linux runner cannot substitute. Two Figment options fit this test:
CloudMac publishes $99.99/month on its pricing page and a $999/year offer on its homepage. The annual offer is roughly $83.25/month effective. CloudMac is not Figment and the hardware classes differ, so compare on the axis that matters for this test:
| Option | Headline | 12-month cost | Hardware | Notes |
|---|---|---|---|---|
| Figment virtual Intel macOS | $29/month | $348 | 2 vCPU, 8 GiB, 80 GiB, Intel | Browser console; Intel Xcode 26 or earlier |
| Figment M-series entry | from $95/month | from $1,140 | Chip/RAM/disk confirmed in quote | Request-based provisioning |
| CloudMac M4 monthly | $99.99/month | $1,199.88 | M4, 16 GB, 256 GB | 256 GB tier "Reserve now"; 512 GB / 1 TB fully reserved |
| CloudMac M4 annual | $999/year | $999 | M4, 16 GB, 256 GB | $141 less than 12 × $95 |
Entry price is not equivalent hardware. If you need a verified M4/16 GB/256 GB today, CloudMac's $999/year is cheaper than twelve months of Figment's $95 M-series entry; buy there if the spec is the decision. If the decision is managed CI — the pre-test reset, the signing identity, keychain hygiene on the runner, screenshot artefact retention, hardened self-hosted GitHub runner setup per GitHub's secure-use guide — that work is what Figment's Managed Build Mac ($249/month) and Managed Mac Team ($999/month) include. The virtual Intel VM at $29 is the cheapest way to prove the notifyutil flow before committing to a bigger host.