iOS Visual Regression Testing with simctl and Pixel Diffs
A practical UIKit screenshot pipeline, a paywall layout bug, and the failure modes to fix before trusting CI. iOS visual regression testing can start with a small, local pipeline: launch a specific screen, capture it on a simulator, and compare its pixels against an accepted baseline. The difficult part is making sure both screenshots represent the same environment and application state. While updating ShotZen's paywall, I reordered subscription options, emphasized a recommendation badge, and added a NO RENEWALS label to the lifetime option. The screenshot report showed changes beyond the areas I intended to edit. The annual subscription row, call-to-action button, and footer had moved too. Replacing the padded badge with plain text reduced the reported difference. That experience shaped how I read visual regression reports: the percentage measures changed area, while the surrounding context explains whether the change is acceptable. This walkthrough covers the implementation described in that September 2026 case, including its limitations. It is useful as a local review tool; several capture checks still need strengthening before treating it as an unattended CI gate. Define what the test is supposed to prove The case used four languages, two appearance modes, and four screens: 32 screenshot states. A screen name alone is not a sufficient coverage unit. Language and appearance belong in its identity. The assertion is narrow: under specified inputs and rendering conditions, how does the current static appearance differ from the appearance previously accepted? It does not prove that a purchase succeeds, that a button responds, or that a price description is correct. An unchanged screenshot can accompany broken business logic. Choose a testing approach according to the behavior you need to observe: | Approach | Useful observation | Main responsibility | |---|---|---| | XCUITest with screenshots | Launch behavior, navigation, and interaction paths | Prepare state and maintain UI tests; launch arguments can also provide shortcuts | | swift-snapshot-testing | Snapshots of views, view controllers, and other values | Configure rendering strategies and assertions in tests | | DEBUG harness with simctl | Full simulator screenshots of registered screen states | Maintain application state isolation, capture integrity, and comparison logic | Point-Free's swift-snapshot-testing supports view controllers, device configurations, and trait collections. It is not limited to isolated views. The choice here was to expose a predictable entry point inside the app and let an external script handle the screenshots. Keep the architecture small The pipeline has three parts: - Application harness: VisualRegressionHarness.swift reads launch arguments, configures state, builds the requested view controller, and installs it as the window's root controller. - Capture driver: capture.py selects a simulator, locates and installs the app, then loops through language, appearance, and screen combinations. - Comparator: diff_report.py compares PNGs using Pillow, draws difference boxes, and writes an HTML report plussummary.json . The shell entry point, run.sh , exposes initialization, baseline capture, checking, and opening the report. capture.py --launch arguments--> DEBUG harness in the app | +-- simctl screenshot --> current/ / / .png | baselines/ / / .png | diff_report.py | HTML + summary.json The diagram abbreviates the screenshot operation; the actual command is xcrun simctl io screenshot . The interfaces are conventions: argument names, registry keys, and relative image paths. The generic engine does not need to know the app's business architecture. Each project supplies its own screen builders, language handling, theme integration, and fixed test data. The app does not need an additional Swift testing library for this route. The host still needs Xcode with simulator support, Python, and Pillow. Make the screen entry point deterministic The capture driver launches the app with arguments such as these. Replace the placeholders with a real simulator UDID and bundle identifier: xcrun simctl launch \ -VRScreen paywall \ -VRLang ja \ -VRStyle dark These are application-defined arguments. simctl passes them through; it does not automatically interpret -VRLang as a localization instruction. The initialization order matters: parse the request, prepare fixed data, select the language, synchronize the theme, construct the controller, and display the window. Setting the language after constructing labels may leave old text in place. For a UIKit project using SceneDelegate , the integration point belongs before normal routing. This fragment depends on the project's own harness, theme manager, and router: guard let windowScene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: windowScene) self.window = window #if DEBUG if VisualRegressionHarness.activateIfRequested(on: window) { window.makeKeyAndVisible() return } #endif ThemeManager.shared.attach(window) let router = AppRouter(window: window) router.start() The early return bypasses onboarding and ordinary navigation. If the screen needs a navigation bar, construct it inside a UINavigationController in the registry. Be careful about what else the early return skips. Dependency injection, required services, and theme observers may still be necessary. Supply deterministic fixtures for network responses, account state, photo access, and product information. Guard both the harness and its call site with #if DEBUG , and verify that production build configurations do not define DEBUG accidentally. Synchronize the application's theme state In ShotZen, appearance involved both the UIKit window and a custom theme manager: // Project-specific setup, before constructing the target controller. ThemeManager.shared.current = .dark window.overrideUserInterfaceStyle = .dark Apple documents how overrideUserInterfaceStyle overrides interface appearance. Your application's stored preference remains your responsibility. A controller that reapplies a saved theme during loading can undo an earlier window override. The result might be a light screenshot saved under dark/ . A correct filename does not establish a correct state. The supplied first-round English dark-mode report shows a 3.49% difference. Control the environment before tuning the threshold Restarting an app does not reset UserDefaults, databases, permissions, or every system overlay. A reliable capture needs more control than a fresh process. Record the device and build identities The implementation described here prefers the configured simulator name. Among matching names, it prefers an already booted device, then a newer runtime. If the name is unavailable, it warns and falls back to an available iPhone. That still leaves room for environment drift. Two simulators with the same name can use different runtimes. A stronger CI setup would record the UDID, runtime, Xcode version, and image dimensions, then validate compatibility before comparison. The case implementation does not yet provide that complete manifest. The driver locates an existing .app through xcodebuild -showBuildSettings , using BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME . Reusing a successful Xcode build is convenient, including when a particular command-line build encounters framework-embedding errors. However, the inspected implementation can continue with an existing .app after a requested build fails. A successful capture may therefore show old code. CI should require a successful build and bind the screenshot run to that exact artifact. Freeze the visible inputs The status bar can be stabilized with: xcrun simctl status_bar override \ --time 9:41 \ --batteryLevel 100 \ --batteryState charged \ --cellularBars 4 \ --dataNetwork wifi \ --wifiBars 3 This does not freeze in-app dates, countdowns, remote images, or prices. Those need fixed inputs. The capture driver also defaults to rebooting the simulator and resetting app privacy permissions before capture. These operations are configurable. A screen that needs granted access requires an explicitly prepared state; resetting permission alone can create another prompt. Treat a delay as a delay The case configuration waits four seconds after each launch. For 32 states, that is 128 seconds of waiting alone, before build, boot, installation, and screenshot overhead. A fixed sleep is not proof that layout has settled. Disabling UIView animations does not stop every timer or asynchronous task. For complex screens, a readiness signal after fixtures and final layout are complete would be more reliable. That is a proposed improvement, not a capability to assume in this implementation. Understand the pixel difference algorithm Here is a standalone example of the core calculation. It requires Pillow: from PIL import Image, ImageChops PIX_TOL = 24 def changed_percent(baseline: Image.Image, current: Image.Image) -> float: if baseline.size != current.size: raise ValueError("Images must have matching dimensions") base = baseline.convert("RGB") cur = current.convert("RGB") # Convert the absolute RGB difference to a grayscale difference. gray = ImageChops.difference(base, cur).convert("L") mask = gray.point(lambda value: 255 if value > PIX_TOL else 0) changed = mask.histogram()[255] return 100.0 * changed / (mask.width * mask.height) base = Image.new("RGB", (10, 10), "white") cur = base.copy() cur.putpixel((0, 0), (0, 0, 0)) print(f"{changed_percent(base, cur):.2f}%") # 1.00% Pillow's difference operation computes absolute channel differences. The subsequent grayscale conversion weights those differences approximately as: gray_difference = 0.299 * abs(delta_R) + 0.587 * abs(delta_G) + 0.114 * abs(delta_B) The weights come from Pillow's RGB-to-grayscale conversion. Converting the difference image is not equivalent to converting both originals to grayscale first. The threshold of 24 applies to that weighted result, not independently to ea
Comments
No comments yet. Start the discussion.