Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

iOS Simulator Face ID Biometric Test Script

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.

Enrolling Face ID and Posting Biometric Notifications

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.

Wiring LAContext Into the App Under Test

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 BiometricSimulator Helper and Two XCUITests

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.

Resetting Enrolment Between Runs

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.

Choosing the Right Figment Host for This Job

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:

  • $29 virtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk). It runs a full macOS install and the iOS Simulator with an Intel-compatible Xcode (Xcode 26 or earlier per Apple's porting guide). Access is through the browser console; outbound agents from your CI service connect fine. Good enough for one simulator, one test target, and the notifyutil flow above. No GPU acceleration or audio. ARM-only toolchains (Xcode 27, visionOS) are not available on this host.
  • M-series Mac from $95/month. Use this when you need current Apple-silicon toolchains, more RAM for parallel simulators, or faster single-simulator wall time. The $95 figure is an entry price — chip, RAM and disk are confirmed in the quote, not pre-advertised.

Worked comparison vs CloudMac M4 (16 GB / 256 GB)

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:

OptionHeadline12-month costHardwareNotes
Figment virtual Intel macOS$29/month$3482 vCPU, 8 GiB, 80 GiB, IntelBrowser console; Intel Xcode 26 or earlier
Figment M-series entryfrom $95/monthfrom $1,140Chip/RAM/disk confirmed in quoteRequest-based provisioning
CloudMac M4 monthly$99.99/month$1,199.88M4, 16 GB, 256 GB256 GB tier "Reserve now"; 512 GB / 1 TB fully reserved
CloudMac M4 annual$999/year$999M4, 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.

Request a configuration →


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms