Guide - Testing

Automated UI Testing on Mac Servers: XCTest, Appium & Selenium

Set up a dedicated Mac server for 24/7 automated UI testing. Run XCTest, XCUITest, Appium, and Selenium tests in parallel across multiple iOS Simulators with consistent, reproducible results.

30 min read Updated January 2025

Why Dedicated Hardware for Testing?

UI testing on shared or local machines introduces flakiness, inconsistency, and bottlenecks. A dedicated Mac server solves these problems.

Consistent Results

No other processes competing for CPU or RAM. Tests run in a clean, controlled environment every time, eliminating flaky test failures caused by resource contention.

Parallel Execution

Run tests across multiple iOS Simulator instances simultaneously. The M4 Pro's 14 cores handle 4-6 parallel Simulator instances without performance degradation.

24/7 Availability

Tests run any time -- nightly regression suites, post-merge validation, or on-demand. The server is always ready, no developer laptop required.

XCTest on a Dedicated Mac

XCTest is Apple's built-in testing framework, included with Xcode. XCUITest extends it for UI testing. Both run natively and require no additional setup beyond Xcode.

Running XCTest from the Command Line

# Run all unit tests
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \
  -resultBundlePath ./TestResults/UnitTests.xcresult

# Run only UI tests
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyAppUITests \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \
  -resultBundlePath ./TestResults/UITests.xcresult

# Run a specific test class
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -only-testing:MyAppTests/LoginTests

# Run a specific test method
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -only-testing:MyAppTests/LoginTests/testSuccessfulLogin

Testing on Multiple Destinations

# Test on multiple iPhone models simultaneously
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -destination 'platform=iOS Simulator,name=iPhone 16 Pro Max' \
  -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \
  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \
  -resultBundlePath ./TestResults/MultiDevice.xcresult

# List all available destinations
xcodebuild -showdestinations \
  -workspace MyApp.xcworkspace \
  -scheme MyApp

Result Bundles and Screenshots

# Extract test results summary
xcrun xcresulttool get --path ./TestResults/UITests.xcresult \
  --format json

# Export test attachments (screenshots, videos)
xcrun xcresulttool export \
  --path ./TestResults/UITests.xcresult \
  --output-path ./TestArtifacts \
  --type attachments

# Get human-readable test summary
xcrun xcresulttool get --path ./TestResults/UITests.xcresult \
  --format json | python3 -m json.tool

Appium Setup for iOS

Appium is an open-source automation framework that lets you write tests in any language (Python, JavaScript, Java, etc.) and run them against iOS apps on simulators or devices. It uses Apple's XCUITest driver under the hood.

Install Appium on Your Mac Server

# Install Node.js via Homebrew
brew install node

# Install Appium 2.x globally
npm install -g appium

# Install the XCUITest driver for iOS
appium driver install xcuitest

# Verify installation
appium --version
appium driver list --installed

# Install appium-doctor to check dependencies
npm install -g appium-doctor
appium-doctor --ios

# Start Appium server
appium server --address 127.0.0.1 --port 4723

Configure Desired Capabilities

# Example capabilities (JSON format for Appium 2.x)
{
    "platformName": "iOS",
    "appium:automationName": "XCUITest",
    "appium:deviceName": "iPhone 16",
    "appium:platformVersion": "18.2",
    "appium:app": "/path/to/MyApp.app",
    "appium:noReset": false,
    "appium:wdaStartupRetries": 3,
    "appium:wdaStartupRetryInterval": 20000,
    "appium:simulatorStartupTimeout": 120000
}

Example Appium Test (Python)

# Install Appium Python client
# pip install Appium-Python-Client

from appium import webdriver
from appium.options.ios import XCUITestOptions
from appium.webdriver.common.appiumby import AppiumBy

# Configure options
options = XCUITestOptions()
options.platform_name = "iOS"
options.device_name = "iPhone 16"
options.platform_version = "18.2"
options.app = "/path/to/MyApp.app"

# Connect to Appium server
driver = webdriver.Remote(
    command_executor="http://127.0.0.1:4723",
    options=options
)

try:
    # Wait for app to load
    driver.implicitly_wait(10)

    # Find and tap login button
    login_button = driver.find_element(
        AppiumBy.ACCESSIBILITY_ID, "loginButton"
    )
    login_button.click()

    # Enter username
    username_field = driver.find_element(
        AppiumBy.ACCESSIBILITY_ID, "usernameField"
    )
    username_field.send_keys("testuser@example.com")

    # Enter password
    password_field = driver.find_element(
        AppiumBy.ACCESSIBILITY_ID, "passwordField"
    )
    password_field.send_keys("password123")

    # Submit login
    submit_button = driver.find_element(
        AppiumBy.ACCESSIBILITY_ID, "submitButton"
    )
    submit_button.click()

    # Verify welcome screen
    welcome_label = driver.find_element(
        AppiumBy.ACCESSIBILITY_ID, "welcomeLabel"
    )
    assert "Welcome" in welcome_label.text

    print("Test PASSED: Login successful")

finally:
    driver.quit()

Example Appium Test (JavaScript)

// npm install webdriverio @wdio/cli

const { remote } = require('webdriverio');

async function runTest() {
    const driver = await remote({
        protocol: 'http',
        hostname: '127.0.0.1',
        port: 4723,
        path: '/',
        capabilities: {
            platformName: 'iOS',
            'appium:automationName': 'XCUITest',
            'appium:deviceName': 'iPhone 16',
            'appium:platformVersion': '18.2',
            'appium:app': '/path/to/MyApp.app'
        }
    });

    try {
        // Tap login button
        const loginBtn = await driver.$('~loginButton');
        await loginBtn.click();

        // Enter credentials
        const username = await driver.$('~usernameField');
        await username.setValue('testuser@example.com');

        const password = await driver.$('~passwordField');
        await password.setValue('password123');

        // Submit
        const submit = await driver.$('~submitButton');
        await submit.click();

        // Verify
        const welcome = await driver.$('~welcomeLabel');
        const text = await welcome.getText();
        console.assert(text.includes('Welcome'), 'Login test failed');

        console.log('Test PASSED: Login successful');
    } finally {
        await driver.deleteSession();
    }
}

runTest();

Selenium for Safari

macOS includes Safari and safaridriver out of the box. No additional browser driver downloads needed -- unlike Chrome or Firefox on other platforms.

Enable safaridriver

# Enable the Safari WebDriver (one-time setup)
safaridriver --enable

# Verify safaridriver is working
safaridriver --version

# For headless-like automation, enable "Allow Remote Automation"
# in Safari > Settings > Advanced > Show Develop menu
# Then: Develop > Allow Remote Automation
# Or via command line:
defaults write com.apple.Safari AllowRemoteAutomation 1

Selenium Safari Test (Python)

# pip install selenium

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# Create Safari driver
driver = webdriver.Safari()

try:
    # Navigate to your web app
    driver.get("https://your-webapp.com")

    # Wait for page to load
    wait = WebDriverWait(driver, 10)

    # Find and click a button
    login_link = wait.until(
        EC.element_to_be_clickable((By.CSS_SELECTOR, "a.login-btn"))
    )
    login_link.click()

    # Fill in form
    email_input = wait.until(
        EC.presence_of_element_located((By.ID, "email"))
    )
    email_input.send_keys("test@example.com")

    password_input = driver.find_element(By.ID, "password")
    password_input.send_keys("testpassword")

    # Submit form
    submit_btn = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
    submit_btn.click()

    # Verify redirect to dashboard
    wait.until(EC.url_contains("/dashboard"))
    assert "/dashboard" in driver.current_url

    print("Test PASSED: Safari login flow works correctly")

finally:
    driver.quit()

Selenium Safari Test (JavaScript)

// npm install selenium-webdriver

const { Builder, By, until } = require('selenium-webdriver');

async function safariTest() {
    const driver = await new Builder()
        .forBrowser('safari')
        .build();

    try {
        await driver.get('https://your-webapp.com');

        // Click login
        const loginBtn = await driver.findElement(By.css('a.login-btn'));
        await loginBtn.click();

        // Fill form
        const email = await driver.findElement(By.id('email'));
        await email.sendKeys('test@example.com');

        const password = await driver.findElement(By.id('password'));
        await password.sendKeys('testpassword');

        // Submit
        const submit = await driver.findElement(
            By.css("button[type='submit']")
        );
        await submit.click();

        // Verify
        await driver.wait(until.urlContains('/dashboard'), 10000);
        const url = await driver.getCurrentUrl();
        console.assert(url.includes('/dashboard'));

        console.log('Test PASSED: Safari login flow works');
    } finally {
        await driver.quit();
    }
}

safariTest();

Parallel Test Execution

Running tests in parallel dramatically reduces your total test suite execution time. The Mac Mini M4 Pro can comfortably run 4-6 Simulator instances simultaneously.

XCTest Parallel Testing

# Enable parallel testing with xcodebuild
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -parallel-testing-worker-count 4 \
  -resultBundlePath ./TestResults/Parallel.xcresult

# Parallel testing across multiple device types
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \
  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \
  -parallel-testing-enabled YES \
  -resultBundlePath ./TestResults/MultiDeviceParallel.xcresult

Managing Simulator Instances

# List all available simulators
xcrun simctl list devices available

# Create custom simulator instances for testing
xcrun simctl create "Test-iPhone-1" "iPhone 16" "iOS 18.2"
xcrun simctl create "Test-iPhone-2" "iPhone 16" "iOS 18.2"
xcrun simctl create "Test-iPhone-3" "iPhone 16" "iOS 18.2"
xcrun simctl create "Test-iPhone-4" "iPhone 16" "iOS 18.2"

# Boot multiple simulators
xcrun simctl boot "Test-iPhone-1"
xcrun simctl boot "Test-iPhone-2"
xcrun simctl boot "Test-iPhone-3"
xcrun simctl boot "Test-iPhone-4"

# Check booted simulators
xcrun simctl list devices booted

# Shut down all simulators
xcrun simctl shutdown all

# Delete all test simulators
xcrun simctl delete "Test-iPhone-1"
xcrun simctl delete "Test-iPhone-2"
xcrun simctl delete "Test-iPhone-3"
xcrun simctl delete "Test-iPhone-4"

Parallel Testing Performance

Configuration 200 UI Tests Duration Speed Improvement
1 Simulator (sequential) 45 minutes Baseline
2 Simulators (parallel) 24 minutes 1.9x faster
4 Simulators (parallel) 13 minutes 3.5x faster
6 Simulators (parallel) 10 minutes 4.5x faster

Note: Results measured on Mac Mini M4 Pro (14-core, 24GB RAM). The optimal number of parallel Simulators depends on your test complexity and memory requirements. For most UI test suites, 4 parallel workers provides the best balance of speed and stability.

CI/CD Integration

Integrate your automated tests into your CI/CD pipeline for automated test execution on every push, pull request, or scheduled interval.

GitHub Actions Workflow for Automated Testing

# .github/workflows/ios-tests.yml
name: iOS Automated Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]
  schedule:
    # Nightly regression tests at 2 AM UTC
    - cron: '0 2 * * *'

jobs:
  unit-tests:
    name: Unit Tests
    runs-on: self-hosted
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode version
        run: sudo xcode-select -s /Applications/Xcode_16.2.app

      - name: Resolve dependencies
        run: |
          xcodebuild -resolvePackageDependencies \
            -workspace MyApp.xcworkspace \
            -scheme MyApp

      - name: Run unit tests
        run: |
          xcodebuild test \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \
            -parallel-testing-enabled YES \
            -resultBundlePath $/UnitTests.xcresult

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: unit-test-results
          path: $/UnitTests.xcresult

  ui-tests:
    name: UI Tests
    runs-on: self-hosted
    timeout-minutes: 60
    needs: unit-tests

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode version
        run: sudo xcode-select -s /Applications/Xcode_16.2.app

      - name: Boot simulators for parallel testing
        run: |
          xcrun simctl boot "iPhone 16" || true
          xcrun simctl boot "iPhone SE (3rd generation)" || true

      - name: Run UI tests in parallel
        run: |
          xcodebuild test \
            -workspace MyApp.xcworkspace \
            -scheme MyAppUITests \
            -destination 'platform=iOS Simulator,name=iPhone 16' \
            -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \
            -parallel-testing-enabled YES \
            -parallel-testing-worker-count 4 \
            -resultBundlePath $/UITests.xcresult

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: ui-test-results
          path: $/UITests.xcresult

      - name: Cleanup simulators
        if: always()
        run: xcrun simctl shutdown all

Running Appium Tests in CI

# Add to your GitHub Actions workflow
      - name: Start Appium server
        run: |
          appium server --address 127.0.0.1 --port 4723 &
          sleep 5
          curl http://127.0.0.1:4723/status

      - name: Build app for testing
        run: |
          xcodebuild build-for-testing \
            -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 16'

      - name: Run Appium tests
        run: |
          cd tests/appium
          pip install -r requirements.txt
          pytest test_login.py test_checkout.py \
            --junitxml=results.xml -v

      - name: Stop Appium server
        if: always()
        run: pkill -f appium || true

Test Reporting

Generate and process test reports to track quality trends and quickly identify failing tests.

Working with xcresult Bundles

# Get test results summary as JSON
xcrun xcresulttool get \
  --path ./TestResults.xcresult \
  --format json

# Get specific test action results
xcrun xcresulttool get \
  --path ./TestResults.xcresult \
  --format json \
  --id "REF_ID"

# Export all attachments (screenshots, logs)
xcrun xcresulttool export \
  --path ./TestResults.xcresult \
  --output-path ./artifacts \
  --type attachments

# Merge multiple xcresult bundles
xcrun xcresulttool merge \
  ./UnitTests.xcresult \
  ./UITests.xcresult \
  --output-path ./MergedResults.xcresult

Convert to JUnit XML

JUnit XML is the universal format supported by CI/CD platforms, Slack integrations, and test dashboards.

# Install xcresult-to-junit converter
brew install chargepoint/xcparse/xcparse

# Convert xcresult to JUnit XML
xcparse tests ./TestResults.xcresult ./junit-results/

# Or use trainer (a popular Ruby gem)
gem install trainer
trainer --path ./TestResults.xcresult --output_directory ./reports

# The generated JUnit XML works with:
# - GitHub Actions test summaries
# - Jenkins Test Result plugin
# - GitLab CI test reporting
# - Slack notifications via CI integrations

Screenshot on Failure

// In your XCUITest, add screenshot capture on failure:
// XCTestCase+Screenshots.swift

import XCTest

extension XCTestCase {
    override func tearDown() {
        if testRun?.hasSucceeded == false {
            let screenshot = XCUIScreen.main.screenshot()
            let attachment = XCTAttachment(screenshot: screenshot)
            attachment.name = "Failure-\(name)"
            attachment.lifetime = .keepAlways
            add(attachment)
        }
        super.tearDown()
    }
}

Best Practices

Test Isolation

Each test should be independent and not rely on state from previous tests. Reset the app state before each test.

// In your XCUITest setUp() method:
override func setUp() {
    super.setUp()
    continueAfterFailure = false

    let app = XCUIApplication()
    app.launchArguments = ["--uitesting", "--reset-state"]
    app.launchEnvironment = [
        "DISABLE_ANIMATIONS": "1",
        "UI_TEST_MODE": "true"
    ]
    app.launch()
}

Simulator Management

Clean up simulators between test runs to prevent state leaks and disk space issues.

#!/bin/bash
# cleanup-simulators.sh - Run before and after test suites

# Shutdown all running simulators
xcrun simctl shutdown all

# Erase all simulator content and settings
xcrun simctl erase all

# Delete unavailable simulators
xcrun simctl delete unavailable

# Clear DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/*

# Clear simulator logs
rm -rf ~/Library/Logs/CoreSimulator/*

echo "Simulator cleanup complete"

Disable Animations for Speed

Disable animations in your app during UI tests to reduce test execution time and flakiness.

// In your AppDelegate or App struct:
#if DEBUG
if CommandLine.arguments.contains("--uitesting") {
    UIView.setAnimationsEnabled(false)
}
#endif

// Also disable Simulator animations via command line:
// Set Simulator > Debug > Slow Animations = OFF
defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO

Retry Flaky Tests

Use Xcode's built-in test retry mechanism to handle occasionally flaky UI tests.

# Retry failed tests up to 3 times
xcodebuild test \
  -workspace MyApp.xcworkspace \
  -scheme MyAppUITests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -retry-tests-on-failure \
  -test-iterations 3 \
  -resultBundlePath ./TestResults.xcresult

Monitor Disk Space

Simulators and test artifacts consume significant disk space. Set up automated cleanup.

# Add to crontab for daily cleanup at midnight
# crontab -e
0 0 * * * /usr/local/bin/cleanup-test-artifacts.sh

# cleanup-test-artifacts.sh
#!/bin/bash
# Remove test results older than 7 days
find ~/TestResults -name "*.xcresult" -mtime +7 -delete

# Remove DerivedData older than 3 days
find ~/Library/Developer/Xcode/DerivedData \
  -maxdepth 1 -mtime +3 -exec rm -rf {} +

# Remove old simulator logs
find ~/Library/Logs/CoreSimulator -mtime +3 -delete

# Check remaining disk space
df -h / | tail -1

Frequently Asked Questions

How many parallel simulators can a Mac Mini M4 Pro handle?

With 14 CPU cores and 24GB RAM, the M4 Pro comfortably handles 4-6 parallel iOS Simulator instances for UI testing. For unit tests only (no Simulator GUI), you can run even more parallel workers. We recommend starting with 4 and increasing based on your test suite's memory requirements.

Can I run tests headlessly without VNC?

Yes. XCTest and Appium tests run entirely from the command line via SSH. The iOS Simulator runs in a "headless" mode when launched via xcodebuild without a display session. You do not need VNC connected during test execution. VNC is only useful for debugging failed tests visually.

How do I handle flaky UI tests?

First, ensure test isolation (reset app state before each test). Disable animations. Use explicit waits instead of sleep(). Use Xcode's -retry-tests-on-failure flag to automatically retry failed tests. On a dedicated server, flakiness from resource contention is eliminated, which is the most common cause of flaky tests on shared CI machines.

Can I test on real iOS devices from a remote Mac?

You cannot connect physical iOS devices to a remote server (USB is local). However, the iOS Simulator covers the vast majority of UI test scenarios. For device-specific testing, you can use Apple's "Xcode Cloud" device farm or distribute test builds via TestFlight for manual device testing.

What about testing on different iOS versions?

You can install multiple iOS Simulator runtimes on the same Mac. Use xcodebuild -downloadPlatform iOS for the latest, or download older runtimes from Xcode Settings > Platforms. Then specify the OS version in your test destination: -destination 'platform=iOS Simulator,name=iPhone 16,OS=17.5'.

Run Your Test Suite 24/7

Get a dedicated Mac Mini M4 Pro for automated testing. Parallel execution, consistent results, and always-on availability.

Related Guides