Mastering the iOS Simulator with `simctl`: Push Testing, Screenshots, and CI


You have probably clicked through the Simulator menu to toggle dark mode, or maybe you manually typed in a push notification payload and wished there was a faster way. There is. The simctl command-line tool ships with every Xcode installation and gives you programmatic control over every simulator on your machine — from sending push notifications to recording pixel-perfect App Store screenshots, all without touching the GUI.

This guide covers the simctl subcommands that matter most in day-to-day iOS development and CI/CD pipelines. We will not cover Simulator internals or the CoreSimulator private framework — the focus here is practical, production-ready usage.

Contents

The Problem

Imagine you are building the notification system for a Pixar movie streaming app. Every time a new short drops — say Lava or Bao — your app fires a push notification with a custom image and deep link. Testing this flow typically means provisioning a real device, configuring APNs certificates, running a backend script, and waiting for the push to arrive. That cycle is slow.

And it is not just push. Consider this common screenshot workflow:

# The painful manual approach
# 1. Launch Simulator
# 2. Navigate to the right screen
# 3. Toggle dark mode in Settings
# 4. Change the clock to 9:41 manually... wait, you can't
# 5. Take a screenshot with Cmd+S
# 6. Repeat for 6 device sizes x 2 color schemes = 12 screenshots

Twelve manual screenshots, each requiring you to navigate to the correct screen and hope the status bar looks clean. For a production app with localization, multiply that by every supported language. This does not scale.

simctl solves both problems — and a dozen others — from a single terminal command.

Getting Started with simctl

Every simctl command runs through xcrun, which resolves the active Xcode toolchain. The basic invocation pattern is:

xcrun simctl <subcommand> <device> [arguments]

The <device> argument can be a device UDID, a device name, or the keyword booted to target whichever simulator is currently running. Start by listing available devices:

xcrun simctl list devices available
== Devices ==
-- iOS 18.0 --
    iPhone 16 Pro (A1B2C3D4-...) (Shutdown)
    iPhone 16 Pro Max (E5F6G7H8-...) (Booted)
-- iOS 17.5 --
    iPhone 15 (I9J0K1L2-...) (Shutdown)

You can filter the output to find exactly what you need:

# List only booted devices
xcrun simctl list devices booted

# Get a specific device UDID by name
xcrun simctl list devices available | grep "iPhone 16 Pro Max"

Tip: Pass -j to any simctl list command to get JSON output. This is invaluable for CI scripts that need to parse device information programmatically: xcrun simctl list devices available -j

To create, boot, and shut down simulators programmatically:

# Create a new simulator
xcrun simctl create "PixarScreenshots" \
  "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro" \
  "com.apple.CoreSimulator.SimRuntime.iOS-18-0"

# Boot it
xcrun simctl boot "PixarScreenshots"

# Shut it down when done
xcrun simctl shutdown "PixarScreenshots"

# Delete it entirely
xcrun simctl delete "PixarScreenshots"

Apple Docs: xcrun simctl — Xcode documentation on Simulator runtimes

Push Notification Testing

This is the subcommand that saves the most time. Instead of configuring APNs and running a backend, you can send a push notification directly to a booted simulator with a JSON payload file.

Create a file called pixar-notification.apns:

{
  "Simulator Target Bundle": "com.cocoabytes.PixarTracker",
  "aps": {
    "alert": {
      "title": "New Pixar Short Available!",
      "subtitle": "Inside Out: Riley's First Date?",
      "body": "Watch the latest short now. Joy and Disgust have opinions."
    },
    "badge": 3,
    "sound": "default",
    "category": "NEW_CONTENT"
  },
  "deepLink": "pixartracker://shorts/rileys-first-date"
}

Now send it:

xcrun simctl push booted \
  com.cocoabytes.PixarTracker pixar-notification.apns

The notification appears immediately on the simulator — no APNs certificate, no backend, no device provisioning. If you include the Simulator Target Bundle key inside the JSON (as shown above), you can omit the bundle identifier from the command:

# Bundle ID read from the payload itself
xcrun simctl push booted pixar-notification.apns

Tip: You can test different notification categories, custom actions, and even interruption levels by modifying the payload JSON. This is the fastest way to iterate on your UNNotificationContentExtension or UNNotificationServiceExtension before involving real APNs infrastructure.

Testing Background Push

For silent push notifications that trigger background processing, use the content-available flag:

{
  "Simulator Target Bundle": "com.cocoabytes.PixarTracker",
  "aps": {
    "content-available": 1
  },
  "updateType": "new-movie-added",
  "movieId": "inside-out-3"
}
xcrun simctl push booted pixar-notification.apns

This triggers your application(_:didReceiveRemoteNotification:fetchCompletionHandler:) delegate method, letting you verify that background data sync works without deploying to a device.

App Store Screenshots with Status Bar Overrides

Clean status bar presentation matters for App Store screenshots. No one wants to see “Carrier” at 47% battery with a random time. simctl status_bar override lets you set every status bar element to pixel-perfect values.

xcrun simctl status_bar booted override \
  --time "9:41" \
  --batteryState charged \
  --batteryLevel 100 \
  --wifiBars 3 \
  --cellularBars 4 \
  --cellularMode active \
  --dataNetwork wifi \
  --operatorName ""

After running this command, the simulator status bar shows 9:41, full battery, full signal, and no carrier name — exactly what Apple uses in its own marketing materials.

To capture the screenshot:

xcrun simctl io booted screenshot ~/Desktop/PixarTracker-Home.png

When you are done, clear the override:

xcrun simctl status_bar booted clear

Automating Multi-Device Screenshots

Here is where simctl really shines. Combine device creation, status bar overrides, and screenshot capture into a single script that runs across every required device size:

#!/bin/bash
# screenshot-all-devices.sh
# Captures App Store screenshots for all required device sizes

DEVICES=(
  "iPhone 16 Pro Max"
  "iPhone 16 Pro"
  "iPhone SE (3rd generation)"
  "iPad Pro (13-inch) (M4)"
)

BUNDLE_ID="com.cocoabytes.PixarTracker"
OUTPUT_DIR="$HOME/Desktop/AppStoreScreenshots"
mkdir -p "$OUTPUT_DIR"

for DEVICE in "${DEVICES[@]}"; do
  echo "Capturing screenshots for $DEVICE..."

  # Boot the device
  xcrun simctl boot "$DEVICE" 2>/dev/null

  # Wait for boot to complete
  xcrun simctl bootstatus "$DEVICE"

  # Override status bar
  xcrun simctl status_bar "$DEVICE" override \
    --time "9:41" \
    --batteryState charged \
    --batteryLevel 100 \
    --wifiBars 3 \
    --cellularBars 4

  # Launch the app
  xcrun simctl launch "$DEVICE" "$BUNDLE_ID"

  # Give the app time to render
  sleep 3

  # Capture screenshot
  SAFE_NAME=$(echo "$DEVICE" | tr ' ' '-')
  xcrun simctl io "$DEVICE" screenshot \
    "$OUTPUT_DIR/${SAFE_NAME}-home.png"

  # Clean up
  xcrun simctl status_bar "$DEVICE" clear
  xcrun simctl shutdown "$DEVICE"
done

echo "Screenshots saved to $OUTPUT_DIR"

Warning: xcrun simctl bootstatus blocks until the device finishes booting, but the app itself may need additional time to render its first frame. The sleep 3 is a pragmatic workaround. For CI, consider using xcrun simctl spawn with a health-check script instead of a fixed delay.

Recording Video

Need to record a demo for your PR description or a bug reproduction? simctl can record the simulator screen to a video file:

# Start recording (press Ctrl+C to stop)
xcrun simctl io booted recordVideo \
  ~/Desktop/pixar-tracker-demo.mov

The recording captures at the simulator’s native resolution. You can specify the codec and display:

xcrun simctl io booted recordVideo \
  --codec h264 \
  --display internal \
  --force \
  ~/Desktop/demo.mov

The --force flag overwrites any existing file at that path. For CI pipelines that capture video of test runs, this is essential to avoid failures from file-already-exists errors.

Tip: Combine video recording with xcrun simctl launch and UI test execution to automatically capture video evidence of test failures. Some teams pipe this into their PR review workflow so reviewers can watch the reproduction without setting up the project locally.

Adding Media and Managing Privacy

Adding Photos and Videos

Testing a photo picker or camera roll integration? Instead of dragging files into the simulator window, use addmedia:

# Add a single image
xcrun simctl addmedia booted ~/Assets/woody-portrait.jpg

# Add multiple files at once
xcrun simctl addmedia booted \
  ~/Assets/buzz-lightyear.png \
  ~/Assets/monsters-inc-trailer.mp4 \
  ~/Assets/nemo-album.jpg

The files appear in the Photos app immediately. This is particularly useful for testing image pickers, share extensions, or any feature that reads from the photo library.

Resetting Privacy Permissions

When testing first-launch flows — permission dialogs for camera, location, photos, notifications — you need the app to behave as if it has never been opened. simctl privacy handles this:

# Reset all permissions for your app
xcrun simctl privacy booted reset all \
  com.cocoabytes.PixarTracker

# Reset a specific permission
xcrun simctl privacy booted reset photos \
  com.cocoabytes.PixarTracker
xcrun simctl privacy booted reset location \
  com.cocoabytes.PixarTracker
xcrun simctl privacy booted reset camera \
  com.cocoabytes.PixarTracker

You can also grant or deny permissions without the dialog:

# Grant photo library access silently
xcrun simctl privacy booted grant photos \
  com.cocoabytes.PixarTracker

# Deny location access
xcrun simctl privacy booted revoke location \
  com.cocoabytes.PixarTracker

This is critical for UI tests. Instead of writing fragile code to tap “Allow” on system dialogs, pre-grant the permissions before the test suite runs.

Toggling Appearance

Switch between light and dark mode without opening Settings:

# Switch to dark mode
xcrun simctl ui booted appearance dark

# Switch back to light mode
xcrun simctl ui booted appearance light

Pair this with the screenshot script above to capture both color schemes automatically.

Opening URLs

Test deep links and universal links by opening a URL directly:

xcrun simctl openurl booted \
  "pixartracker://movies/inside-out-2"
xcrun simctl openurl booted \
  "https://cocoabytes.io/movie/coco"

This triggers your app’s URL handling code — onOpenURL in SwiftUI or application(_:open:options:) in UIKit — without needing to tap a link in Safari.

Advanced Usage: CI/CD Scripting

The real power of simctl emerges when you integrate it into CI/CD pipelines. Here are patterns that work in production.

Creating Ephemeral Simulators

Instead of relying on pre-existing simulators (which may have stale state), create fresh ones per CI run:

#!/bin/bash
# ci-test-runner.sh
# Creates a clean simulator, runs tests, captures artifacts

DEVICE_NAME="CI-PixarTracker-$(date +%s)"
RUNTIME="com.apple.CoreSimulator.SimRuntime.iOS-18-0"
DEVICE_TYPE="com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"

# Create and boot
DEVICE_UDID=$(xcrun simctl create \
  "$DEVICE_NAME" "$DEVICE_TYPE" "$RUNTIME")
xcrun simctl boot "$DEVICE_UDID"
xcrun simctl bootstatus "$DEVICE_UDID"

# Pre-grant permissions to avoid dialog interruptions
xcrun simctl privacy "$DEVICE_UDID" grant all \
  com.cocoabytes.PixarTracker

# Run tests
xcodebuild test \
  -scheme PixarTracker \
  -destination "platform=iOS Simulator,id=$DEVICE_UDID" \
  -resultBundlePath ./test-results.xcresult

# Capture a final screenshot for the test report
xcrun simctl io "$DEVICE_UDID" screenshot \
  ./test-final-state.png

# Tear down
xcrun simctl shutdown "$DEVICE_UDID"
xcrun simctl delete "$DEVICE_UDID"

Keychain and App Data Management

Clear app data without reinstalling:

# Uninstall and reinstall for a clean slate
xcrun simctl uninstall booted com.cocoabytes.PixarTracker
xcrun simctl install booted \
  ./Build/Products/Debug-iphonesimulator/PixarTracker.app

# Or terminate and relaunch
xcrun simctl terminate booted com.cocoabytes.PixarTracker
xcrun simctl launch booted com.cocoabytes.PixarTracker

Passing Launch Arguments and Environment Variables

You can pass launch arguments and environment variables when spawning an app, which is useful for toggling feature flags or pointing to staging servers:

xcrun simctl launch booted com.cocoabytes.PixarTracker \
  -EnableDebugOverlay YES \
  -APIEnvironment staging

Your app reads these through ProcessInfo:

// In your app's launch configuration
let isDebugOverlay = ProcessInfo.processInfo
    .arguments.contains("-EnableDebugOverlay")

let apiEnvironment = ProcessInfo.processInfo
    .environment["APIEnvironment"] ?? "production"

Erasing a Simulator

When you need a truly clean slate — no apps, no data, no settings:

xcrun simctl erase booted

Warning: erase resets the simulator to factory defaults. Every app, photo, and setting is deleted. This is irreversible. Use it deliberately in CI teardown scripts, not on your daily-driver simulator.

Performance Considerations

simctl commands are generally fast — most complete in under a second. However, there are a few operations where performance matters:

Boot time is the biggest bottleneck. A cold boot takes 10-30 seconds depending on the runtime version and host machine. On CI, prefer keeping a simulator booted across test suites rather than creating and destroying one per test target.

bootstatus blocks until SpringBoard is ready. Always use it after boot in scripts — without it, subsequent commands may fail silently because the runtime has not finished initializing.

Screenshot capture is near-instant, but video recording adds CPU overhead. On CI machines with limited resources, recording video during test execution can slow down tests by 5-15%. Record only when investigating failures, not as a default.

Parallel simulators are supported but resource-intensive. Each booted simulator consumes 1-2 GB of RAM. On a 16 GB CI machine, running more than four simulators concurrently risks memory pressure and flaky tests. The sweet spot for most teams is 2-3 parallel simulators.

Tip: Use xcrun simctl list devices booted at the start of your CI script to detect leftover simulators from previous failed runs. Shut them down before starting fresh to avoid resource contention.

When to Use (and When Not To)

ScenarioRecommendation
Push notificationsUse simctl push. No APNs config needed.
App Store screenshotsstatus_bar override + io screenshot.
CI/CD test pipelinesEphemeral simulators via simctl create.
Bug reproductionssimctl io recordVideo for .mov capture.
Deep linkssimctl openurl. Faster than Safari.
On-device performanceDo not use the simulator. Use a device.
Hardware sensorsDo not use the simulator. Not emulated.
Load testingDo not use simctl. Single-user only.

Summary

  • xcrun simctl push sends push notifications to the simulator instantly, bypassing APNs entirely — use it for every notification-related feature.
  • xcrun simctl status_bar override gives you pixel-perfect control over the status bar for App Store screenshots. Combine it with io screenshot and a shell loop to automate every required device size and color scheme.
  • xcrun simctl privacy grants, revokes, and resets permissions programmatically, eliminating flaky system dialogs in UI tests.
  • xcrun simctl io recordVideo captures screen recordings for bug reproductions and PR reviews.
  • In CI/CD, create ephemeral simulators with simctl create, pre-grant permissions, run tests, capture artifacts, and delete the simulator. Clean state on every run eliminates an entire category of flaky failures.

The simulator is a development tool, not a performance benchmark. For anything involving real-world hardware characteristics — CPU thermals, GPU performance, sensor data — you still need a physical device. But for the 90% of development workflow that involves building, testing, and capturing screenshots, simctl is the most underused tool in your Xcode installation.

Want to go deeper into Xcode tooling? Check out Xcode 26: The Features That Change How You Work for the latest IDE capabilities, or TestFlight Beyond the Basics to automate your beta distribution pipeline.