Guía

Automatizar tests de UI de iOS con XCUITest en un Mac dedicado

Para automatizar XCUITest, ejecuta xcodebuild test con un destino de simulador y un -resultBundlePath, en un Mac con una sesión de usuario iniciada. Crea un simulador dedicado con xcrun simctl. Añade -parallel-testing-enabled YES para repartir los tests entre clones del simulador, y -retry-tests-on-failure para repetir los tests inestables. Ejecutamos cada comando de esta página en un Mac con Xcode 26.6 y el simulador de iOS 26.5.

Lo que necesitas antes

  • Un Mac con Xcode y al menos un runtime de simulador de iOS.
  • Un target de tests de UI en tu proyecto y un scheme compartido que lo pruebe.
  • Una sesión de escritorio iniciada. Los tests de UI manejan la app Simulator, así que lo que los ejecute tiene que vivir en esa sesión. Es decir, un agente de CI arrancado como LaunchAgent, no como LaunchDaemon. La plantilla de plist de Buildkite dice lo mismo: el modo GUI permite los tests de UI de Xcode, pero necesita una sesión iniciada.

1. Comprueba Xcode y los runtimes

xcodebuild -version
sudo xcodebuild -runFirstLaunch
xcrun simctl list runtimes
xcrun simctl list devicetypes | grep iPhone

-runFirstLaunch instala paquetes y acepta la licencia. Ejecútalo después de cada instalación o actualización de Xcode.

2. Crea un simulador solo para CI

Un dispositivo dedicado separa el CI de cualquier cosa que una persona haya abierto a mano. bootstatus -b lo arranca y espera a que esté listo.

UDID=$(xcrun simctl create "CI iPhone 17" "iPhone 17" com.apple.CoreSimulator.SimRuntime.iOS-26-5)
xcrun simctl bootstatus "$UDID" -b
echo "$UDID"

Usa el UDID en el destino: -destination "platform=iOS Simulator,id=$UDID". También vale un nombre, como name=iPhone 17,OS=26.5, pero dos dispositivos pueden tener el mismo nombre.

3. Ejecuta los tests de UI con un result bundle

rm -rf build/TestResults.xcresult
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination "platform=iOS Simulator,id=$UDID" \
  -derivedDataPath build/DerivedData \
  -resultBundlePath build/TestResults.xcresult

El result bundle guarda cada resultado de test, log, captura y fallo. Lee un resumen en la línea de comandos:

xcrun xcresulttool get test-results summary --path build/TestResults.xcresult

Imprime un JSON con los tests superados, fallidos y omitidos por dispositivo. Adjunta el bundle a tu ejecución de CI como artifact, comprimido con ditto -c -k --keepParent.

4. Compila una vez, prueba muchas

Separa el build de la ejecución de tests. Así puedes repetir un subconjunto sin volver a compilar.

xcodebuild build-for-testing -project MyApp.xcodeproj -scheme MyApp \
  -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath build/DerivedData

xcodebuild test-without-building -project MyApp.xcodeproj -scheme MyApp \
  -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath build/DerivedData \
  -only-testing:MyAppUITests/LoginTests/testSignIn

-only-testing acepta Target, Target/Class o Target/Class/method. -skip-testing funciona igual, pero al revés.

5. Ejecuta los tests en paralelo

xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
  -destination "platform=iOS Simulator,id=$UDID" \
  -parallel-testing-enabled YES \
  -parallel-testing-worker-count 2 \
  -resultBundlePath build/TestResults.xcresult

Xcode clona el simulador y reparte las clases de test entre los clones. Nuestro log mostraba tests ejecutándose en Clone 1 of iPhone 17. -parallel-testing-enabled anula el ajuste del scheme. En un Mac de 16 GB, empieza con 2 workers y mide antes de subirlo. Cada clon es un simulador completo en memoria. Para probar en varios tipos de dispositivo a la vez, indica varios destinos y define -maximum-concurrent-test-simulator-destinations.

6. Reintenta los tests inestables

xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
  -destination "platform=iOS Simulator,id=$UDID" \
  -retry-tests-on-failure \
  -test-iterations 3 \
  -resultBundlePath build/TestResults.xcresult

xcodebuild -help dice que un test fallido se repite hasta el número de iteraciones. Sin -test-iterations, el máximo es 3. Lo comprobamos con un test que falla una vez y luego pasa. La ejecución terminó en TEST SUCCEEDED. El resumen indicó 3 ejecuciones para 2 tests.

Así que un reintento pone en verde un test inestable, y el fallo queda oculto. Lee el result bundle en cada ejecución y lleva la cuenta de qué tests necesitaron un reintento. Añade -test-repetition-relaunch-enabled YES para repetir cada intento en un proceso nuevo. Para cazar un fallo intermitente, usa -run-tests-until-failure. No se puede combinar con -retry-tests-on-failure.

7. Pon límites a los tests atascados

-test-timeouts-enabled YES \
-default-test-execution-time-allowance 120 \
-maximum-test-execution-time-allowance 300

Añade estos flags al comando de test. Así, un test de UI que espera para siempre a un elemento falla al agotar su margen. Y no retiene la máquina hasta el timeout del CI.

8. Mantén limpios los simuladores entre ejecuciones

xcrun simctl shutdown "$UDID"
xcrun simctl erase "$UDID"
xcrun simctl delete unavailable

Erase restablece el contenido y los ajustes del simulador. Delete unavailable quita los dispositivos que el Xcode actual ya no soporta. Ejecútalo después de cada actualización de Xcode.

Cómo sobrevive a los reinicios

Los simuladores que creas se conservan tras los reinicios. Lo que tiene que volver es el agente de CI que ejecuta los tests. Ejecútalo como LaunchAgent, activa el inicio de sesión automático y arranca el simulador de CI al principio de cada job con bootstatus -b. Ese comando es seguro en un dispositivo que ya está arrancado.

Errores que nos encontramos y cómo arreglarlos

  • Unable to find a device matching the provided destination specifier. El nombre o la versión no existen. Revisa xcrun simctl list devices y la lista de runtimes.
  • xcodebuild: error: Existing file at -resultBundlePath. Borra el bundle anterior antes de cada ejecución.
  • Unable to erase contents and settings in current state: Booted. Apaga el simulador antes de borrarlo.

Por qué ayuda un Mac dedicado

Los tests de UI pasan mucho tiempo antes del primer toque. Esperan a compilar, a arrancar un simulador y a instalar la app. Una máquina que sigue encendida tiene DerivedData y un simulador arrancado listos. En nuestro benchmark compilamos la app de Wikipedia para iOS con Xcode 26.6. Un job tras un cambio pequeño tardó 27 segundos en un M6 en caliente. Tardó 269 segundos en un runner macos-26 recién creado de GitHub. Nuestro M6 tiene 12 núcleos de CPU, así que dos clones en paralelo aún dejan sitio para el build.

Cuándo basta con un CI alojado

Una suite pequeña que se ejecuta en unas pocas pull requests al día encaja con pagar por minutos. Con la tarifa de GitHub de $0.062 por minuto de macOS (comprobada en septiembre de 2026), un Mac a $139 se amortiza a partir de unos 2,242 minutos al mes. Por debajo, quédate en lo alojado. Si tienes que probar en muchos iPhone físicos, necesitas una nube de dispositivos. Un Mac mini ejecuta simuladores. ¿Tu suite en paralelo necesita más de 16 GB de memoria? Nuestros modelos M5 Pro tienen 48 o 64 GB, pero son en preventa, así que cuenta con una espera de una semana aproximadamente.

La guía del pipeline de CI/CD para iOS muestra dónde encajan los tests de UI en un pipeline completo. La calculadora hace las cuentas con tus propias cifras.

Preguntas frecuentes

¿xcodebuild tiene un flag para reintentar los tests fallidos?

+

Sí. -retry-tests-on-failure repite un test fallido hasta -test-iterations veces, o 3 por defecto. No se puede combinar con -run-tests-until-failure.

¿Cómo ejecuto tests de XCUITest en paralelo desde la línea de comandos?

+

Añade -parallel-testing-enabled YES y, si quieres, -parallel-testing-worker-count. Xcode clona el simulador y reparte las clases de test entre los clones.

¿Puede ejecutarse XCUITest en un Mac sin pantalla?

+

Necesita una sesión de usuario iniciada, porque los tests de UI manejan la app Simulator. Ejecuta el agente de CI como LaunchAgent y activa el inicio de sesión automático.

¿Cómo leo los resultados de xcodebuild test sin abrir Xcode?

+

Pasa -resultBundlePath y luego ejecuta xcrun xcresulttool get test-results summary --path sobre el bundle. Imprime los recuentos en JSON.

Guías relacionadas