
Qa Testing Ios
- 174 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
Helps with testing & qa tasks.
About
qa-testing-ios is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- qa-testing-ios
- Testing & QA
- AI-coding skill
Qa Testing Ios by the numbers
- 174 all-time installs (skills.sh)
- +10 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #841 of 2,152 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill qa-testing-iosAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 174 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
What it does
Helps with testing & qa tasks.
Files
QA Testing (iOS)
Use xcodebuild + Xcode Simulator (simctl) to build, run, and stabilize iOS tests.
Primary docs: XCTest, Swift Testing, simctl, Xcode testing
Inputs to Confirm
- Xcode entrypoint:
-workspaceor-project -scheme(and optional-testPlan)- Destination(s): simulator name + iOS runtime (or
OS=latest), and whether real devices are required - UI-test hooks: launch arguments/env toggles (stubs, demo data, auth bypass, disable animations)
- Artifact needs:
xcresult, coverage, screenshots/video, logs
Quick Commands
| Task | Command |
|---|---|
| List schemes | xcodebuild -list -workspace MyApp.xcworkspace |
| List simulators | xcrun simctl list devices |
| List devices (USB) | xcrun xctrace list devices |
| Boot simulator | xcrun simctl boot "iPhone 15 Pro" |
| Wait for boot | xcrun simctl bootstatus booted -b |
| Build app | xcodebuild build -scheme MyApp -sdk iphonesimulator |
| Install app | xcrun simctl install booted app.app |
| Run tests | xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=latest' -resultBundlePath TestResults.xcresult |
| Run tests (device) | xcodebuild test -scheme MyApp -destination 'platform=iOS,id=<UDID>' -resultBundlePath TestResults.xcresult |
| Reset simulators | xcrun simctl shutdown all && xcrun simctl erase all |
| Take screenshot | xcrun simctl io booted screenshot screenshot.png |
| Record video | xcrun simctl io booted recordVideo recording.mov |
Workflow
1. Resolve build inputs (workspace/project, scheme, testPlan, destinations). 2. Make simulator state repeatable: shutdown/erase as needed, boot, and wait for boot. 3. Run tests with artifacts enabled (-resultBundlePath); parallelize and retry only when appropriate. 4. Triage failures from the xcresult bundle; confirm flakes with repetition; quarantine with an owner and reproduction steps.
xcodebuild Patterns
- Select tests to reproduce:
-only-testing:TargetTests/ClassName/testMethodand-skip-testing:TargetTests/FlakyClass. - Prefer test plans for large suites:
-testPlan <plan>(keeps device/config/runs consistent). - Enable parallel testing when suites are isolation-safe:
-parallel-testing-enabled YES(+-maximum-parallel-testing-workers N). - Always write a result bundle in automation:
-resultBundlePath TestResults.xcresult. - For reruns, split build and test:
xcodebuild build-for-testing ...thenxcodebuild test-without-building .... - Inspect results locally:
open TestResults.xcresultorxcrun xcresulttool get --path TestResults.xcresult --format json.
Flake Triage (Repetition and Retry)
- Prefer repetition to prove flake rate before adding retries.
- Use targeted reruns before suite-wide retries.
Common patterns (flags vary by Xcode version):
- Retry failing tests once in CI:
-retry-tests-on-failure -test-iterations 2 - Measure flakiness until first failure:
-test-iterations 50 -test-repetition-mode until-failure - Run a single test repeatedly:
-only-testing:TargetTests/ClassName/testMethod -test-iterations 20
Testing Layers
| Layer | Framework | Scope |
|---|---|---|
| Unit | XCTest / Swift Testing | Business logic (fast) |
| Snapshot | XCTest + snapshot libs | View rendering |
| Integration | XCTest | Persistence, networking |
| UI | XCUITest | Critical user journeys |
Device Matrix
- Default: simulators for PR gates; real devices for release
- Cover: one small phone, one large phone, iPad if supported
- Add OS versions only for multiple major release support
Flake Control
Use these defaults unless the project requires otherwise:
- Disable or reduce animations in UI-test builds.
- Fix locale/timezone (via launch arguments or app-level configuration).
- Stub network at the boundary (avoid real third-party calls in UI tests).
- Reset app state between tests (fresh install, deep-link reset, or explicit teardown).
- Prefer state-based waits (
waitForExistence, expectations) over sleeps. - Pre-grant/reset permissions where possible (simulators):
xcrun simctl privacy booted grant ....
CI Integration (GitHub Actions)
name: iOS CI
on: [push, pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "16.0"
- run: |
set -euo pipefail
xcodebuild test \
-scheme MyApp \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=latest' \
-resultBundlePath TestResults.xcresult
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: TestResults.xcresultDo / Avoid
Do
- Make UI tests independent and idempotent
- Use test data builders and dedicated test accounts
- Collect
xcresultbundles on failure - Use accessibilityIdentifier, not labels
Avoid
- Relying on test ordering or global state
- UI tests requiring real network
- Thread.sleep() for synchronization
- Accepting AI-proposed selectors without validation
Resources
| Resource | Purpose |
|---|---|
| references/swift-testing.md | Swift Testing framework |
| references/simulator-commands.md | Complete simctl reference |
| references/xctest-patterns.md | XCTest/XCUITest patterns |
| references/xcuitest-patterns.md | XCUITest UI testing patterns |
| references/snapshot-testing-ios.md | Visual snapshot testing |
| references/ios-ci-optimization.md | CI pipeline optimization |
Templates
| Template | Purpose |
|---|---|
| assets/template-ios-ui-test-stability-checklist.md | Stability checklist |
Related Skills
| Skill | Purpose |
|---|---|
| software-mobile | iOS development |
| qa-testing-strategy | Test strategy |
| qa-testing-mobile | Cross-platform mobile |
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
iOS UI Test Stability Checklist (Isolation, Determinism, Device Matrix)
Use this checklist when adding or reviewing XCUITest suites.
Core
Scope and Layering
- [ ] UI test protects a critical user journey (thin E2E); lower-layer tests cover most logic.
- [ ] Test name states user intent and expected outcome.
Device Matrix
- [ ] Simulator matrix is risk-based and small (1-2 iPhones + iPad if needed).
- [ ] Real-device runs exist for nightly/release confidence.
Determinism (Flake Control)
- [ ] Animations reduced/disabled in test configuration where possible.
- [ ] Locale/timezone fixed (no “works on my locale”).
- [ ] Time control: avoid wall-clock dependencies; use injectable clocks where possible.
- [ ] Network: avoid real third-party calls in UI tests; stub boundaries.
- [ ] Permissions: predictable permission state (camera/photos/notifications/location).
Isolation
- [ ] Tests are order-independent and parallel-safe where applicable.
- [ ] App state reset between tests (fresh install or explicit reset flow).
- [ ] No shared accounts/tenants unless isolated by test-run namespace.
Test Data
- [ ] Data builders/factories exist for required accounts and content.
- [ ] Cleanup is deterministic (no leaking records into the next test run).
CI Ergonomics
- [ ]
xcodebuild testwrites anxcresultbundle (-resultBundlePath TestResults.xcresult) for artifacts. - [ ] Failing tests capture screenshots/logs and attach to the
xcresultbundle. - [ ] Simulator setup is automated (boot/erase as needed via
simctl: https://developer.apple.com/documentation/xcode/simctl).
Debugging Ergonomics
- [ ] Failure output includes: scheme, destination, device, OS, and
xcresultlocation. - [ ] Rerun policy is explicit: rerun-pass tests are treated as flakes with a ticket and owner.
Optional: AI / Automation
Do:
- Use AI to expand test ideas from user journeys and failure modes; automate only deterministic cases.
- Use AI to summarize
xcresultoutput and cluster failures; verify by reproducing with controlled conditions.
Avoid:
- Generating UI tests that rely on sleeps/timing rather than state-based assertions.
{
"metadata": {
"skill": "qa-testing-ios",
"updated": "2026-01-26",
"version": "2.3",
"total_sources": 8,
"description": "Primary references for iOS testing automation with Swift Testing (Testing module), XCTest/XCUITest, and simulator tooling."
},
"categories": {
"apple_official_docs": [
{
"name": "Swift Testing Documentation",
"url": "https://developer.apple.com/documentation/testing",
"description": "Official Swift Testing framework docs (Testing module; modern unit/integration testing).",
"add_as_web_search": true,
"optional": false
},
{
"name": "Swift Testing Overview",
"url": "https://developer.apple.com/xcode/swift-testing",
"description": "Apple's Swift Testing product page with feature overview.",
"add_as_web_search": true,
"optional": false
},
{
"name": "XCTest Documentation",
"url": "https://developer.apple.com/documentation/xctest",
"description": "Official XCTest framework docs (UI testing, performance testing).",
"add_as_web_search": true,
"optional": false
},
{
"name": "simctl Documentation",
"url": "https://developer.apple.com/documentation/xcode/simctl",
"description": "Official `simctl` reference for simulator automation.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Testing Your Apps in Xcode",
"url": "https://developer.apple.com/documentation/xcode/testing-your-apps-in-xcode",
"description": "Apple guidance on organizing and running tests in Xcode.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Xcodebuild Technote (TN2339)",
"url": "https://developer.apple.com/library/archive/technotes/tn2339/_index.html",
"description": "Command-line build and test reference patterns for `xcodebuild`.",
"add_as_web_search": true,
"optional": false
},
{
"name": "WWDC Testing Videos",
"url": "https://developer.apple.com/videos/testing",
"description": "Official Apple videos on testing practices and tooling updates.",
"add_as_web_search": true,
"optional": false
}
],
"optional_ai_automation": [
{
"name": "NIST AI Risk Management Framework",
"url": "https://www.nist.gov/itl/ai-risk-management-framework",
"description": "Optional governance baseline when using AI to expand test cases or triage failures.",
"add_as_web_search": true,
"optional": true
}
]
}
}
iOS CI Optimization
Build and test pipeline optimization for iOS projects in continuous integration.
Contents
- Xcode Build Caching
- Test Parallelization
- Test Sharding Across CI Nodes
- Simulator Management
- CI Provider Comparison
- xcresult Bundle Processing
- Test Result Merging
- Fastlane Integration
- Code Signing in CI
- Build Time Optimization
- Test Selection
- Related Resources
---
Xcode Build Caching
DerivedData Caching
# GitHub Actions: cache DerivedData
- name: Cache DerivedData
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData
key: deriveddata-${{ runner.os }}-${{ hashFiles('**/*.xcodeproj/project.pbxproj', '**/Package.resolved') }}
restore-keys: |
deriveddata-${{ runner.os }}-SPM Cache
# Cache Swift Package Manager resolved packages
- name: Cache SPM
uses: actions/cache@v4
with:
path: |
~/Library/Caches/org.swift.swiftpm
.build
key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-CocoaPods Cache
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
pods-${{ runner.os }}-
- name: Install pods
run: |
if [ ! -d "Pods" ]; then
pod install
fiIncremental Builds
# Build for testing (compiles without running)
xcodebuild build-for-testing \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-derivedDataPath ./DerivedData
# Run tests using pre-built artifacts
xcodebuild test-without-building \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-derivedDataPath ./DerivedData---
Test Parallelization
xcodebuild Parallel Testing
# Enable parallel testing (distributes test classes across simulators)
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-parallel-testing-enabled YES \
-parallel-testing-worker-count 4 \
-resultBundlePath TestResults.xcresultTest Plan Configuration
{
"configurations": [
{
"name": "Default",
"options": {
"targetForVariableExpansion": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "MyAppTests",
"name": "MyAppTests"
},
"maximumTestExecutionTimeAllowance": 60,
"testExecutionOrdering": "random",
"testTimeoutsEnabled": true
}
}
],
"defaultOptions": {
"isParallelizable": true,
"maximumParallelTestWorkerCount": 4
}
}Parallel Testing Strategies
| Strategy | How It Works | Best For |
|---|---|---|
| Class-level | Each test class on a different sim | Mixed test durations |
| Target-level | Each test target on a different sim | Multi-module projects |
| Manual sharding | Split by test plan | Fine-grained control |
---
Test Sharding Across CI Nodes
Manual Sharding with Test Plans
# Shard 1: Unit tests
xcodebuild test \
-scheme MyApp \
-testPlan UnitTests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath UnitResults.xcresult
# Shard 2: Integration tests
xcodebuild test \
-scheme MyApp \
-testPlan IntegrationTests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath IntegrationResults.xcresult
# Shard 3: UI tests
xcodebuild test \
-scheme MyApp \
-testPlan UITests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath UIResults.xcresultGitHub Actions Matrix Sharding
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- shard: unit
test-plan: UnitTests
- shard: integration
test-plan: IntegrationTests
- shard: ui-1
only-testing: MyAppUITests/LoginFlowTests
- shard: ui-2
only-testing: MyAppUITests/CheckoutFlowTests
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Run tests
run: |
ARGS="-scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'"
if [ -n "${{ matrix.test-plan }}" ]; then
ARGS="$ARGS -testPlan ${{ matrix.test-plan }}"
fi
if [ -n "${{ matrix.only-testing }}" ]; then
ARGS="$ARGS -only-testing:${{ matrix.only-testing }}"
fi
eval xcodebuild test $ARGS \
-resultBundlePath "Results-${{ matrix.shard }}.xcresult"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: results-${{ matrix.shard }}
path: "Results-${{ matrix.shard }}.xcresult"---
Simulator Management
Boot and Clone Simulators
# List available simulators
xcrun simctl list devices available
# Boot a specific simulator
xcrun simctl boot "iPhone 15"
# Clone a simulator for parallel testing
xcrun simctl clone "iPhone 15" "iPhone 15 - Worker 1"
xcrun simctl clone "iPhone 15" "iPhone 15 - Worker 2"
xcrun simctl clone "iPhone 15" "iPhone 15 - Worker 3"
# Boot clones in parallel
xcrun simctl boot "iPhone 15 - Worker 1" &
xcrun simctl boot "iPhone 15 - Worker 2" &
xcrun simctl boot "iPhone 15 - Worker 3" &
waitSimulator Lifecycle in CI
#!/bin/bash
set -euo pipefail
DEVICE_NAME="CI-iPhone15"
RUNTIME="iOS-18-0"
DEVICE_TYPE="iPhone 15"
# Create simulator
UDID=$(xcrun simctl create "$DEVICE_NAME" "$DEVICE_TYPE" "$RUNTIME")
# Boot
xcrun simctl boot "$UDID"
# Wait for boot
xcrun simctl bootstatus "$UDID" -b
# Disable keyboard autocorrect and autocapitalization
xcrun simctl spawn "$UDID" defaults write \
com.apple.Preferences KeyboardAutocorrection -bool NO
xcrun simctl spawn "$UDID" defaults write \
com.apple.Preferences KeyboardAutocapitalization -bool NO
# Run tests
xcodebuild test \
-scheme MyApp \
-destination "platform=iOS Simulator,id=$UDID" \
-resultBundlePath TestResults.xcresult
# Cleanup
xcrun simctl shutdown "$UDID"
xcrun simctl delete "$UDID"Parallel Simulator Strategy
# Create and boot 4 parallel simulators
WORKERS=4
PIDS=()
for i in $(seq 1 $WORKERS); do
UDID=$(xcrun simctl create "Worker-$i" "iPhone 15" "iOS-18-0")
xcrun simctl boot "$UDID" &
PIDS+=($!)
done
# Wait for all boots
for pid in "${PIDS[@]}"; do wait "$pid"; done
# Run tests distributed across workers
xcodebuild test \
-scheme MyApp \
-parallel-testing-enabled YES \
-parallel-testing-worker-count $WORKERS \
-destination 'platform=iOS Simulator,name=Worker-1' \
-destination 'platform=iOS Simulator,name=Worker-2' \
-destination 'platform=iOS Simulator,name=Worker-3' \
-destination 'platform=iOS Simulator,name=Worker-4'---
CI Provider Comparison
| Feature | Xcode Cloud | GitHub Actions | CircleCI |
|---|---|---|---|
| macOS runners | Included | macos-14 | macOS resource class |
| Xcode pre-installed | Latest + recent | Multiple versions | Via orb/image |
| Simulator caching | Automatic | Manual | Manual |
| Code signing | App Store Connect | Manual/fastlane | Manual/fastlane |
| Build minutes (free) | 25 hrs/month | 2000 min/month (10x) | 6000 min/month |
| Parallelism | Limited | Up to 20 parallel | Depends on plan |
| Xcode integration | Native | Via CLI | Via CLI |
| Artifact storage | App Store Connect | 500MB-50GB | 30 days |
Xcode Cloud Configuration
# ci_scripts/ci_post_clone.sh
#!/bin/bash
set -euo pipefail
# Install dependencies
if [ -f "Podfile.lock" ]; then
pod install
fi
# ci_scripts/ci_pre_xcodebuild.sh
#!/bin/bash
# Pre-build steps (environment setup)
export API_BASE_URL="https://staging.example.com"GitHub Actions Recommended Config
name: iOS Tests
on:
pull_request:
paths:
- '**/*.swift'
- '**/*.xib'
- '**/*.storyboard'
- '**/project.pbxproj'
jobs:
test:
runs-on: macos-14
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-${{ runner.os }}-${{ hashFiles('**/Package.resolved', '**/project.pbxproj') }}
- name: Build and test
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0' \
-parallel-testing-enabled YES \
-resultBundlePath TestResults.xcresult \
| xcbeautify
- name: Publish test results
if: always()
uses: dorny/test-reporter@v1
with:
name: iOS Tests
path: TestResults.xcresult
reporter: xcode---
xcresult Bundle Processing
Extract Test Results
# Summary
xcrun xcresulttool get --path TestResults.xcresult --format json
# Test failures
xcrun xcresulttool get test-results summary \
--path TestResults.xcresult \
--format json
# Export for CI reporting
xcrun xcresulttool export \
--path TestResults.xcresult \
--output-path ./exported-results \
--type fileConvert to JUnit XML
# Using xcbeautify (recommended)
xcodebuild test ... | xcbeautify --report junit --report-path results.xml
# Using xcresultparser
xcresultparser -o junit TestResults.xcresult > results.xmlExtract Failure Screenshots
# xcresulttool can export attachments
xcrun xcresulttool export \
--path TestResults.xcresult \
--output-path ./test-attachments \
--type attachments---
Test Result Merging
Merging Results from Shards
# Use xcresulttool merge (Xcode 16+)
xcrun xcresulttool merge \
Results-unit.xcresult \
Results-integration.xcresult \
Results-ui.xcresult \
--output-path MergedResults.xcresultCustom Merge Script for JUnit XML
#!/bin/bash
# Merge JUnit XML files from multiple shards
OUTPUT="merged-results.xml"
echo '<?xml version="1.0" encoding="UTF-8"?>' > "$OUTPUT"
echo '<testsuites>' >> "$OUTPUT"
for file in results-*.xml; do
# Extract testsuites content (skip xml declaration and root tags)
sed -n '/<testsuite /,/<\/testsuite>/p' "$file" >> "$OUTPUT"
done
echo '</testsuites>' >> "$OUTPUT"
echo "Merged $(ls results-*.xml | wc -l) result files into $OUTPUT"---
Fastlane Integration
Scanfile Configuration
# fastlane/Scanfile
scheme("MyApp")
device("iPhone 15")
clean(false)
code_coverage(true)
output_directory("./fastlane/test_output")
result_bundle(true)
parallel_testing(true)
concurrent_workers(4)Fastlane Scan Action
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run all tests"
lane :test do
scan(
scheme: "MyApp",
device: "iPhone 15 (18.0)",
result_bundle: true,
output_types: "junit,html",
parallel_testing: true,
concurrent_workers: 4,
fail_build: true,
)
end
desc "Run unit tests only"
lane :unit_test do
scan(
scheme: "MyApp",
testplan: "UnitTests",
device: "iPhone 15 (18.0)",
result_bundle: true,
)
end
desc "Run UI tests with retry"
lane :ui_test do
scan(
scheme: "MyApp",
testplan: "UITests",
device: "iPhone 15 (18.0)",
result_bundle: true,
number_of_retries: 2, # retry flaky tests
)
end
endCI with Fastlane
- name: Run tests via fastlane
run: bundle exec fastlane test
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120---
Code Signing in CI
Automatic Signing Disabled for Tests
# For running tests, code signing is typically not needed
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NOFastlane Match for Distribution Builds
# fastlane/Matchfile
type("development")
app_identifier("com.example.myapp")
git_url("https://github.com/org/certificates.git")
storage_mode("git")
# In Fastfile
lane :build_for_testing do
match(type: "development", readonly: true)
gym(
scheme: "MyApp",
export_method: "development",
skip_codesigning: false,
)
endKeychain Management in CI
# Create a temporary keychain for CI
KEYCHAIN_NAME="ci-keychain"
KEYCHAIN_PASSWORD="ci-password"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 3600 "$KEYCHAIN_NAME"
# Import certificates
security import cert.p12 \
-k "$KEYCHAIN_NAME" \
-P "$CERT_PASSWORD" \
-A -T /usr/bin/codesign
# Allow codesign access
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"---
Build Time Optimization
Modular Builds
Monolith (slow):
MyApp (all source) → 8 min build
Modular (fast):
Core → 1 min (cached)
Networking → 1 min (cached)
Feature-Auth → 1 min
Feature-Home → 1 min
MyApp (thin) → 30 sec
Total: ~2 min (with cache hits)Explicit Modules
# Enable explicit module builds for faster compilation
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-enableExplicitModules YESBuild Settings for CI
# Optimize for CI speed
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
COMPILER_INDEX_STORE_ENABLE=NO \
DEBUG_INFORMATION_FORMAT=dwarf \
ONLY_ACTIVE_ARCH=YES \
ENABLE_TESTABILITY=YESBuild Time Budgets
| Phase | Target | Optimization Lever |
|---|---|---|
| Checkout + cache | < 1 min | Shallow clone, cache restore |
| Dependency resolution | < 1 min | Cache SPM/CocoaPods |
| Compilation | < 5 min | Modular builds, DerivedData cache |
| Simulator boot | < 15 sec | Pre-booted sim, clone strategy |
| Unit tests | < 3 min | Parallel forks |
| UI tests | < 10 min | Sharding across 4+ simulators |
| Artifact upload | < 1 min | Compress, selective upload |
| Total pipeline | < 20 min | All of the above |
---
Test Selection
Only Run Changed Targets
#!/bin/bash
# Detect changed Swift files and run affected test targets
CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- '*.swift')
TARGETS_TO_TEST=()
for file in $CHANGED_FILES; do
# Map source file to test target
if [[ "$file" == *"Feature/Auth"* ]]; then
TARGETS_TO_TEST+=("FeatureAuthTests")
elif [[ "$file" == *"Feature/Home"* ]]; then
TARGETS_TO_TEST+=("FeatureHomeTests")
elif [[ "$file" == *"Core/"* ]]; then
# Core changes require all tests
TARGETS_TO_TEST=("ALL")
break
fi
done
# Deduplicate
UNIQUE_TARGETS=($(echo "${TARGETS_TO_TEST[@]}" | tr ' ' '\n' | sort -u))
if [[ "${UNIQUE_TARGETS[0]}" == "ALL" ]]; then
xcodebuild test -scheme MyApp -destination '...'
else
ONLY_TESTING=""
for target in "${UNIQUE_TARGETS[@]}"; do
ONLY_TESTING="$ONLY_TESTING -only-testing:$target"
done
eval xcodebuild test -scheme MyApp -destination '...' $ONLY_TESTING
fiSkip/Only Testing Flags
# Run only specific test targets
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:MyAppTests/AuthTests \
-only-testing:MyAppTests/PaymentTests
# Skip slow or flaky tests
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-skip-testing:MyAppUITests/PerformanceTests \
-skip-testing:MyAppUITests/FlakyNetworkTestsChecklist -- iOS CI Optimization:
- [ ] DerivedData cached between CI runs
- [ ] SPM/CocoaPods dependencies cached
- [ ] Parallel testing enabled with 4+ workers
- [ ] Test sharding across CI matrix for large suites
- [ ] Simulator pre-booted or cloned for parallel execution
- [ ] xcresult bundles uploaded as artifacts
- [ ] JUnit XML generated for CI reporting
- [ ] Code signing disabled for test-only pipelines
- [ ] Build time budget defined and tracked
- [ ] Test selection skips unaffected targets on PRs
- [ ] Flaky tests retried or quarantined separately
---
Related Resources
- xctest-patterns.md -- XCTest testing patterns
- xcuitest-patterns.md -- XCUITest UI testing
- snapshot-testing-ios.md -- Visual snapshot CI integration
- swift-testing.md -- Modern Swift Testing framework
- simulator-commands.md -- Simulator lifecycle management
iOS Simulator Commands Reference
Complete simctl command reference for iOS simulator automation.
Contents
- Device Management
- App Management
- I/O Operations
- Device State
- Push Notifications
- Media and Files
- Diagnostics
- Scripting Patterns
- Environment Variables
- Related
---
Device Management
List Devices
# All devices with runtimes
xcrun simctl list devices
# Available runtimes only
xcrun simctl list runtimes
# Booted devices only
xcrun simctl list devices | grep "Booted"
# Device types
xcrun simctl list devicetypes
# JSON output (for scripting)
xcrun simctl list devices --jsonBoot and Shutdown
# Boot by name
xcrun simctl boot "iPhone 15 Pro"
# Boot by UDID
xcrun simctl boot 12345678-ABCD-1234-ABCD-123456789ABC
# Shutdown specific device
xcrun simctl shutdown "iPhone 15 Pro"
# Shutdown all
xcrun simctl shutdown all
# Erase (factory reset)
xcrun simctl erase "iPhone 15 Pro"
# Erase all
xcrun simctl erase allCreate and Delete
# Create new simulator
xcrun simctl create "My Test iPhone" "iPhone 15" "iOS-17-2"
# Clone existing
xcrun simctl clone "iPhone 15 Pro" "iPhone 15 Pro Clone"
# Delete simulator
xcrun simctl delete "My Test iPhone"
# Delete unavailable (cleanup)
xcrun simctl delete unavailable---
App Management
Install and Uninstall
# Install app bundle
xcrun simctl install booted /path/to/MyApp.app
# Install on specific device
xcrun simctl install "iPhone 15" /path/to/MyApp.app
# Uninstall by bundle ID
xcrun simctl uninstall booted com.example.myapp
# Get app container path
xcrun simctl get_app_container booted com.example.myappLaunch and Terminate
# Launch app
xcrun simctl launch booted com.example.myapp
# Launch with arguments
xcrun simctl launch booted com.example.myapp --argument1 --argument2
# Launch and wait for debugger
xcrun simctl launch --wait-for-debugger booted com.example.myapp
# Launch with console output
xcrun simctl launch --console booted com.example.myapp
# Terminate app
xcrun simctl terminate booted com.example.myapp---
I/O Operations
Screenshots
# PNG screenshot
xcrun simctl io booted screenshot screenshot.png
# JPEG screenshot
xcrun simctl io booted screenshot --type=jpeg screenshot.jpg
# Specific device
xcrun simctl io "iPhone 15 Pro" screenshot home.png
# With mask (device frame)
xcrun simctl io booted screenshot --mask=black screenshot.pngVideo Recording
# Start recording (Ctrl+C to stop)
xcrun simctl io booted recordVideo recording.mov
# With codec
xcrun simctl io booted recordVideo --codec=h264 recording.mp4
# Force overwrite
xcrun simctl io booted recordVideo --force recording.movTouch Input
# Tap at coordinates
xcrun simctl io booted tap 200 400
# Swipe (x1 y1 x2 y2)
xcrun simctl io booted swipe 100 500 100 200
# Type text
xcrun simctl io booted type "Hello World"
# Paste from clipboard
xcrun simctl io booted paste
# Press home button
xcrun simctl io booted home---
Device State
Location
# Set GPS coordinates
xcrun simctl location booted set 37.7749,-122.4194
# Set with scenario
xcrun simctl location booted set --scenario=freeway
# Clear location
xcrun simctl location booted clear
# Available scenarios: none, freeway, city, hikingPrivacy Permissions
# Grant permission
xcrun simctl privacy booted grant photos com.example.myapp
# Revoke permission
xcrun simctl privacy booted revoke camera com.example.myapp
# Reset all permissions
xcrun simctl privacy booted reset all com.example.myapp
# Permission types: all, calendar, contacts, location,
# location-always, photos, photos-add, media-library,
# microphone, camera, reminders, siriStatus Bar
# Override status bar
xcrun simctl status_bar booted override \
--time "9:41" \
--batteryState charged \
--batteryLevel 100 \
--cellularMode active \
--cellularBars 4
# Clear overrides
xcrun simctl status_bar booted clear---
Push Notifications
Send Push
# Send from file
xcrun simctl push booted com.example.myapp notification.apns
# Send inline JSON
echo '{"aps":{"alert":"Test"}}' | xcrun simctl push booted com.example.myapp -APNS Payload Examples
// Basic alert
{
"aps": {
"alert": "Hello World"
}
}
// Rich notification
{
"aps": {
"alert": {
"title": "New Message",
"subtitle": "From John",
"body": "Hey, how are you?"
},
"badge": 5,
"sound": "default",
"category": "MESSAGE"
},
"customData": {
"messageId": "12345"
}
}
// Silent push
{
"aps": {
"content-available": 1
}
}---
Media and Files
Add Media
# Add photo
xcrun simctl addmedia booted photo.jpg
# Add video
xcrun simctl addmedia booted video.mp4
# Add multiple
xcrun simctl addmedia booted photo1.jpg photo2.jpg video.mp4Open URL
# Open URL in Safari
xcrun simctl openurl booted "https://example.com"
# Open deep link
xcrun simctl openurl booted "myapp://path/to/screen"
# Open universal link
xcrun simctl openurl booted "https://example.com/app-link"---
Diagnostics
Logs
# Spawn log stream
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.example.myapp"'
# System log
xcrun simctl spawn booted log show --last 1h
# Collect diagnostics
xcrun simctl diagnoseDevice Info
# Get device UDID
xcrun simctl list devices | grep "iPhone 15"
# Boot status
xcrun simctl bootstatus booted
# Environment info
xcrun simctl getenv booted HOME---
Scripting Patterns
Wait for Boot
#!/bin/bash
DEVICE="iPhone 15 Pro"
xcrun simctl boot "$DEVICE"
xcrun simctl bootstatus "$DEVICE" -b
echo "Simulator ready"Batch Screenshot
#!/bin/bash
DEVICES=("iPhone 15" "iPhone 15 Pro" "iPad Pro (12.9-inch)")
for device in "${DEVICES[@]}"; do
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
xcrun simctl io "$device" screenshot "${device// /-}.png"
xcrun simctl shutdown "$device"
doneClean Environment
#!/bin/bash
# Reset all simulators to clean state
xcrun simctl shutdown all
xcrun simctl erase all
echo "All simulators reset"---
Environment Variables
| Variable | Purpose |
|---|---|
SIMCTL_CHILD_* | Pass env vars to simulator |
SIMULATOR_DEVICE_NAME | Current device name |
SIMULATOR_UDID | Current device UDID |
SIMULATOR_RUNTIME_VERSION | iOS version |
# Set env var in simulator
export SIMCTL_CHILD_MY_VAR="value"
xcrun simctl boot "iPhone 15"---
Related
Snapshot Testing for iOS
Visual snapshot testing patterns for iOS applications using swift-snapshot-testing.
Library: swift-snapshot-testing (Point-Free)
Contents
- Setup and Configuration
- Recording vs Verifying
- Snapshot Strategies
- SwiftUI Snapshot Testing
- UIKit Snapshot Testing
- Device Sizes and Orientations
- Dark Mode Testing
- Dynamic Type Testing
- CI Integration
- Handling Diffs and Updating Baselines
- Perceptual Diff Tools
- Flake Prevention
- Related Resources
---
Setup and Configuration
Swift Package Manager
// Package.swift dependency
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"),
// Target dependency
.testTarget(
name: "MyAppTests",
dependencies: [
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
)Xcode Project (SPM)
1. File > Add Package Dependencies
2. URL: https://github.com/pointfreeco/swift-snapshot-testing
3. Add "SnapshotTesting" to your test targetBasic Test Structure
import XCTest
import SnapshotTesting
@testable import MyApp
final class LoginViewSnapshotTests: XCTestCase {
func testDefaultState() {
let view = LoginView(
state: .init(email: "", password: "", isLoading: false)
)
assertSnapshot(of: view, as: .image)
}
}---
Recording vs Verifying
Recording Mode
When a reference image does not exist, the test fails and records a new baseline. You can also force recording.
// Method 1: Set globally (record all snapshots in this test run)
// Useful when first creating tests or after major UI changes
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
// Method 2: Per-assertion recording
func testNewComponent() {
let view = NewFeatureView()
assertSnapshot(of: view, as: .image, record: .all)
}
// Method 3: Environment variable
// Set SNAPSHOT_TESTING_RECORD=all in scheme environment variablesVerify Mode (Default)
func testLoginView() {
let view = LoginView(state: .default)
// Compares against existing reference image
// Fails if reference does not exist (records and fails)
// Fails if pixel differences detected
assertSnapshot(of: view, as: .image)
}Named Snapshots
func testLoginStates() {
let defaultView = LoginView(state: .default)
assertSnapshot(of: defaultView, as: .image, named: "default")
let loadingView = LoginView(state: .loading)
assertSnapshot(of: loadingView, as: .image, named: "loading")
let errorView = LoginView(state: .error("Invalid credentials"))
assertSnapshot(of: errorView, as: .image, named: "error")
}Reference images are stored at:
__Snapshots__/
LoginViewSnapshotTests/
testLoginStates.default.png
testLoginStates.loading.png
testLoginStates.error.png---
Snapshot Strategies
Image Strategy (Most Common)
// Default image snapshot
assertSnapshot(of: view, as: .image)
// With specific size
assertSnapshot(of: view, as: .image(size: CGSize(width: 375, height: 667)))
// With traits (device traits)
assertSnapshot(of: view, as: .image(
traits: UITraitCollection(userInterfaceStyle: .dark)
))Recursive Description Strategy
Captures the view hierarchy as text. Useful for structural testing without pixel sensitivity.
func testViewHierarchy() {
let vc = LoginViewController()
vc.loadViewIfNeeded()
assertSnapshot(of: vc, as: .recursiveDescription)
}
// Output looks like:
// <UIView; frame = (0 0; 375 667)>
// <UITextField; frame = (20 100; 335 44); text = ''>
// <UITextField; frame = (20 160; 335 44); text = ''>
// <UIButton; frame = (20 220; 335 50); title = 'Log In'>Dump Strategy
Captures the full Swift Mirror dump of an object. Useful for testing non-visual models.
func testUserModel() {
let user = User(
id: "123",
name: "Jane Doe",
email: "jane@example.com",
role: .admin
)
assertSnapshot(of: user, as: .dump)
}Custom Strategy Composition
// Combine multiple strategies in one test
func testLoginViewAllStrategies() {
let vc = LoginViewController()
vc.loadViewIfNeeded()
// Visual appearance
assertSnapshot(of: vc, as: .image, named: "visual")
// View hierarchy structure
assertSnapshot(of: vc, as: .recursiveDescription, named: "hierarchy")
// Accessibility audit
assertSnapshot(of: vc, as: .recursiveDescription(on: .init(
preferredContentSizeCategory: .accessibilityExtraLarge
)), named: "accessibility")
}---
SwiftUI Snapshot Testing
Basic SwiftUI Snapshots
import SwiftUI
import SnapshotTesting
import XCTest
final class ProfileViewTests: XCTestCase {
func testProfileView() {
let view = ProfileView(
user: .preview,
isEditing: false
)
assertSnapshot(
of: view,
as: .image(layout: .device(config: .iPhone13))
)
}
func testProfileEditMode() {
let view = ProfileView(
user: .preview,
isEditing: true
)
assertSnapshot(
of: view,
as: .image(layout: .device(config: .iPhone13)),
named: "editing"
)
}
}Layout Options
// Fixed size
assertSnapshot(of: view, as: .image(layout: .fixed(width: 375, height: 200)))
// Size that fits content
assertSnapshot(of: view, as: .image(layout: .sizeThatFits))
// Device configuration
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
assertSnapshot(of: view, as: .image(layout: .device(config: .iPadPro11)))Testing SwiftUI Previews
// In your app: define Preview providers
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
Group {
ProfileView(user: .preview, isEditing: false)
.previewDisplayName("Default")
ProfileView(user: .preview, isEditing: true)
.previewDisplayName("Editing")
}
}
}
// In test target: snapshot each preview state
final class ProfileViewPreviewTests: XCTestCase {
func testDefault() {
assertSnapshot(
of: ProfileView(user: .preview, isEditing: false),
as: .image(layout: .device(config: .iPhone13)),
named: "default"
)
}
func testEditing() {
assertSnapshot(
of: ProfileView(user: .preview, isEditing: true),
as: .image(layout: .device(config: .iPhone13)),
named: "editing"
)
}
}---
UIKit Snapshot Testing
View Controller Snapshots
func testSettingsViewController() {
let vc = SettingsViewController()
vc.viewModel = SettingsViewModel(
user: .preview,
preferences: .defaultPreferences
)
// Load view hierarchy
vc.loadViewIfNeeded()
assertSnapshot(of: vc, as: .image(on: .iPhone13))
}Individual View Snapshots
func testCustomCard() {
let card = ProductCardView()
card.configure(with: ProductCardViewModel(
title: "Premium Widget",
price: "$29.99",
rating: 4.5,
imageURL: nil // use placeholder
))
// Set intrinsic size
card.frame = CGRect(x: 0, y: 0, width: 343, height: 200)
card.layoutIfNeeded()
assertSnapshot(of: card, as: .image)
}Navigation Controller Snapshots
func testNavigationFlow() {
let vc = OrderDetailViewController()
vc.order = Order.preview
let nav = UINavigationController(rootViewController: vc)
assertSnapshot(of: nav, as: .image(on: .iPhone13))
}---
Device Sizes and Orientations
Multiple Device Sizes
final class ResponsiveLayoutTests: XCTestCase {
let view = DashboardView(state: .preview)
func testIPhoneSE() {
assertSnapshot(of: view,
as: .image(layout: .device(config: .iPhoneSe)),
named: "iPhone-SE")
}
func testIPhone13() {
assertSnapshot(of: view,
as: .image(layout: .device(config: .iPhone13)),
named: "iPhone-13")
}
func testIPhone15ProMax() {
assertSnapshot(of: view,
as: .image(layout: .device(config: .iPhone13ProMax)),
named: "iPhone-15-Pro-Max")
}
func testIPadPro11() {
assertSnapshot(of: view,
as: .image(layout: .device(config: .iPadPro11)),
named: "iPad-Pro-11")
}
}Landscape Orientation
func testLandscape() {
let vc = VideoPlayerViewController()
vc.loadViewIfNeeded()
assertSnapshot(
of: vc,
as: .image(on: .iPhone13(.landscape))
)
}Parameterized Device Testing
final class MultiDeviceTests: XCTestCase {
struct DeviceConfig {
let name: String
let config: ViewImageConfig
}
let devices: [DeviceConfig] = [
.init(name: "iPhone-SE", config: .iPhoneSe),
.init(name: "iPhone-13", config: .iPhone13),
.init(name: "iPhone-13-Pro-Max", config: .iPhone13ProMax),
.init(name: "iPad-Pro-11", config: .iPadPro11),
]
func testOnboardingAcrossDevices() {
let view = OnboardingView(step: .welcome)
for device in devices {
assertSnapshot(
of: view,
as: .image(layout: .device(config: device.config)),
named: device.name
)
}
}
}---
Dark Mode Testing
final class ThemeSnapshotTests: XCTestCase {
func testSettingsLight() {
let view = SettingsView(preferences: .default)
assertSnapshot(
of: view,
as: .image(
layout: .device(config: .iPhone13),
traits: UITraitCollection(userInterfaceStyle: .light)
),
named: "light"
)
}
func testSettingsDark() {
let view = SettingsView(preferences: .default)
assertSnapshot(
of: view,
as: .image(
layout: .device(config: .iPhone13),
traits: UITraitCollection(userInterfaceStyle: .dark)
),
named: "dark"
)
}
}
// Helper for both modes
extension XCTestCase {
func assertSnapshotInBothModes<V: View>(
_ view: V,
config: ViewImageConfig = .iPhone13,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
assertSnapshot(
of: view,
as: .image(
layout: .device(config: config),
traits: .init(userInterfaceStyle: .light)
),
named: "light",
file: file, testName: testName, line: line
)
assertSnapshot(
of: view,
as: .image(
layout: .device(config: config),
traits: .init(userInterfaceStyle: .dark)
),
named: "dark",
file: file, testName: testName, line: line
)
}
}
// Usage
func testProfileCard() {
assertSnapshotInBothModes(ProfileCardView(user: .preview))
}---
Dynamic Type Testing
final class AccessibilitySnapshotTests: XCTestCase {
let contentSizes: [(String, UIContentSizeCategory)] = [
("xs", .extraSmall),
("default", .large),
("xl", .extraLarge),
("xxxl", .accessibilityExtraExtraExtraLarge),
]
func testOrderSummaryDynamicType() {
let view = OrderSummaryView(order: .preview)
for (name, size) in contentSizes {
assertSnapshot(
of: view,
as: .image(
layout: .device(config: .iPhone13),
traits: UITraitCollection(preferredContentSizeCategory: size)
),
named: name
)
}
}
}---
CI Integration
GitHub Actions
name: Snapshot Tests
on: [pull_request]
jobs:
snapshot-tests:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
lfs: true # if snapshots stored in LFS
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Run snapshot tests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0' \
-only-testing:MyAppTests/SnapshotTests \
-resultBundlePath SnapshotResults.xcresult
- name: Upload failure diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshot-failures
path: |
**/Failures/*.png
retention-days: 7Snapshot Storage Strategy
| Strategy | Pros | Cons |
|---|---|---|
| In-repo | Simple, versioned with code | Repo size grows |
| Git LFS | Efficient storage, versioned | Requires LFS setup |
| Separate branch | Keeps main branch small | Harder to review |
Recommendation: Store snapshots in the repo for small-medium projects. Use Git LFS when snapshot count exceeds 500 images.
CI Consistency
// Always specify exact simulator for consistent rendering
// In CI, pin to a specific Xcode and simulator version
// Snapshots recorded on macOS 14 + Xcode 16 + iPhone 15 simulator
// Will differ on macOS 13 + Xcode 15 due to rendering differences---
Handling Diffs and Updating Baselines
Reviewing Diffs
When a snapshot test fails, swift-snapshot-testing saves three files:
__Snapshots__/
Failures/
testLoginView.reference.png # Original baseline
testLoginView.failure.png # Current render
testLoginView.diff.png # Pixel difference overlayUpdating Baselines After Intentional Changes
# Method 1: Delete old snapshots, re-run tests
rm -rf Tests/__Snapshots__/LoginViewTests/
xcodebuild test -scheme MyApp -only-testing:MyAppTests/LoginViewTests
# Method 2: Set record mode in code
# withSnapshotTesting(record: .all) { ... }
# Run tests, then revert the record mode change
# Method 3: Environment variable
# SNAPSHOT_TESTING_RECORD=all xcodebuild test ...PR Workflow
1. Make UI changes
2. Run snapshot tests locally -- see failures
3. Review diff images to verify changes are intentional
4. Update baselines: delete old snapshots, re-record
5. Commit updated snapshots alongside code changes
6. PR reviewer checks snapshot diffs in the file changes---
Perceptual Diff Tools
For cases where exact pixel matching is too strict.
Custom Precision
// Allow small pixel differences (anti-aliasing, font rendering)
assertSnapshot(
of: view,
as: .image(precision: 0.98) // 98% pixel match required
)
// Looser tolerance for complex views
assertSnapshot(
of: view,
as: .image(precision: 0.95, perceptualPrecision: 0.98)
)Precision Parameters
| Parameter | Range | Description |
|---|---|---|
precision | 0-1 | Fraction of pixels that must match exactly |
perceptualPrecision | 0-1 | Per-pixel color similarity threshold |
// Strict: every pixel must match (default)
assertSnapshot(of: view, as: .image(precision: 1.0, perceptualPrecision: 1.0))
// Moderate: allow 2% pixel difference, 98% color similarity
assertSnapshot(of: view, as: .image(precision: 0.98, perceptualPrecision: 0.98))
// Loose: for dynamic content areas
assertSnapshot(of: view, as: .image(precision: 0.90, perceptualPrecision: 0.95))---
Flake Prevention
| Flake Source | Mitigation |
|---|---|
| Date/time in UI | Inject fixed dates in view models |
| Animated content | Disable animations or capture at known state |
| Network images | Use placeholder images in test data |
| Cursor blinking | Resign first responder before snapshot |
| Simulator differences | Pin Xcode + simulator version in CI |
| Font rendering | Same OS version across all environments |
| Random content | Use seeded random or static test data |
Deterministic Test Data
extension User {
static var preview: User {
User(
id: "test-123",
name: "Jane Doe",
email: "jane@example.com",
avatarURL: nil, // no network image
joinDate: Date(timeIntervalSince1970: 1700000000) // fixed date
)
}
}Disable Animations
override func setUp() {
super.setUp()
UIView.setAnimationsEnabled(false)
}
override func tearDown() {
UIView.setAnimationsEnabled(true)
super.tearDown()
}Checklist -- Snapshot Test Reliability:
- [ ] All test data uses fixed values (no random, no current date)
- [ ] Network images replaced with local placeholders
- [ ] Animations disabled during snapshot capture
- [ ] Xcode and simulator versions pinned in CI
- [ ] Precision threshold set appropriately (0.98 for most views)
- [ ] Snapshots committed with code changes in same PR
- [ ] CI uses same macOS and Xcode version as developers
- [ ] Failure artifacts uploaded for review on CI failure
---
Related Resources
- xctest-patterns.md -- XCTest unit testing patterns
- xcuitest-patterns.md -- XCUITest UI testing
- swift-testing.md -- Modern Swift Testing framework
- ios-ci-optimization.md -- CI pipeline optimization
- simulator-commands.md -- Simulator management commands
Swift Testing Framework (Testing module)
Apple's modern testing framework (import Testing) available in recent Xcode toolchains. Uses Swift macros for expressive, concise tests.
Official docs: Swift Testing | Xcode Swift Testing
Contents
- When to Use Swift Testing vs XCTest
- Basic Syntax
- Parameterized Tests
- Test Organization
- Setup and Teardown
- Async and Concurrency
- Migration from XCTest
- CI Integration
- Best Practices
- Resources
---
When to Use Swift Testing vs XCTest
| Use Case | Framework |
|---|---|
| New unit tests | Swift Testing |
| New integration tests | Swift Testing |
| UI tests (XCUITest) | XCTest (required) |
| Performance tests | XCTest (required) |
| Existing XCTest suites | Keep or migrate gradually |
Swift Testing and XCTest can coexist in the same test target.
---
Basic Syntax
Test Functions
import Testing
// Basic test
@Test func userCanLogin() {
let auth = AuthService()
let result = auth.login(email: "user@example.com", password: "pass123")
#expect(result.isSuccess)
}
// Async test
@Test func fetchUserReturnsData() async throws {
let service = UserService()
let user = try await service.fetchUser(id: 1)
#expect(user.name == "John")
}
// Test with display name
@Test("Login fails with invalid email format")
func loginInvalidEmail() {
let auth = AuthService()
let result = auth.login(email: "not-an-email", password: "pass")
#expect(result.error == .invalidEmail)
}Assertions with #expect
// Basic equality
#expect(user.name == "John")
#expect(count > 0)
#expect(items.isEmpty)
// Optional handling
#expect(user != nil)
let unwrapped = try #require(optionalValue) // Unwrap or fail
// Error expectations
#expect(throws: ValidationError.self) {
try validator.validate(input: "")
}
// Specific error
#expect(throws: NetworkError.timeout) {
try await api.fetch(timeout: 0)
}Comparison: XCTest vs Swift Testing
// XCTest
XCTAssertEqual(user.name, "John")
XCTAssertTrue(user.isActive)
XCTAssertNotNil(user.email)
XCTAssertThrowsError(try validate("")) { error in
XCTAssertEqual(error as? ValidationError, .empty)
}
// Swift Testing
#expect(user.name == "John")
#expect(user.isActive)
#expect(user.email != nil)
#expect(throws: ValidationError.empty) {
try validate("")
}---
Parameterized Tests
Run the same test logic with multiple inputs.
// Basic parameterized test
@Test(arguments: ["apple", "banana", "cherry"])
func fruitIsValid(_ fruit: String) {
#expect(FruitValidator.isValid(fruit))
}
// Multiple parameters
@Test(arguments: [
(email: "user@example.com", valid: true),
(email: "invalid", valid: false),
(email: "", valid: false),
(email: "user@domain.co.uk", valid: true)
])
func emailValidation(email: String, valid: Bool) {
#expect(EmailValidator.isValid(email) == valid)
}
// Combining sequences
@Test(arguments: 1...5, ["USD", "EUR", "GBP"])
func currencyConversion(amount: Int, currency: String) async throws {
let result = try await converter.convert(amount: amount, to: currency)
#expect(result > 0)
}
// Using zip for paired arguments
@Test(arguments: zip(["admin", "user", "guest"], [true, false, false]))
func adminAccess(role: String, hasAccess: Bool) {
let user = User(role: role)
#expect(user.canAccessAdmin == hasAccess)
}---
Test Organization
Suites (Grouping Tests)
import Testing
@Suite("Authentication")
struct AuthTests {
@Test func loginSucceeds() { }
@Test func logoutClearsSession() { }
}
@Suite("User Management")
struct UserTests {
@Suite("Profile")
struct ProfileTests {
@Test func updateName() { }
@Test func updateEmail() { }
}
@Suite("Settings")
struct SettingsTests {
@Test func changePassword() { }
}
}Tags
extension Tag {
@Tag static var critical: Self
@Tag static var slow: Self
@Tag static var network: Self
}
@Test(.tags(.critical))
func paymentProcessing() { }
@Test(.tags(.slow, .network))
func syncLargeDataset() async { }
// Run only tagged tests via Xcode or xcodebuildTraits
// Disabled test
@Test(.disabled("Waiting for API v2"))
func newFeatureTest() { }
// Conditionally enabled
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func ciOnlyTest() { }
// Timeout
@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async { }
// Bug reference
@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on iOS 17"))
func flakyTest() { }---
Setup and Teardown
@Suite struct DatabaseTests {
var database: TestDatabase
// Called before each test
init() async throws {
database = try await TestDatabase.create()
}
// Called after each test
deinit {
database.destroy()
}
@Test func insertUser() async throws {
try await database.insert(User(name: "John"))
#expect(await database.count() == 1)
}
}---
Async and Concurrency
// Async tests run with full Swift Concurrency support
@Test func fetchMultipleUsers() async throws {
async let user1 = api.fetchUser(id: 1)
async let user2 = api.fetchUser(id: 2)
let users = try await [user1, user2]
#expect(users.count == 2)
}
// Actor isolation
@Test func actorState() async {
let counter = Counter() // actor
await counter.increment()
await counter.increment()
#expect(await counter.value == 2)
}---
Migration from XCTest
Gradual Migration Strategy
1. New tests: Write in Swift Testing 2. Existing XCTest: Keep working, migrate when touched 3. UI tests: Keep in XCTest (required) 4. Performance tests: Keep in XCTest (required)
Side-by-Side Example
// Same test target can have both:
// XCTest (existing)
import XCTest
class LegacyTests: XCTestCase {
func testOldFeature() {
XCTAssertTrue(feature.works)
}
}
// Swift Testing (new)
import Testing
@Test func newFeature() {
#expect(feature.works)
}Key Differences
| Aspect | XCTest | Swift Testing |
|---|---|---|
| Test marker | func test...() | @Test |
| Assertions | XCTAssert* (40+ functions) | #expect, #require |
| Async | async or expectations | Native async |
| Grouping | Classes inheriting XCTestCase | @Suite structs |
| Parameterized | Manual loops | @Test(arguments:) |
| Parallel | Opt-in | Default |
---
CI Integration
# Run Swift Testing tests (same as XCTest)
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=latest'
# Filter by tag (Xcode 16+)
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=latest' \
-testPlan CriticalTestsTest Plans for Tags
Create an .xctestplan file to run tests by tag:
{
"configurations": [{
"name": "Critical Tests",
"options": {
"testExecutionOrdering": "random"
}
}],
"testTargets": [{
"target": { "name": "MyAppTests" },
"selectedTests": ["tag:critical"]
}]
}---
Best Practices
Do
- Use
#expectfor all assertions (simpler, better diagnostics) - Use
#requireto unwrap optionals and fail fast - Use parameterized tests to reduce duplication
- Use tags to categorize tests (critical, slow, network)
- Use suites to organize related tests
- Set timeouts for async tests that could hang
Avoid
- Mixing XCTest assertions in Swift Testing tests
- Using Swift Testing for UI tests (not supported)
- Using Swift Testing for performance tests (not supported)
- Overusing parameterized tests (keep readable)
---
Resources
XCTest Patterns and Best Practices
Testing patterns for XCTest and XCUITest in iOS development.
Contents
- Unit Testing Patterns
- Mocking Patterns
- UI Testing Patterns
- Test Data Patterns
- Performance Testing
- Test Organization
- CI Integration
- Related
---
Unit Testing Patterns
Basic Test Structure
import XCTest
@testable import MyApp
final class UserServiceTests: XCTestCase {
// System under test
var sut: UserService!
var mockAPI: MockAPIClient!
override func setUp() {
super.setUp()
mockAPI = MockAPIClient()
sut = UserService(api: mockAPI)
}
override func tearDown() {
sut = nil
mockAPI = nil
super.tearDown()
}
func testFetchUser_Success() async throws {
// Given
mockAPI.mockResponse = User(id: 1, name: "John")
// When
let user = try await sut.fetchUser(id: 1)
// Then
XCTAssertEqual(user.name, "John")
XCTAssertEqual(mockAPI.requestCount, 1)
}
}Async Testing
// Async/await (preferred)
func testAsyncOperation() async throws {
let result = try await sut.performAsync()
XCTAssertTrue(result.isSuccess)
}
// Expectations (legacy or complex scenarios)
func testCallbackOperation() {
let expectation = expectation(description: "Callback received")
sut.performWithCallback { result in
XCTAssertNotNil(result)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
}
// Multiple expectations
func testMultipleCallbacks() {
let first = expectation(description: "First callback")
let second = expectation(description: "Second callback")
sut.performSequence(
onFirst: { first.fulfill() },
onSecond: { second.fulfill() }
)
wait(for: [first, second], timeout: 10.0, enforceOrder: true)
}Error Testing
func testInvalidInput_ThrowsError() {
XCTAssertThrowsError(try sut.validate(input: "")) { error in
guard let validationError = error as? ValidationError else {
XCTFail("Wrong error type")
return
}
XCTAssertEqual(validationError, .emptyInput)
}
}
func testAsyncError() async {
do {
_ = try await sut.fetchInvalidResource()
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is NetworkError)
}
}---
Mocking Patterns
Protocol-Based Mocks
// Protocol
protocol APIClientProtocol {
func fetch<T: Decodable>(endpoint: String) async throws -> T
}
// Mock implementation
class MockAPIClient: APIClientProtocol {
var mockResponse: Any?
var mockError: Error?
var requestCount = 0
var lastEndpoint: String?
func fetch<T: Decodable>(endpoint: String) async throws -> T {
requestCount += 1
lastEndpoint = endpoint
if let error = mockError {
throw error
}
guard let response = mockResponse as? T else {
throw MockError.invalidResponse
}
return response
}
}Spy Pattern
class AnalyticsSpy: AnalyticsProtocol {
var trackedEvents: [(name: String, params: [String: Any])] = []
func track(event: String, parameters: [String: Any]) {
trackedEvents.append((event, parameters))
}
func verifyTracked(_ event: String) -> Bool {
trackedEvents.contains { $0.name == event }
}
}Stub with Closure
class StubUserRepository: UserRepositoryProtocol {
var fetchUserHandler: ((Int) async throws -> User)?
func fetchUser(id: Int) async throws -> User {
guard let handler = fetchUserHandler else {
fatalError("fetchUserHandler not set")
}
return try await handler(id)
}
}
// Usage in test
func testUserFetch() async throws {
let stub = StubUserRepository()
stub.fetchUserHandler = { id in
User(id: id, name: "Test User")
}
let user = try await stub.fetchUser(id: 42)
XCTAssertEqual(user.id, 42)
}---
UI Testing Patterns
Basic UI Test
import XCTest
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func testSuccessfulLogin() {
// Navigate
app.buttons["loginButton"].tap()
// Enter credentials
let emailField = app.textFields["emailField"]
emailField.tap()
emailField.typeText("user@example.com")
let passwordField = app.secureTextFields["passwordField"]
passwordField.tap()
passwordField.typeText("password123")
// Submit
app.buttons["submitButton"].tap()
// Verify
XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 5))
}
}Page Object Pattern
// Page object
class LoginPage {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
var emailField: XCUIElement {
app.textFields["emailField"]
}
var passwordField: XCUIElement {
app.secureTextFields["passwordField"]
}
var submitButton: XCUIElement {
app.buttons["submitButton"]
}
var errorLabel: XCUIElement {
app.staticTexts["errorLabel"]
}
func login(email: String, password: String) {
emailField.tap()
emailField.typeText(email)
passwordField.tap()
passwordField.typeText(password)
submitButton.tap()
}
}
// Usage in test
func testLogin() {
let loginPage = LoginPage(app: app)
loginPage.login(email: "user@example.com", password: "password")
XCTAssertTrue(app.navigationBars["Dashboard"].waitForExistence(timeout: 5))
}Accessibility Identifiers
// In production code
emailTextField.accessibilityIdentifier = "emailField"
submitButton.accessibilityIdentifier = "submitButton"
// In SwiftUI
TextField("Email", text: $email)
.accessibilityIdentifier("emailField")
Button("Submit") { submit() }
.accessibilityIdentifier("submitButton")Waiting Patterns
// Wait for element to exist
func testElementAppears() {
let element = app.buttons["asyncButton"]
XCTAssertTrue(element.waitForExistence(timeout: 10))
}
// Wait for element to disappear
func testLoadingDisappears() {
let spinner = app.activityIndicators["loadingSpinner"]
// Wait for spinner to appear first
XCTAssertTrue(spinner.waitForExistence(timeout: 5))
// Then wait for it to disappear
let disappeared = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: disappeared, object: spinner)
wait(for: [expectation], timeout: 10)
}
// Custom predicate
func waitForEnabled(_ element: XCUIElement, timeout: TimeInterval = 5) {
let predicate = NSPredicate(format: "isEnabled == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
wait(for: [expectation], timeout: timeout)
}---
Test Data Patterns
Factory Pattern
enum UserFactory {
static func make(
id: Int = 1,
name: String = "Test User",
email: String = "test@example.com",
isVerified: Bool = true
) -> User {
User(id: id, name: name, email: email, isVerified: isVerified)
}
static func makeUnverified() -> User {
make(isVerified: false)
}
static func makeList(count: Int) -> [User] {
(1...count).map { make(id: $0, name: "User \($0)") }
}
}
// Usage
func testUserDisplay() {
let user = UserFactory.make(name: "John Doe")
// test with user...
}JSON Fixtures
extension XCTestCase {
func loadJSON<T: Decodable>(_ filename: String) throws -> T {
let bundle = Bundle(for: type(of: self))
guard let url = bundle.url(forResource: filename, withExtension: "json") else {
throw FixtureError.fileNotFound(filename)
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(T.self, from: data)
}
}
// Usage
func testParseResponse() throws {
let response: APIResponse = try loadJSON("user_response")
XCTAssertEqual(response.users.count, 3)
}---
Performance Testing
Measure Block
func testParsingPerformance() {
let largeJSON = loadLargeTestData()
measure {
_ = try? JSONDecoder().decode([User].self, from: largeJSON)
}
}
// With metrics
func testScrollPerformance() {
measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
// Perform operation
}
}Baseline Testing
func testDatabaseQueryPerformance() {
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(options: options) {
_ = database.fetchAllUsers()
}
}---
Test Organization
Test Naming Convention
// Pattern: test_[scenario]_[expectedResult]
func test_login_withValidCredentials_succeeds() { }
func test_login_withInvalidEmail_showsError() { }
func test_fetchUser_whenNetworkFails_throwsError() { }Test Categories with Tags
// In scheme settings or xcodebuild:
// -only-testing:MyAppTests/LoginTests
// -skip-testing:MyAppTests/SlowTests
// Or use test plans for different test suitesShared Setup
class BaseTestCase: XCTestCase {
var mockAPI: MockAPIClient!
override func setUp() {
super.setUp()
mockAPI = MockAPIClient()
// Common setup
}
override func tearDown() {
mockAPI = nil
super.tearDown()
}
}
class UserTests: BaseTestCase {
func testUser() {
// mockAPI already available
}
}---
CI Integration
xcodebuild Commands
# Run all tests
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath TestResults.xcresult
# Run specific tests
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:MyAppTests/LoginTests
# Skip slow tests
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-skip-testing:MyAppTests/PerformanceTests
# Parallel testing
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-parallel-testing-enabled YES \
-parallel-testing-worker-count 4Test Results
# Generate JUnit XML (with xcbeautify)
xcodebuild test ... | xcbeautify --report junit
# View results
xcrun xcresulttool get --path TestResults.xcresult --format json---
Related
XCUITest Patterns and Best Practices
Advanced UI testing patterns for iOS using XCUITest.
Official docs: XCUITest
Contents
- Element Query Patterns
- Page Object Pattern for iOS
- System Alerts and Permissions
- Wait Strategies
- Keyboard Interaction
- Scroll and Swipe Gestures
- Picker Wheel Interaction
- Launch Arguments and Environment Variables
- Test Data Injection
- Accessibility Identifier Best Practices
- Recording and Debugging
- Related Resources
---
Element Query Patterns
XCUIElementQuery Basics
import XCTest
class ElementQueryExamples: XCTestCase {
let app = XCUIApplication()
func testElementQueries() {
// By accessibility identifier (preferred)
let loginButton = app.buttons["loginButton"]
// By label text
let submitButton = app.buttons["Submit"]
// By element type
let allButtons = app.buttons
let firstTextField = app.textFields.firstMatch
// By index (fragile -- avoid in production tests)
let secondCell = app.cells.element(boundBy: 1)
}
}Predicate-Based Queries
// NSPredicate matching
let containsEmail = NSPredicate(format: "label CONTAINS[c] 'email'")
let emailField = app.textFields.matching(containsEmail).firstMatch
// Begins with
let startsWithUser = NSPredicate(format: "label BEGINSWITH 'User'")
let userLabel = app.staticTexts.matching(startsWithUser).firstMatch
// Multiple conditions
let enabledSubmit = NSPredicate(
format: "label == 'Submit' AND isEnabled == true"
)
let readyButton = app.buttons.matching(enabledSubmit).firstMatch
// Regular expression
let datePattern = NSPredicate(
format: "label MATCHES '\\\\d{2}/\\\\d{2}/\\\\d{4}'"
)
let dateLabel = app.staticTexts.matching(datePattern).firstMatchDescendant Queries
// Find elements within a container
let formContainer = app.otherElements["loginForm"]
let emailField = formContainer.textFields["emailField"]
let passwordField = formContainer.secureTextFields["passwordField"]
// Navigate table cells
let orderCell = app.tables.cells.containing(
NSPredicate(format: "label CONTAINS 'Order #1234'")
).firstMatch
// Find button within a specific cell
let deleteButton = orderCell.buttons["deleteButton"]
// Children vs descendants
let directChildren = app.otherElements["container"].children(matching: .button)
let allDescendants = app.otherElements["container"].descendants(matching: .button)Element Type Reference
| XCUIElement Type | UIKit Equivalent | SwiftUI Equivalent |
|---|---|---|
buttons | UIButton | Button |
textFields | UITextField | TextField |
secureTextFields | UITextField (secure) | SecureField |
staticTexts | UILabel | Text |
images | UIImageView | Image |
switches | UISwitch | Toggle |
sliders | UISlider | Slider |
tables | UITableView | List |
collectionViews | UICollectionView | LazyVGrid/LazyHGrid |
navigationBars | UINavigationBar | NavigationStack |
tabBars | UITabBar | TabView |
alerts | UIAlertController | .alert modifier |
sheets | UIViewController (sheet) | .sheet modifier |
---
Page Object Pattern for iOS
Base Page
protocol Page {
var app: XCUIApplication { get }
func verify() -> Self
}
extension Page {
@discardableResult
func verify() -> Self {
return self
}
}Page Implementation
class LoginPage: Page {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
// MARK: - Elements
private var emailField: XCUIElement {
app.textFields["emailField"]
}
private var passwordField: XCUIElement {
app.secureTextFields["passwordField"]
}
private var loginButton: XCUIElement {
app.buttons["loginButton"]
}
private var errorBanner: XCUIElement {
app.staticTexts["errorBanner"]
}
private var forgotPasswordLink: XCUIElement {
app.buttons["forgotPasswordLink"]
}
// MARK: - Actions
@discardableResult
func typeEmail(_ email: String) -> Self {
emailField.tap()
emailField.clearAndType(email)
return self
}
@discardableResult
func typePassword(_ password: String) -> Self {
passwordField.tap()
passwordField.clearAndType(password)
return self
}
@discardableResult
func tapLogin() -> DashboardPage {
loginButton.tap()
return DashboardPage(app: app).verify()
}
@discardableResult
func tapLoginExpectingError() -> Self {
loginButton.tap()
XCTAssertTrue(errorBanner.waitForExistence(timeout: 5))
return self
}
func tapForgotPassword() -> ForgotPasswordPage {
forgotPasswordLink.tap()
return ForgotPasswordPage(app: app).verify()
}
// MARK: - Assertions
@discardableResult
func assertErrorMessage(_ message: String) -> Self {
XCTAssertEqual(errorBanner.label, message)
return self
}
@discardableResult
func assertLoginButtonEnabled(_ enabled: Bool = true) -> Self {
XCTAssertEqual(loginButton.isEnabled, enabled)
return self
}
// MARK: - Verify
@discardableResult
func verify() -> Self {
XCTAssertTrue(emailField.waitForExistence(timeout: 5),
"Login page did not appear")
return self
}
}Using Pages in Tests
final class LoginFlowTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launch()
}
func testSuccessfulLogin() {
LoginPage(app: app)
.verify()
.typeEmail("user@example.com")
.typePassword("password123")
.tapLogin()
.assertWelcomeMessage("Welcome, user@example.com")
}
func testInvalidCredentials() {
LoginPage(app: app)
.verify()
.typeEmail("wrong@example.com")
.typePassword("wrongpass")
.tapLoginExpectingError()
.assertErrorMessage("Invalid email or password")
}
}---
System Alerts and Permissions
Handling Permission Dialogs
// Method 1: addUIInterruptionMonitor (handles alerts globally)
override func setUp() {
super.setUp()
app = XCUIApplication()
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
let allowButton = alert.buttons["Allow"]
let allowWhileUsing = alert.buttons["Allow While Using App"]
if allowWhileUsing.exists {
allowWhileUsing.tap()
return true
} else if allowButton.exists {
allowButton.tap()
return true
}
return false
}
app.launch()
}
// After triggering a permission, interact with app to fire the monitor
func testCameraPermission() {
app.buttons["takePhotoButton"].tap()
app.tap() // tap the app to trigger the interruption monitor
// Continue test after permission is granted
}Handling Specific System Alerts
// Method 2: Direct springboard interaction
func testNotificationPermission() {
app.buttons["enableNotifications"].tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Allow"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
}
// Method 3: Reset permissions via launch arguments (iOS 15+)
func testLocationPermission() {
app.resetAuthorizationStatus(for: .location)
app.launch()
app.buttons["shareLocation"].tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowOnce = springboard.buttons["Allow Once"]
if allowOnce.waitForExistence(timeout: 5) {
allowOnce.tap()
}
}Common Alert Button Labels
| Permission | Allow Button Text | Deny Button Text |
|---|---|---|
| Location | "Allow While Using App" / "Allow Once" | "Don't Allow" |
| Camera | "OK" | "Don't Allow" |
| Photos | "Allow Full Access" / "Select Photos" | "Don't Allow" |
| Notifications | "Allow" | "Don't Allow" |
| Contacts | "OK" | "Don't Allow" |
| Tracking (ATT) | "Allow" | "Ask App Not to Track" |
---
Wait Strategies
waitForExistence (Built-in)
// Simple existence wait
let element = app.buttons["asyncButton"]
XCTAssertTrue(element.waitForExistence(timeout: 10))XCTNSPredicateExpectation (Advanced)
// Wait for element to become enabled
func waitForEnabled(_ element: XCUIElement, timeout: TimeInterval = 10) {
let predicate = NSPredicate(format: "isEnabled == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
XCTAssertEqual(result, .completed, "Element did not become enabled")
}
// Wait for element to disappear
func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval = 10) {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
XCTAssertEqual(result, .completed, "Element did not disappear")
}
// Wait for label to change
func waitForLabel(_ element: XCUIElement, toBe text: String, timeout: TimeInterval = 10) {
let predicate = NSPredicate(format: "label == %@", text)
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
XCTAssertEqual(result, .completed, "Label did not become '\(text)'")
}Polling Wait (Custom)
extension XCTestCase {
func waitUntil(
timeout: TimeInterval = 10,
interval: TimeInterval = 0.5,
condition: () -> Bool
) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return }
Thread.sleep(forTimeInterval: interval)
}
XCTFail("Condition not met within \(timeout) seconds")
}
}
// Usage
func testDataLoads() {
app.buttons["refreshButton"].tap()
waitUntil {
app.cells.count > 0
}
XCTAssertGreaterThan(app.cells.count, 0)
}---
Keyboard Interaction
// Type text
let field = app.textFields["searchField"]
field.tap()
field.typeText("search query")
// Clear and type (helper extension)
extension XCUIElement {
func clearAndType(_ text: String) {
guard let currentValue = self.value as? String, !currentValue.isEmpty else {
self.typeText(text)
return
}
// Select all and delete
self.tap()
self.press(forDuration: 1.0) // long press to show menu
if XCUIApplication().menuItems["Select All"].waitForExistence(timeout: 2) {
XCUIApplication().menuItems["Select All"].tap()
self.typeText(XCUIKeyboardKey.delete.rawValue)
}
self.typeText(text)
}
}
// Dismiss keyboard
app.keyboards.buttons["Return"].tap()
// Or tap outside
app.otherElements["mainView"].tap()
// Or swipe down (common pattern)
app.swipeDown()
// Check keyboard visibility
func isKeyboardVisible() -> Bool {
return app.keyboards.count > 0
}---
Scroll and Swipe Gestures
// Swipe in a direction
app.swipeUp()
app.swipeDown()
app.swipeLeft()
app.swipeRight()
// Scroll to find an element in a table/list
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement,
maxSwipes: Int = 10) {
var swipeCount = 0
while !element.isHittable && swipeCount < maxSwipes {
scrollView.swipeUp()
swipeCount += 1
}
XCTAssertTrue(element.isHittable,
"Element not found after \(maxSwipes) swipes")
}
// Usage
let cell = app.cells["item-42"]
scrollToElement(cell, in: app.tables.firstMatch)
cell.tap()
// Scroll to exact position using coordinate-based swipe
func scrollSlowly(in element: XCUIElement) {
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
start.press(forDuration: 0.1, thenDragTo: end)
}
// Pull to refresh
func pullToRefresh(in table: XCUIElement) {
let start = table.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
let end = table.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
start.press(forDuration: 0.1, thenDragTo: end)
}---
Picker Wheel Interaction
// Date picker
func selectDate(month: String, day: String, year: String) {
let datePicker = app.datePickers.firstMatch
datePicker.pickerWheels.element(boundBy: 0).adjust(toPickerWheelValue: month)
datePicker.pickerWheels.element(boundBy: 1).adjust(toPickerWheelValue: day)
datePicker.pickerWheels.element(boundBy: 2).adjust(toPickerWheelValue: year)
}
// Standard picker
func selectPickerValue(_ value: String) {
let picker = app.pickers.firstMatch
picker.pickerWheels.firstMatch.adjust(toPickerWheelValue: value)
}
// Segmented control
func selectSegment(_ title: String) {
app.segmentedControls.buttons[title].tap()
}---
Launch Arguments and Environment Variables
Setting Launch Arguments
override func setUp() {
super.setUp()
app = XCUIApplication()
// Feature flags
app.launchArguments.append("--uitesting")
app.launchArguments.append("--skip-onboarding")
app.launchArguments.append("--disable-animations")
// Environment variables (key-value pairs)
app.launchEnvironment["API_BASE_URL"] = "http://localhost:8080"
app.launchEnvironment["MOCK_AUTH_TOKEN"] = "test-token-123"
app.launchEnvironment["LOCALE"] = "en_US"
app.launch()
}Reading in App Code
// In your app's AppDelegate or entry point
struct AppConfig {
static var isUITesting: Bool {
ProcessInfo.processInfo.arguments.contains("--uitesting")
}
static var shouldSkipOnboarding: Bool {
ProcessInfo.processInfo.arguments.contains("--skip-onboarding")
}
static var apiBaseURL: String {
ProcessInfo.processInfo.environment["API_BASE_URL"]
?? "https://api.production.com"
}
}
// Usage in app
if AppConfig.isUITesting {
// Use mock network layer
NetworkService.shared = MockNetworkService()
}---
Test Data Injection
Local Mock Server
// Launch a local server before tests
override func setUp() {
super.setUp()
app = XCUIApplication()
app.launchEnvironment["API_BASE_URL"] = "http://localhost:8080"
app.launch()
}
// Combine with a test fixture server (e.g., using Embassy or Swifter)Pre-Seeded Database
// Copy a pre-built SQLite database before launch
override func setUp() {
super.setUp()
// Use launch argument to trigger data seeding
app = XCUIApplication()
app.launchArguments.append("--seed-test-data")
app.launchArguments.append("--seed-file=test_orders.json")
app.launch()
}UserDefaults Injection
app.launchArguments += ["-user_has_completed_onboarding", "YES"]
app.launchArguments += ["-preferred_currency", "EUR"]
// The - prefix sets UserDefaults keys directly---
Accessibility Identifier Best Practices
Naming Convention
// Pattern: [screen]_[element]_[type] or camelCase with context
// Consistent naming makes test maintenance easier
// UIKit
emailTextField.accessibilityIdentifier = "login_email_textField"
passwordTextField.accessibilityIdentifier = "login_password_secureField"
submitButton.accessibilityIdentifier = "login_submit_button"
// SwiftUI
TextField("Email", text: $email)
.accessibilityIdentifier("login_email_textField")
SecureField("Password", text: $password)
.accessibilityIdentifier("login_password_secureField")
Button("Log In") { login() }
.accessibilityIdentifier("login_submit_button")Centralized Identifier Registry
// AccessibilityIdentifiers.swift (shared between app and test targets)
enum AccessibilityID {
enum Login {
static let emailField = "login_email_textField"
static let passwordField = "login_password_secureField"
static let submitButton = "login_submit_button"
static let errorBanner = "login_error_banner"
}
enum Dashboard {
static let welcomeLabel = "dashboard_welcome_label"
static let settingsButton = "dashboard_settings_button"
}
enum OrderList {
static func orderCell(id: String) -> String {
"orderList_cell_\(id)"
}
static let refreshControl = "orderList_refresh"
}
}
// In production code
submitButton.accessibilityIdentifier = AccessibilityID.Login.submitButton
// In test code
let button = app.buttons[AccessibilityID.Login.submitButton]Checklist -- Accessibility Identifiers:
- [ ] Every interactive element has an accessibility identifier
- [ ] Identifiers use a consistent naming convention
- [ ] Identifiers are defined in a shared file between app and test targets
- [ ] Dynamic list items use parameterized identifiers (e.g.,
cell_\(id)) - [ ] Identifiers do not duplicate accessibility labels (they serve different purposes)
---
Recording and Debugging
Xcode Test Recording
1. Open UI test file in Xcode
2. Place cursor inside a test method
3. Click the red Record button at bottom of editor
4. Interact with the app -- Xcode generates XCUITest code
5. Stop recording and clean up generated code
Note: Recorded code is verbose. Always refactor into page objects.Debugging Techniques
// Print element tree
func testDebugElementTree() {
// Print full accessibility hierarchy
print(app.debugDescription)
// Print specific container
print(app.tables.firstMatch.debugDescription)
}
// Screenshot during test
func testWithScreenshot() {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "debug-screenshot"
attachment.lifetime = .keepAlways
add(attachment)
}
// Conditional breakpoint in test
func testWithDebugging() {
let button = app.buttons["submitButton"]
// Set breakpoint here and use `po app.debugDescription` in lldb
if !button.exists {
let screenshot = app.screenshot()
add(XCTAttachment(screenshot: screenshot))
XCTFail("Submit button not found. See screenshot attachment.")
}
}Common Debugging Commands (LLDB)
(lldb) po app.debugDescription # Full element tree
(lldb) po app.buttons.debugDescription # All buttons
(lldb) po app.buttons["login"].exists # Check specific element
(lldb) expr app.screenshot() # Capture screenshot---
Related Resources
- xctest-patterns.md -- XCTest unit testing patterns
- swift-testing.md -- Modern Swift Testing framework
- snapshot-testing-ios.md -- Visual snapshot testing
- simulator-commands.md -- Simulator management
- ios-ci-optimization.md -- CI pipeline optimization