なぜテストに専用ハードウェアなのか?
共有マシンやローカルマシンでのUIテストは、不安定さ(flakiness)、一貫性の欠如、ボトルネックを招きます。専用 Mac mini M4 ビルドサーバーはこうした問題を解決します。
一貫した結果
CPUやRAMを奪い合う他のプロセスがありません。テストは毎回クリーンで制御された環境で実行され、リソースの競合に起因する不安定なテストの失敗をなくします。
並列実行
複数のiOS Simulatorインスタンスにまたがってテストを同時に実行します。Mac mini M4 Pro(12コアCPU、24GB)は、性能を損なうことなく4〜6個の並列Simulatorインスタンスを処理します。
24時間365日の可用性
テストはいつでも実行できます。夜間のリグレッションスイート、マージ後の検証、オンデマンド実行のいずれにも対応します。サーバーは常に準備万端で、開発者のノートパソコンは不要です。
専用MacでのXCTest
XCTestは、Xcodeに含まれるApple標準のテストフレームワークです。XCUITestはこれをUIテスト向けに拡張したものです。どちらもネイティブに動作し、Xcode以外の追加セットアップは不要です。
コマンドラインからXCTestを実行する
# Use any device listed by: xcrun simctl list devices available
# Run all unit tests
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
-resultBundlePath ./TestResults/UnitTests.xcresult
# Run only UI tests
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
-resultBundlePath ./TestResults/UITests.xcresult
# Run a specific test class
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:MyAppTests/LoginTests
# Run a specific test method
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:MyAppTests/LoginTests/testSuccessfulLogin
複数のデスティネーションでテストする
# Test on multiple iPhone models simultaneously
# (each name must exist in: xcrun simctl list devices available;
# create a missing one with: xcrun simctl create "iPhone 16e" "iPhone 16e")
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-destination 'platform=iOS Simulator,name=iPhone 16e' \
-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
結果バンドルとスクリーンショット
# Test results summary as JSON (Xcode 16 and later)
xcrun xcresulttool get test-results summary \
--path ./TestResults/UITests.xcresult
# Every test with its result
xcrun xcresulttool get test-results tests \
--path ./TestResults/UITests.xcresult
# Export test attachments (screenshots, videos)
xcrun xcresulttool export attachments \
--path ./TestResults/UITests.xcresult \
--output-path ./TestArtifacts
iOS向けAppiumのセットアップ
Appiumはオープンソースの自動化フレームワークで、任意の言語(Python、JavaScript、Javaなど)でテストを書き、シミュレータや実機上のiOSアプリに対して実行できます。内部ではAppleのXCUITestドライバーを使用しています。
MacサーバーにAppiumをインストールする
# Install Node.js via Homebrew
brew install node
# Install Appium globally (npm installs the latest release)
npm install -g appium
# Install the XCUITest driver for iOS
appium driver install xcuitest
# Verify installation
appium --version
appium driver list --installed
# Check the driver's requirements (Xcode, WebDriverAgent, etc.)
appium driver doctor xcuitest
# Start Appium server
appium server --address 127.0.0.1 --port 4723
Desired Capabilitiesを構成する
# Example capabilities (JSON format, Appium 2 and later)
# The Simulator is picked by name; add "appium:platformVersion"
# to pin a specific iOS version
{
"platformName": "iOS",
"appium:automationName": "XCUITest",
"appium:deviceName": "iPhone 17",
"appium:app": "/path/to/MyApp.app",
"appium:noReset": false,
"appium:wdaStartupRetries": 3,
"appium:wdaStartupRetryInterval": 20000,
"appium:simulatorStartupTimeout": 120000
}
Appiumテストの例(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 17"
# Optional: options.platform_version pins a specific iOS version
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()
Appiumテストの例(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 17',
'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();
Safari向けSelenium
macOSには、Safariとsafaridriverが最初から含まれています。他のプラットフォームのChromeやFirefoxとは異なり、追加のブラウザドライバーのダウンロードは不要です。
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テスト(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テスト(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();
並列テスト実行
テストを並列に実行すると、テストスイート全体の実行時間を劇的に短縮できます。Mac Mini M4 Proは、4〜6個のSimulatorインスタンスを余裕をもって同時に実行できます。
XCTestの並列テスト
# Enable parallel testing with xcodebuild
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-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 17' \
-destination 'platform=iOS Simulator,name=iPhone 16e' \
-destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \
-parallel-testing-enabled YES \
-resultBundlePath ./TestResults/MultiDeviceParallel.xcresult
Simulatorインスタンスの管理
# List all available simulators
xcrun simctl list devices available
# Create custom simulator instances for testing
# (without a runtime argument, simctl uses the newest iOS runtime)
xcrun simctl create "Test-iPhone-1" "iPhone 17"
xcrun simctl create "Test-iPhone-2" "iPhone 17"
xcrun simctl create "Test-iPhone-3" "iPhone 17"
xcrun simctl create "Test-iPhone-4" "iPhone 17"
# 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"
並列テストの性能
| 構成 | UIテスト200件の所要時間 | 速度向上 |
|---|---|---|
| Simulator 1台(逐次) | 45分 | 基準 |
| Simulator 2台(並列) | 24分 | 1.9倍高速 |
| Simulator 4台(並列) | 13分 | 3.5倍高速 |
| Simulator 6台(並列) | 10分 | 4.5倍高速 |
注: 結果はMac Mini M4 Pro(14コア、24GB RAM)で測定しました。並列Simulatorの最適な数は、テストの複雑さとメモリ要件によって異なります。ほとんどのUIテストスイートでは、4つの並列ワーカーが速度と安定性の最良のバランスをもたらします。
CI/CD連携
自動テストをCI/CDパイプラインに組み込み、プッシュ、プルリクエスト、あるいはスケジュールされた間隔ごとに自動でテストを実行しましょう。
自動テストのためのGitHub Actionsワークフロー
# .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 * * *'
env:
# Xcode for every job, no sudo needed (path from: xcodes installed)
DEVELOPER_DIR: /Applications/Xcode-27.0.0.app/Contents/Developer
jobs:
unit-tests:
name: Unit Tests
runs-on: self-hosted
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- 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 17,OS=latest' \
-parallel-testing-enabled YES \
-resultBundlePath ${{ runner.temp }}/UnitTests.xcresult
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: ${{ runner.temp }}/UnitTests.xcresult
ui-tests:
name: UI Tests
runs-on: self-hosted
timeout-minutes: 60
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Boot simulators for parallel testing
run: |
xcrun simctl boot "iPhone 17" || true
xcrun simctl boot "iPhone 16e" || true
- name: Run UI tests in parallel
run: |
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-destination 'platform=iOS Simulator,name=iPhone 16e' \
-parallel-testing-enabled YES \
-parallel-testing-worker-count 4 \
-resultBundlePath ${{ runner.temp }}/UITests.xcresult
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: ${{ runner.temp }}/UITests.xcresult
- name: Cleanup simulators
if: always()
run: xcrun simctl shutdown all
CIでAppiumテストを実行する
# 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 'generic/platform=iOS Simulator'
- 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
テストレポート
テストレポートを生成・処理して、品質の傾向を追跡し、失敗しているテストを素早く特定しましょう。
xcresultバンドルを扱う
# Test results summary as JSON (Xcode 16 and later)
xcrun xcresulttool get test-results summary \
--path ./TestResults.xcresult
# Results of every test, then the details of one of them
# (TEST_ID: an identifier printed by the tests command)
xcrun xcresulttool get test-results tests \
--path ./TestResults.xcresult
xcrun xcresulttool get test-results test-details \
--test-id "TEST_ID" \
--path ./TestResults.xcresult
# Export all attachments (screenshots, logs)
xcrun xcresulttool export attachments \
--path ./TestResults.xcresult \
--output-path ./artifacts
# Merge multiple xcresult bundles
xcrun xcresulttool merge \
./UnitTests.xcresult \
./UITests.xcresult \
--output-path ./MergedResults.xcresult
JUnit XMLに変換する
JUnit XMLは、CI/CDプラットフォーム、Slack連携、テストダッシュボードでサポートされる汎用フォーマットです。
# Option 1: write JUnit XML while the tests run (xcbeautify)
brew install xcbeautify
set -o pipefail && xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-resultBundlePath ./TestResults/Tests.xcresult \
| xcbeautify --report junit --report-path ./reports
# Writes ./reports/junit.xml
# Option 2: convert existing .xcresult bundles with fastlane's
# trainer action (path: the folder that holds them)
fastlane run trainer path:"./TestResults" 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
失敗時のスクリーンショット
// 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()
}
}
ベストプラクティス
テストの分離
各テストは独立しているべきであり、前のテストの状態に依存すべきではありません。各テストの前にアプリの状態をリセットしましょう。
// 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の管理
状態のリークやディスク容量の問題を防ぐため、テスト実行の合間にシミュレータをクリーンアップしましょう。
#!/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"
速度のためにアニメーションを無効化する
UIテスト中はアプリのアニメーションを無効にして、テストの実行時間と不安定さを減らしましょう。
// 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
不安定なテストを再試行する
Xcodeに組み込まれたテスト再試行の仕組みを使って、ときおり不安定になるUIテストに対処しましょう。
# Run each failing test up to 3 times in total
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-retry-tests-on-failure \
-test-iterations 3 \
-resultBundlePath ./TestResults.xcresult
ディスク容量を監視する
シミュレータやテスト成果物は多くのディスク容量を消費します。自動クリーンアップをセットアップしましょう。
# 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
よくある質問
Mac Mini M4 Proは並列シミュレータをいくつ処理できますか?
VNCなしでテストをヘッドレスに実行できますか?
xcodebuildから起動されると"ヘッドレス"モードで動作します。テスト実行中にVNCを接続する必要はありません。VNCは、失敗したテストを視覚的にデバッグするときにのみ役立ちます。不安定なUIテストにはどう対処すればよいですか?
sleep()ではなく明示的な待機(explicit wait)を使います。Xcodeの-retry-tests-on-failureフラグを使って、失敗したテストを自動的に再試行します。専用macOSサーバーでは、共有CIマシンで不安定なテストの最も一般的な原因である、リソースの競合による不安定さがなくなります。リモートのMacから実際のiOSデバイスでテストできますか?
異なるiOSバージョンでのテストはどうですか?
xcodebuild -downloadPlatform iOSを使うか、Xcode > Settings > Componentsから古いランタイムをダウンロードします。そのうえで、テストのデスティネーションでOSバージョンを指定します: -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.0'。