App Store screenshot automation with XCUITest lets you capture pixel-perfect, locale-correct screenshots across every market your app ships into, in a single command. The setup takes an afternoon and removes the manual screenshot pass from every release.
Indie iOS developers know the manual screenshot pass is the worst part of shipping a new version. Open the simulator. Set the status bar to 9:41. Wipe the existing data. Seed something realistic. Take screenshots of every screen. Switch to German. Repeat. Switch to Spanish. Repeat. Five hours later, the pass is done, half of the screens have the wrong battery icon, and one of them shows your dev email in the corner.
This post walks through a complete XCUITest-based automation pipeline that fixes this, end to end. The four pieces are a screenshot mode in the app, an XCUITest that loops through locales and captures screens, a shell script that prepares the simulator with a clean status bar, and an XcodeGen target for the test bundle.
On this page
- Why automate App Store screenshots
- What you need to set up
- Step 1: Add screenshot mode to the app
- Step 2: Write the XCUITest
- Step 3: Wrap it in a shell script
- Step 4: XcodeGen configuration
- XCUITest vs other approaches
- Validate the screens before you automate them
- Common pitfalls and how to fix them
- FAQ
Why automate App Store screenshots
Manual screenshots scale poorly. A modest paid iOS app needs six screens per device family across nine locales: that is 54 screenshots per release for the iPhone bundle alone. If you also support iPad, it is 108. If you support iPad Mini and iPad Pro 13 inch separately for full marketing coverage, it is 162.
Apple expects locale-correct screenshots. App Store Connect lets you upload locale-specific screenshots per language, and Apple's ranking gives weight to apps that ship localized App Store assets in markets where the app is localized. The benefit compounds: a localized UI plus localized screenshots reads as a properly localized product to both reviewers and customers. Our writeup of App Store optimization tips covers the broader ASO surface; screenshots are one of the highest-leverage levers inside it.
The cost of getting one screenshot wrong is also high. A screenshot taken at 9:43 with one signal bar reads as amateurish next to a competitor showing 9:41 and full signal. The status bar override matters because it removes a class of "should I retake this" decisions from your process.
Practical rule: the deterministic part is the point. Same script in six months, same output, no judgement calls in between.
The benefit of automation, beyond saving the five-hour pass at every release, is that the captured screenshots become deterministic. The same script run six months from now produces the same output. The pipeline replaces "what did we change last release" with "what does the script say now."
What you need to set up
The architecture is four files:
- A launch flag in the app that switches it into screenshot mode with predictable state.
- A single XCUITest file that loops through locales and saves PNGs.
- A shell script that boots the simulator, sets the status bar, and runs the test.
- An XcodeGen update to declare the UI test target and a dedicated scheme.
The full output of a single run is a directory tree organized by locale folder name (App Store Connect's exact codes), with one PNG per screen per locale:
/tmp/screenshots/
├── en-US/
│ ├── 01_Dashboard.png
│ ├── 02_Feature.png
│ └── ...
├── de-DE/
│ └── ...
└── zh-Hans/
└── ...
The naming convention with numeric prefixes is deliberate. App Store Connect's screenshot order is set by sort order in the upload, so numeric prefixes give you control without renaming on upload. The folder names match App Store Connect's locale codes so uploading is a straight directory walk.
Step 1: Add screenshot mode to the app
The app needs to know it is being run for screenshots so it can switch to predictable state. A static flag reading from launch arguments is the cleanest hook.
// MyApp.swift
@main
struct MyApp: App {
static let isScreenshotMode = ProcessInfo.processInfo.arguments.contains("-SCREENSHOT_MODE")
// ...
}In the app initializer, branch the data store. Production uses your CloudKit-backed configuration; screenshot mode uses an in-memory store. This prevents test data from leaking into iCloud and gives every locale a clean slate to seed against.
let config: ModelConfiguration
if Self.isScreenshotMode {
config = ModelConfiguration(
"MyApp",
schema: schema,
isStoredInMemoryOnly: true
)
} else {
config = ModelConfiguration(
"MyApp",
schema: schema,
cloudKitDatabase: .automatic
)
}In the root view, skip onboarding when the flag is set so the app lands directly on the main interface:
var body: some View {
Group {
if hasCompletedOnboarding || MyApp.isScreenshotMode {
MainTabView()
} else {
OnboardingView(/* ... */)
}
}
.task {
if MyApp.isScreenshotMode {
seedScreenshotData()
}
}
}The seed function populates realistic data. The key principles are:
- Generate at least six months of historical data so charts, heatmaps, and streak counters look full.
- Include an improvement trend so progress charts visibly go up over time. An app that looks like it is delivering value sells better than one that looks empty.
- Vary the data naturally. Not every day, gaps of one to three days, occasional outliers. Generated data that looks generated is worse than no data.
- Cover every model the app displays. Sessions, entries, check-ins, badges, all of them. A screen with a placeholder where data should be reads as broken.
struct SampleDataGenerator {
static func generateAll(in context: ModelContext) {
// 180 days of history with realistic variation
}
static func removeAll(in context: ModelContext) {
// Delete each model type so reruns are clean
}
}If your app gates features behind a paywall, bypass the gate in screenshot mode so the captured UI shows the full experience. App reviewers and conversion-rate optimization both benefit from seeing the unlocked product.
isPremium: subscriptionManager.isPremiumUser || MyApp.isScreenshotModeStep 2: Write the XCUITest
UI tests use XCTest (Swift Testing does not support UI tests yet). Create a UI test target and a single test file that handles every locale in one run.
Locale configuration
Define every locale with three identifiers: the -AppleLanguages value, the -AppleLocale value, and the App Store Connect folder name. The folder name is what Apple expects when you organize screenshots for upload.
final class ScreenshotTests: XCTestCase {
private struct Locale {
let language: String // -AppleLanguages value
let localeID: String // -AppleLocale value
let folderName: String // App Store Connect folder name
}
private static let locales: [Locale] = [
Locale(language: "en", localeID: "en_US", folderName: "en-US"),
Locale(language: "de", localeID: "de_DE", folderName: "de-DE"),
Locale(language: "fr", localeID: "fr_FR", folderName: "fr-FR"),
Locale(language: "es", localeID: "es_ES", folderName: "es-ES"),
Locale(language: "es-MX", localeID: "es_MX", folderName: "es-MX"),
Locale(language: "pt-BR", localeID: "pt_BR", folderName: "pt-BR"),
Locale(language: "pt-PT", localeID: "pt_PT", folderName: "pt-PT"),
Locale(language: "it", localeID: "it_IT", folderName: "it-IT"),
Locale(language: "zh-Hans", localeID: "zh_CN", folderName: "zh-Hans"),
]
}If you are picking which languages to support in the first place, the Xcode 27 translation agents post covers that decision in depth.
Locale-independent tab navigation
Tab text changes per language. Tap by index instead so the same code works in every locale:
private enum Tab: Int {
case home = 0
case feature = 1
case progress = 2
case settings = 3
}
private func tapTab(_ tab: Tab, in app: XCUIApplication) {
let button = app.tabBars.buttons.element(boundBy: tab.rawValue)
if button.waitForExistence(timeout: 5) {
button.tap()
}
}System alert dismissal
If your app requests permissions (notifications, camera, microphone), iOS shows a system alert that will cover the screenshot. Add a UI interruption monitor with localized "Allow" labels covering every language your app ships in:
addUIInterruptionMonitor(withDescription: "System Alert") { alert in
for label in ["Allow", "OK", "Erlauben", "Autoriser",
"Permitir", "Consenti", "允许"] {
let button = alert.buttons[label]
if button.exists {
button.tap()
return true
}
}
return false
}The monitor fires on the next interaction after the alert appears, not on its own. Pre-trigger the alert by navigating to the screen that requests the permission, then tap the app body to fire the monitor:
tapTab(.feature, in: app)
sleep(1)
app.tap() // triggers the interruption monitor
sleep(2)The locale loop
For each locale, launch the app fresh with the locale arguments plus the screenshot flag, then walk the screens:
func testCaptureScreenshots() throws {
for locale in Self.locales {
try captureScreenshots(for: locale)
}
}
private func captureScreenshots(for locale: Locale) throws {
let app = XCUIApplication()
app.launchArguments = [
"-SCREENSHOT_MODE",
"-AppleLanguages", "(\(locale.language))",
"-AppleLocale", locale.localeID,
]
app.launch()
sleep(2) // let data queries and animations settle
let outputDir = "\(Self.outputBase)/\(locale.folderName)"
try FileManager.default.createDirectory(
atPath: outputDir,
withIntermediateDirectories: true
)
tapTab(.home, in: app)
sleep(1)
try saveScreenshot(app: app, name: "01_Home", dir: outputDir, locale: locale)
tapTab(.feature, in: app)
sleep(1)
try saveScreenshot(app: app, name: "02_Feature", dir: outputDir, locale: locale)
// ...more screens
}Localized button text
When you need to tap a button or link whose text changes per language, prepare an array of every translation and iterate. This is uglier than reading a localized string from the bundle, but it does not require the test target to know about your String(localized:) keys:
let thirtyDayLabels = ["30D", "30T", "30J", "30G", "30天"]
for label in thirtyDayLabels {
let button = app.staticTexts[label]
if button.exists {
button.tap()
sleep(1)
break
}
}Saving screenshots
Save each capture twice. Once as an XCTAttachment so it appears in the Xcode test report, and once as a PNG on disk so the shell script can pick it up:
private func saveScreenshot(
app: XCUIApplication,
name: String,
dir: String,
locale: Locale
) throws {
let screenshot = app.screenshot()
// 1. XCTAttachment (visible in test navigator)
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
attachment.name = "\(locale.folderName)_\(name)"
add(attachment)
// 2. PNG file on disk
let filePath = "\(dir)/\(name).png"
try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: filePath))
}The output directory is taken from an environment variable so the shell script can override it:
private static let outputBase: String = {
ProcessInfo.processInfo.environment["SCREENSHOTS_DIR"] ?? "/tmp/screenshots"
}()Step 3: Wrap it in a shell script
The shell script handles three things the XCUITest cannot: booting the simulator, overriding the status bar to a clean 9:41 with full signal and full battery, and resetting it after the run.
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PROJECT="$PROJECT_DIR/MyApp.xcodeproj"
SCHEME="Screenshots"
DEVICE="${DEVICE:-iPhone 17 Pro Max}"
OUTPUT_DIR="${SCREENSHOTS_DIR:-/tmp/screenshots}"
DEVICE_ID=$(xcrun simctl list devices available -j \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for runtime, devices in data.get('devices', {}).items():
for d in devices:
if d['name'] == '$DEVICE' and d['isAvailable']:
print(d['udid'])
sys.exit(0)
sys.exit(1)
")
xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
# Clean status bar: 9:41, full signal, full battery, charged
xcrun simctl status_bar "$DEVICE_ID" override \
--time "9:41" \
--dataNetwork wifi \
--wifiMode active \
--wifiBars 3 \
--cellularMode active \
--cellularBars 4 \
--batteryState charged \
--batteryLevel 100
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$DEVICE_ID" \
-only-testing:"MyAppUITests/ScreenshotTests/testCaptureScreenshots" \
SCREENSHOTS_DIR="$OUTPUT_DIR" \
CODE_SIGNING_ALLOWED=NO \
2>&1 | tail -20
# Reset status bar so the simulator is usable afterward
xcrun simctl status_bar "$DEVICE_ID" clear
TOTAL=$(find "$OUTPUT_DIR" -name "*.png" | wc -l | tr -d ' ')
echo "Done. $TOTAL screenshots in $OUTPUT_DIR"The 9:41 reference is Apple's marketing convention from the 2007 iPhone keynote. The status bar override matters because the simulator's default time changes between runs, and a mismatched status bar across screenshots looks unprofessional next to competitors who took the time to set it.
Practical rule: a screenshot with the wrong status bar time and the wrong battery icon is worse than no screenshot.
Usage:
# All locales
./scripts/capture-screenshots.sh
# Single locale
./scripts/capture-screenshots.sh en
# Different device
DEVICE="iPad Pro 13-inch (M4)" ./scripts/capture-screenshots.shStep 4: XcodeGen configuration
If you use XcodeGen, add the UI test target and a dedicated scheme. The Screenshots scheme makes it easy to run only the screenshot test from the IDE or CI, separate from your regular unit tests.
targets:
MyApp:
type: application
platform: iOS
# ...your app target config
MyAppUITests:
type: bundle.ui-testing
platform: iOS
sources:
- path: MyAppUITests
excludes:
- "**/.gitkeep"
dependencies:
- target: MyApp
settings:
base:
SWIFT_VERSION: "5.10"
GENERATE_INFOPLIST_FILE: YES
schemes:
Screenshots:
build:
targets:
MyApp: all
MyAppUITests: [test]
run:
config: Debug
test:
config: Debug
targets:
- MyAppUITestsRun xcodegen generate after editing the spec and the project file picks up the new target. For a refresher on the SwiftUI side of the app the test target builds, see our guide to getting started with SwiftUI.
XCUITest vs other approaches
There are three other ways to automate App Store screenshots. Each fits a different team shape:
| Approach | Setup time | Locales | Maintenance | Where it fits |
|---|---|---|---|---|
| XCUITest (this guide) | Afternoon | First-class via launch args | Lives in your repo, no external service | Indie devs and small teams who want full control |
| fastlane snapshot | A few hours | Supported via fastlane locales | Adds a Ruby dependency chain | Teams already using fastlane for deploy |
| Xcode Cloud screenshots | Minutes if already on Xcode Cloud | Single locale per workflow | Locked into Xcode Cloud | Teams running CI in Xcode Cloud already |
| Manual | Zero setup, hours per release | One human per locale | Every release pays the tax | Apps with no localization or release cadence |
The XCUITest approach is the most portable. It lives in your project, requires no third-party tooling beyond Xcode itself, runs anywhere you can run xcodebuild, and gives you direct control over every step. The fastlane snapshot approach is a thin wrapper around XCUITest that adds Ruby tooling: if you are already invested in fastlane for deploy, it is a reasonable choice. Otherwise the saved code is small and the dependency is real.
Validate the screens before you automate them
The pipeline above gives you leverage on the screens you have already picked. Picking the right screens to feature is a separate question, and one that costs nothing in code but everything in conversion rate.
After shipping a few iOS apps, the screens that convert are rarely the ones you initially thought would. The screen the founder is proudest of is usually not the one that closes the install. App Store screenshots act as the first impression in the funnel, and the wrong choice of featured screens compounds across every market.
A useful step before you commit a day to building the automation pipeline is to collect signal from real users about which screens they expect to see featured. Tools like Lighthouse pair a waitlist signup form with built-in surveys and a feedback inbox, so you can ask the people who already opted in to your launch which screens they would have wanted to see. The same flow stays useful after launch: the feedback inbox catches the "I wish you had shown the [X] screen" comments that would otherwise live in your App Store reviews.
The pipeline becomes leverage on the right screens, not all of them.
Common pitfalls and how to fix them
The XCUITest approach has predictable failure modes. Most of them show up on the first run and have one-line fixes.
| Problem | Cause | Fix |
|---|---|---|
| System alert covers screenshot | A permission dialog (notifications, camera, microphone) appears mid-test | Add addUIInterruptionMonitor with localized "Allow" labels and pre-navigate to trigger it |
| Localized button not found | Button text changes per language and the test looks for the English string | Use an array of every translation and iterate until one exists |
| Two consecutive screens captured identically | Tab tap fired but the navigation animation had not finished | Add sleep(1) after each tapTab |
| Charts and heatmaps empty | Sample data was not generated or the date range is too narrow | Generate six or more months of historical data with realistic variation |
| Test data leaked to iCloud | The in-memory store was not used and CloudKit synced | Set isStoredInMemoryOnly: true in the screenshot-mode ModelConfiguration |
| Status bar shows random time | The shell script did not override the status bar before the test | Run xcrun simctl status_bar override before invoking xcodebuild test |
| Screenshots cut off at the bottom | Tab bar covers content the test expected to see | Use swipeUp() to scroll content into view, then swipeDown() to reset before capturing |
| Premium UI locked | Paywall gate was not bypassed | Add || isScreenshotMode to every isPremium check |
FAQ
Does this work with SwiftData and CloudKit?
Yes. Define two ModelConfiguration objects, one for production with cloudKitDatabase: .automatic and one for screenshot mode with isStoredInMemoryOnly: true. The in-memory store isolates the screenshot run from CloudKit, prevents test data from leaking to iCloud, and gives every locale a clean slate to seed against. The same pattern works with Core Data if you use an in-memory NSPersistentStoreDescription instead.
How long does a full run take?
Roughly 8 to 15 minutes for nine locales at five to seven screens each on a recent Mac. The biggest cost is the per-locale app launch, not the capture itself. The sleep calls are the second biggest cost. You can shave time by lowering the post-tap sleep to 500ms once you know the app's animations are quick, but be careful not to capture mid-animation.
What about iPad screenshots?
Run the same script against an iPad simulator. Pass the device through the DEVICE environment variable: DEVICE="iPad Pro 13-inch (M4)" ./scripts/capture-screenshots.sh. Tab navigation by index still works because tab indices do not depend on layout. The output folder structure is the same. The only thing you may want to add per device class is a device-specific subfolder so the iPhone and iPad PNGs do not collide.
Should I commit the captured PNGs to git?
No. The output is large and re-generable from the test. Add /tmp/screenshots/ or screenshots/ to .gitignore. Upload directly to App Store Connect via the web UI, via fastlane deliver, or via the App Store Connect API. The captured PNGs are an artifact, not source.
What if a screen depends on a network response?
Mock the network layer in screenshot mode. The cleanest approach is dependency injection at app boot: when isScreenshotMode is true, inject a stub network client that returns canned responses synchronously. This avoids both the latency of real network calls and the flakiness of running screenshots against a live backend.
Can I use this for App Preview videos instead of screenshots?
Not directly. xcrun simctl io booted recordVideo records the simulator, but the XCUITest approach captures stills. The pattern of preparing the simulator (status bar, locale, sample data, screenshot mode) translates cleanly. The capture call is different. Combining both in one shell script is straightforward once each works on its own.
App Store screenshot automation with XCUITest is the kind of investment that pays off slowly and then suddenly. The first run feels like more work than just retaking the screenshots by hand. The third release, when you have rotated the featured screen and want to refresh every locale at once, is when the pipeline earns its place in the project. The setup lives in the repo, runs deterministically, and removes a class of release-day decisions from your process for good.
Spaceport generates production-ready SwiftUI Xcode projects with the boring-but-essential parts of a paid iOS app already wired up, including App Store Connect pricing across 25 markets and Swift Skills context files for tools like Claude Code and Cursor. The screenshot automation pattern in this post fits naturally on top of that foundation. Localized UI, localized pricing, and locale-correct App Store screenshots are the three sides of a properly localized launch. From an indie iOS dev, for indie iOS devs.
