Tests en CI: paralelizar, simuladores y tests inestables
En integración continua la suite deja de ser una colección de pruebas y pasa a ser un sistema de producción con un presupuesto de tiempo, una tasa de fallo y un coste por ejecución. Esta lección trata la ejecución como ingeniería: cómo se reparte el trabajo entre planes de prueba, clones de simulador y máquinas, cómo se fija el entorno para que el simulador no sea la fuente del ruido, y qué hacer con las pruebas inestables, que no son un problema técnico sino un problema de política que erosiona la confianza de todo el equipo.
Una suite que en tu máquina tarda seis minutos y en integración continua tarda treinta y cinco no tiene un problema de máquina: tiene un problema de diseño de ejecución. Y una suite que falla al azar una vez de cada veinte ejecuciones no tiene un problema de pruebas: tiene un problema de gobierno, porque en cuanto el equipo aprende que el rojo puede no significar nada, todas las pruebas de la suite pierden su capacidad de detener un cambio malo, incluidas las buenas. Los dos asuntos de esta lección —tiempo de ciclo e inestabilidad— son en realidad el mismo asunto visto desde dos lados: qué hace falta para que el resultado de la suite sea una señal en la que alguien esté dispuesto a basar una decisión.
- Presupuestar el tiempo de ciclo y repartir la suite en planes de prueba con propósitos distintos.
- Paralelizar con clones de simulador dentro de una máquina y con reparto de objetivos entre varias.
- Fijar el entorno del simulador para que la variabilidad de la máquina no se confunda con defectos del código.
- Medir la inestabilidad, ponerla en cuarentena con una política explícita y decidir entre arreglar o borrar.
El tiempo de ciclo es el requisito, no el resultado
La primera decisión no es técnica sino de producto: cuánto tiempo puede transcurrir entre subir un cambio y saber si rompió algo. Diez minutos es el umbral por debajo del cual la gente espera el resultado; por encima de veinte, cambia de tarea y el coste del contexto perdido se suma a cada iteración. Ese número se fija primero y luego se diseña la ejecución para cumplirlo, no al revés.
La herramienta que lo hace posible son los planes de prueba, porque permiten que el mismo proyecto tenga varias suites con propósitos distintos y configuraciones distintas.
# En cada propuesta de cambio: rapido, determinista, sin interfaz.
xcodebuild test \
-scheme MiApp \
-testPlan Rapido \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath resultados.xcresult
# De noche o antes de publicar: recorridos completos e instantaneas.
xcodebuild test -scheme MiApp -testPlan Completo -destination ... -enableCodeCoverage YES
El reparto habitual usa tres planes. Uno rápido con toda la lógica y ninguna dependencia de simulador gráfico, que debe terminar en un par de minutos y se ejecuta en cada envío. Uno de fusión que añade instantáneas y pruebas de integración, ejecutado antes de incorporar el cambio a la rama principal. Y uno completo con los recorridos de extremo a extremo y las variantes de idioma y dispositivo, ejecutado por la noche o antes de una entrega, donde media hora ya no molesta a nadie. La ganancia no viene de ejecutar menos pruebas sino de ejecutarlas cuando su coste es asumible.
Buena parte del tiempo de una suite es compilación. Construir para pruebas una sola vez, archivar el producto y ejecutarlo después en varias máquinas evita repetir ese trabajo por cada partición. Los subcomandos build-for-testing y test-without-building existen exactamente para eso, y son la base de cualquier reparto entre máquinas que valga la pena.
Paralelizar en dos ejes
Hay dos formas de paralelizar y conviene no confundirlas, porque resuelven problemas distintos y se combinan.
Clones de simulador
Xcode clona el simulador y reparte las clases de prueba entre los clones dentro de la misma máquina. Es gratis de configurar y suele dar la mayor ganancia inicial.
Reparto entre máquinas
Dividir la suite en particiones con -only-testing y ejecutar cada una en un agente distinto. Escala más allá de los núcleos de una máquina, a cambio de tener que unir los informes.
Equilibrio por duración
Repartir por número de clases produce particiones desiguales. Repartir usando los tiempos de la ejecución anterior acerca el resultado al óptimo.
Aislamiento de recursos
Dos pruebas paralelas que escriben en el mismo fichero, puerto o almacén compartido fallan sin motivo. Cada una necesita su directorio temporal y su base de datos.
# Particion 2 de 4, sobre un producto ya compilado.
xcodebuild test-without-building \
-xctestrun MiApp_iphonesimulator18.2.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \
-only-testing:MiAppTests/CarritoTests \
-only-testing:MiAppTests/DescuentosTests \
-resultBundlePath particion2.xcresult
El paralelismo tiene un límite que casi nadie mide antes de chocar con él: cada clon de simulador consume memoria y núcleos, y pasado cierto punto los procesos compiten, los tiempos de espera se agotan por falta de recursos y aparecen fallos que no existen en ejecución secuencial. La regla práctica es subir el número de trabajadores de dos en dos y quedarse un escalón por debajo del punto donde empiezan a aparecer fallos nuevos. Un paralelismo agresivo que introduce inestabilidad es una pérdida neta, porque cada reejecución cuesta más de lo que ahorró el reparto.
El simulador merece además su propia disciplina. Fijar una versión de sistema y un modelo concretos, partir de un dispositivo recién arrancado y con datos borrados, desactivar animaciones y ajustar el modo de conectividad para que las pruebas no dependan de la red del edificio elimina una clase entera de fallos que se atribuyen erróneamente al código.
# Higiene del simulador antes de cada ejecucion en el agente.
xcrun simctl shutdown all
xcrun simctl erase all
xcrun simctl boot 'iPhone 16'
xcrun simctl bootstatus 'iPhone 16' -b # esperar a que termine de arrancar
Ese último comando es más importante de lo que parece: lanzar pruebas contra un simulador que aún está arrancando produce fallos de tiempo agotado en el primer caso de cada ejecución, que es exactamente el patrón que después se describe como inestabilidad misteriosa. Conviene también anclar la versión de las herramientas de línea de comandos en el agente, porque un cambio de versión de Xcode altera a la vez el compilador, el simulador y el suavizado de texto, y con él se mueven las instantáneas y los tiempos.
La inestabilidad es una política, no un misterio
Una prueba inestable es la que produce resultados distintos sin que el código haya cambiado. Su daño no es proporcional a su número: una sola basta para instalar en el equipo el hábito de reejecutar ante el rojo, y ese hábito anula el valor de todas las demás. Por eso el primer paso no es arreglarla sino medirla: sin una tasa de fallo por prueba, la discusión se vuelve una colección de anécdotas.
flowchart TB r[La suite se pone roja] --> q[Falla siempre al repetir] q --> si[Es un defecto real: arreglar el codigo] q --> no[Falla a veces: es inestable] no --> m[Registrar la tasa de fallo por prueba] m --> c[Cuarentena fuera del camino critico] c --> d[Diagnosticar la causa] d --> t[Tiempo: espera fija o carrera] d --> e[Estado compartido entre pruebas] d --> x[Recurso externo o red real] t --> f[Arreglar y devolver al plan rapido] e --> f x --> f c --> b[Sin arreglo en dos semanas: borrar la prueba]
La cuarentena tiene que ser un mecanismo explícito y con caducidad, no una etiqueta que dure para siempre. El marco moderno permite marcar una prueba con un rasgo que la desactiva dejando anotado el motivo y el asunto que la rastrea, y también admite envolver una comprobación que se sabe defectuosa para que su fallo no rompa la ejecución pero sí avise cuando deje de fallar.
@Test(.disabled("Inestable por carrera en la carga: ticket APP-482"))
func sincronizaAlVolverDeSegundoPlano() async { }
@Test func exportaElInformeMensual() async throws {
try await withKnownIssue("Falla en agentes lentos: ticket APP-517") {
try await sut.exportar()
}
}
Medir es barato y casi nadie lo hace. Basta con ejecutar la suite repetidamente en la máquina de integración, con reejecución activada, y recoger del paquete de resultados qué pruebas necesitaron un segundo intento.
# Instrumento de medida: ejecutar dos veces cada caso y anotar quien pasa a la segunda.
xcodebuild test -scheme MiApp -testPlan Rapido -destination ... \
-retry-tests-on-failure -test-iterations 2 \
-resultBundlePath medicion.xcresult
# Extraer el detalle para construir la tabla de tasa de fallo por prueba.
xcrun xcresulttool get test-results tests --path medicion.xcresult --format json
Con treinta ejecuciones nocturnas y esa extracción tienes una tabla ordenada por tasa de fallo, que es el único documento capaz de cerrar la discusión sobre si el problema es la máquina, la prueba o el código. Guardar además la duración de cada caso alimenta el reparto equilibrado de particiones de la sección anterior, de modo que la misma medición sirve para las dos decisiones.
La reejecución automática ante fallo es útil como instrumento de medida y peligrosa como remedio permanente. Ejecutar cada prueba dos veces y marcar como inestable la que pasa en el segundo intento da exactamente el dato que hace falta; dejar esa opción activada para que la rama principal se vea verde convierte la inestabilidad en invisible y garantiza que crezca. La política que sostiene la confianza cabe en tres frases: en la rama principal no se reejecuta, una prueba inestable sale del camino crítico el mismo día con un ticket asociado, y si en dos semanas nadie la arregló se borra. Borrar una prueba que nadie confía en arreglar no destruye ninguna garantía, porque esa garantía ya no existía; lo único que destruye es la ilusión de tenerla.
Antes de sospechar del hardware conviene descartar lo probable. La primera causa es el tiempo: una espera fija que basta en tu portátil y no en un agente con cuatro pruebas compitiendo. La segunda es el estado compartido: un almacén, un fichero, un valor por defecto o un objeto único que sobrevive entre casos y hace que el resultado dependa del orden, algo que el paralelismo destapa de golpe porque el orden deja de ser el que había. La tercera es el recurso externo: una llamada real a la red, un servidor de pruebas compartido o un reloj del sistema que cruza la medianoche durante la ejecución.
El error de gobierno más caro en integración continua consiste en optimizar el color del indicador en lugar de su contenido informativo. Todas las decisiones que aparecen tentadoras cuando la presión aprieta —reejecutar hasta que pase, subir los tiempos de espera, desactivar la comprobación que molesta, regrabar las instantáneas en bloque, aumentar los trabajadores hasta que la máquina jadea— comparten la misma estructura: mejoran la apariencia del resultado degradando su relación con la realidad. Y conviene ver por qué eso es tan destructivo en términos precisos: el valor de una suite no reside en las pruebas sino en la decisión que el equipo toma cuando se pone roja. Si la respuesta esperada ante un rojo es investigar, entonces cada prueba tiene poder de veto sobre un cambio malo y la suite entera es un activo. Si la respuesta esperada es volver a lanzar, ninguna prueba tiene ese poder, ni siquiera las cien que son perfectamente deterministas, porque su señal llega mezclada con ruido y nadie puede separarlas a simple vista. Eso significa que la fiabilidad de una suite no es la media de la fiabilidad de sus pruebas sino algo mucho más parecido al mínimo, y de ahí se deriva la conclusión que ordena todo lo anterior: es preferible una suite pequeña en la que todo el mundo cree que una suite exhaustiva que nadie se toma en serio. Por eso borrar pruebas inestables es un acto de ingeniería y no de rendición, y por eso el tiempo de ciclo es un requisito de primer orden y no una molestia: una señal que llega tarde o que llega sucia no es una señal peor, es, a efectos de las decisiones que alguien tomará con ella, ninguna señal.
- Mide el tiempo de tu suite completa, decide el presupuesto de ciclo que quieres y reparte las pruebas en un plan rápido y otro completo.
- Activa la ejecución paralela y sube los trabajadores por escalones hasta encontrar el punto donde aparecen fallos nuevos; quédate justo por debajo.
- Divide el plan lento en cuatro particiones equilibradas por duración usando los tiempos de la ejecución anterior y ejecútalas sobre un producto compilado una sola vez.
- Ejecuta la suite treinta veces seguidas y construye la tabla de tasa de fallo por prueba. Pon en cuarentena todo lo que no sea cero, con ticket y fecha.
- Toma la prueba más inestable de esa tabla, clasifica su causa entre tiempo, estado compartido o recurso externo, arréglala y demuéstralo repitiendo la medición.