Edition 80

Testing iOS Face ID with Appium

It's been almost two years since iOS devices started supporting Face ID. This feature is incredibly handy to users, and expected in major apps, yet the integration can be tricky and I've seen some corner cases handled badly. Appium is able to support testing in-app authentication with Face ID on iOS simulators, since the simulator app has this capability built into it.

There are only three limited controls provided by the simulator, and Appium provides the same three actions:

  • enroll
  • matching face
  • non-matching face

enroll sets up the device for Face ID. This is equivalent to scanning your face during device setup and enabling the feature. You can toggle this on and off, to make sure that your app works for users who have opted out of the technology.

The command can be sent via one of Appium's custom mobile: script commands.

driver.executeScript("mobile:enrollBiometric", ImmutableMap.of("isEnabled", true));

This command should be called once the test session has started. Don't worry about calling it twice, setting the same value that's been set before doesn't cause an error. The simulator will remember the value it's been set to though, so don't bet on it being in a certain state at the beginning of a test run.

Once enabled, we need to perform whatever action in the app results in prompting for a face to unlock it. Once prompted, we can then either present the device with the correct simulated face, or an incorrect face.

driver.executeScript("mobile:sendBiometricMatch", ImmutableMap.of("type", "faceId", "match", true));
// or
driver.executeScript("mobile:sendBiometricMatch", ImmutableMap.of("type", "faceId", "match", false));

Replacing faceId with touchId will perform the same function but for the older fingerprint ID feature.

I found a useful demo app on github which has a simple Face ID login button to test this on. Below is a complete test. The full code file and a compiled demo app can be found in our example code repository:

public class Edition080_iOS_FaceId {
    // App provided by https://github.com/zaimramlan/iOSBiometricLogin
    File classpathRoot = new File(System.getProperty("user.dir"));
    File appDir = new File(classpathRoot, "../apps/");
    String app = new File(appDir.getCanonicalPath(), "BiometricLogin.app").getAbsolutePath();
    private IOSDriver driver;

    public Edition080_iOS_FaceId() throws IOException {
    }

    @Before
    public void setUp() throws IOException {
        DesiredCapabilities caps = new DesiredCapabilities();
        caps.setCapability("platformName", "iOS");
        caps.setCapability("platformVersion", "12.4");
        caps.setCapability("deviceName", "iPhone Xs");
        caps.setCapability("automationName", "XCUITest");

        caps.setCapability("app", app);

        driver = new IOSDriver<MobileElement>(new URL("http://localhost:4723/wd/hub"), caps);
    }

    @After
    public void tearDown() {
        try {
            driver.quit();
        } catch (Exception ign) {}
    }

    @Test
    public void testIOSFaceId() {
        driver.executeScript("mobile:enrollBiometric", ImmutableMap.of("isEnabled", true));
        WebElement loginButton = driver.findElementByAccessibilityId("Log In");
        loginButton.click();

        driver.switchTo().alert().accept();

        driver.executeScript("mobile:sendBiometricMatch", ImmutableMap.of("type", "faceId", "match", true));

        WebDriverWait wait = new WebDriverWait(driver, 5);
        wait.until(ExpectedConditions.presenceOfElementLocated(By.name("Log Out")));

        WebElement logoutButton = driver.findElementByAccessibilityId("Log Out");
        logoutButton.click();

        wait.until(ExpectedConditions.presenceOfElementLocated(By.name("Log In")));

        loginButton = driver.findElementByAccessibilityId("Log In");
        loginButton.click();

        driver.executeScript("mobile:sendBiometricMatch", ImmutableMap.of("type", "faceId", "match", false));

        wait.until(ExpectedConditions.alertIsPresent());
        driver.switchTo().alert().dismiss();
    }
}