BookaMac Engineering Notes

Build an iOS Visual Regression Gate on a Cloud Mac

Build an iOS Visual Regression Gate on a Cloud Mac

The same iOS UI code can look correct on a developer machine yet produce clipped buttons, layouts compressed by Dynamic Type, or mismatched colors in Dark Mode after it is merged. A visual regression gate is not intended to replace unit tests. Its purpose is to answer one specific question under controlled rendering conditions: did this commit change the pixels users actually see?

A cloud Mac is well suited to running these checks continuously, provided that Simulator state, test data, and comparison rules are all kept under version control. Saving a few screenshots that merely “look correct” is not enough to create a reliable gate.

Define a stable screenshot matrix first

Do not begin by trying to cover every screen. Start with high-value interfaces such as the signed-out home screen, primary lists, detail views, empty states, and error states. Then pin the following dimensions for each interface:

Dimension Recommended fixed value What to do when it changes
Device One specific Simulator model Create a separate baseline directory
System A specified available runtime Review the baseline images again
Appearance Run Light and Dark modes separately Do not compare across appearances
Language Capture each target language separately Include the language code in the filename
Text size Start with the default size Create a separate test group for larger sizes
Data Local fixtures or a test API Do not depend on random content

A baseline path can follow Snapshots/<runtime>/<device>/<locale>/<appearance>/. Although this directory hierarchy is relatively deep, it makes the comparison environment immediately visible when a test fails and prevents rendering differences between system versions from being mistaken for product regressions.

A baseline image is not a permanently correct answer. It is a reviewed UI contract, and every update should be reviewed alongside the corresponding code change.

Pin the Simulator instead of repeatedly reusing its current state

Reusing a manually operated Simulator over long periods leaves behind permission prompts, keyboard settings, caches, and test accounts. A more reliable approach is to assign a dedicated device to visual tests, then shut it down, erase it, and restart it before every run.

#!/usr/bin/env bash
set -euo pipefail

: "${DEVICE_UDID:?DEVICE_UDID is required}"

xcrun simctl shutdown "$DEVICE_UDID" 2>/dev/null || true
xcrun simctl erase "$DEVICE_UDID"
xcrun simctl boot "$DEVICE_UDID"
xcrun simctl bootstatus "$DEVICE_UDID" -b

xcrun simctl status_bar "$DEVICE_UDID" override \
  --time 09:41 \
  --batteryState charged \
  --batteryLevel 100 \
  --wifiBars 3 \
  --cellularBars 4

rm -rf TestResults/Visual.xcresult
xcodebuild test \
  -workspace Example.xcworkspace \
  -scheme ExampleVisualTests \
  -destination "id=$DEVICE_UDID" \
  -resultBundlePath TestResults/Visual.xcresult

rm -rf TestResults/Attachments
xcrun xcresulttool export attachments \
  --path TestResults/Visual.xcresult \
  --output-path TestResults/Attachments

Do not rely on whichever booted device happens to be active. Parallel jobs may start multiple Simulators at the same time. Passing an explicit UDID ensures that the status bar override, installation, and tests all target the same device.

Trigger screenshots from observable state

The most common mistake in visual testing is launching the app and waiting for a fixed two seconds. Changes in machine load or network conditions can make two seconds either excessive or insufficient. XCUITest should instead wait for an accessible element that indicates the page is ready for comparison.

func capture(_ name: String, readyIdentifier: String) {
    let app = XCUIApplication()
    app.launchArguments = ["-VisualTestMode", "1"]
    app.launch()

    let ready = app.descendants(matching: .any)[readyIdentifier]
    XCTAssertTrue(ready.waitForExistence(timeout: 15))

    let image = XCUIScreen.main.screenshot()
    let attachment = XCTAttachment(screenshot: image)
    attachment.name = name
    attachment.lifetime = .keepAlways
    add(attachment)
}

Test mode should disable carousels, skeleton animations, and blinking cursors while injecting fixed dates, usernames, and list data. The goal is to control the inputs, not to replace the interface under test with a separate implementation. If a page depends on a request completing, expose a stable loading-complete identifier in the UI instead of continually increasing sleep durations.

Handle fonts and animations

Fonts must come from the system or ship with the app; they must not depend on a one-time manual installation. Animations can be disabled through launch arguments, and scrolling should be confirmed as stopped before taking the screenshot. For continuously changing video, maps, or timers, prefer deterministic test fixtures. Use masks only for very small regions that cannot be controlled.

Set explainable pixel tolerances

Different PNG bytes do not necessarily mean different images, so using cmp directly is vulnerable to differences in encoding metadata. The comparator should first decode both images into pixels with identical dimensions and the sRGB color space, then calculate channel differences pixel by pixel.

Use two conditions together:

  1. A maximum per-channel tolerance, allowing for minor antialiasing variation.
  2. The proportion of pixels exceeding that tolerance, which must remain below a very small percentage of the entire image.

An average difference alone can hide severe localized defects: a missing button may occupy only a small portion of the image. The comparator should also produce a highlighted diff image and record the number of out-of-tolerance pixels, the bounding box, and the configured thresholds. Critical confirmation screens can use strict rules, while screens containing shadows or complex gradients can have separate settings. Do not keep loosening the global threshold merely to make the build pass.

Turn failures into reviewable evidence

Every failed run should archive at least the baseline image, actual image, diff image, and .xcresult. It should also record the commit identifier, Xcode version, system runtime, device model, language, and appearance. Filenames should combine the test suite, page state, and environment so that parallel jobs cannot overwrite one another.

When investigating a failure, check items in order: first confirm whether the image dimensions or runtime changed, then verify that the test data is stable, and finally inspect the diff bounding box. Differences spread across the entire screen usually indicate drift in fonts, appearance, or color space. Differences isolated to one control are more likely to result from a layout change.

Baseline updates should also follow a separate process: generate candidate images, have a person review the differences, and replace the baselines only after confirming that the change is intentional. A test job must never overwrite baselines automatically after a failure, or a genuine regression will be accepted on the next run.

Once complete, the gate should have three properties: the environment can be recreated, the tolerances can be explained, and failures can be reproduced. Only then does screenshot testing become an engineering check rather than an image repository that occasionally requires manual cleanup.

Frequently asked questions

Should a visual regression test require every pixel to match exactly?

Usually not. Set both a per-channel difference limit and a maximum ratio of pixels above that limit. Mask only small regions that are unavoidably dynamic, such as a cursor or animation.

Why does the same commit produce different screenshots on different Simulators?

Device size, system runtime, fonts, language, time zone, display scale, and status bar state all affect rendering. Each baseline must be tied to a specific environment.

Which artifacts should be retained after a visual test fails?

Keep the baseline, actual image, diff image, test result bundle, commit identifier, Simulator model, and system version. A single failed screenshot is not enough for reliable diagnosis.

Dedicated Physical Mac mini

Move your validated workflows to a continuously online cloud Mac

Choose BookaMac M4 or BookaMac M4 Pro for your workload, then confirm the region, rental term, and storage add-ons at checkout.

Choose a Rental Plan