Qué testear en una app: la pirámide realista
La cobertura mide qué líneas se ejecutaron, no qué afirmaciones quedaron fijadas, y por eso es una guía pésima para decidir en qué invertir el presupuesto de pruebas. Esta lección reconstruye la pirámide de tests como lo que realmente es en una app Apple: una consecuencia aritmética de que los tres estratos difieren en órdenes de magnitud en tiempo de ciclo, determinismo y coste de mantenimiento. La lógica se prueba siempre, la vista con moderación y bajo condiciones estrictas, y el sistema operativo casi nunca.
Casi todos los equipos contestan a la pregunta de qué testear con un número: un porcentaje de cobertura pactado en una reunión y vigilado por una comprobación automática. Es la peor respuesta disponible, porque la cobertura mide qué líneas se ejecutaron durante la suite y no qué afirmaciones quedaron fijadas por ella; una prueba que llama a todo el sistema y no comprueba nada puntúa igual que una que clava una regla de negocio. La pregunta correcta es otra: qué error concreto impide esta prueba que llegue a producción, cuánto tarda en decírmelo y cuánto costará mantenerla viva durante los tres años que dure el producto. La pirámide no es una estética heredada de la industria: es la forma que adopta ese cálculo cuando se aplica a una app Apple.
- Formular la decisión de escribir una prueba como un balance entre coste de fallo, coste de escritura y coste recurrente de mantenimiento.
- Situar cada prueba en uno de los estratos de una app Apple conociendo su tiempo de ciclo y su grado de determinismo.
- Reconocer las dos inversiones típicas de la pirámide y el daño concreto que produce cada una.
- Delimitar qué pertenece al sistema operativo y por tanto no se prueba, y cuáles son las tres excepciones legítimas.
La cobertura mide ejecución, no verificación
Conviene ver el problema con dos pruebas que producen exactamente la misma métrica de cobertura y valen cosas radicalmente distintas.
import Testing
// Ejecuta todo el camino. No fija nada.
@Test func seCreaElCarrito() {
var carrito = Carrito()
carrito.anadir(Articulo(precio: 100), cantidad: 3)
#expect(carrito.articulos.isEmpty == false)
}
// Ejecuta las mismas lineas. Fija una regla que alguien pactó.
@Test func elDescuentoPorVolumenSeAplicaDesdeTresUnidades() {
var carrito = Carrito()
carrito.anadir(Articulo(precio: 100), cantidad: 3)
#expect(carrito.total == 270)
}
La primera sobrevive a cualquier cambio en el cálculo del descuento; la segunda se pone roja en cuanto alguien lo toca sin querer. Una prueba vale exactamente lo que vale el conjunto de cambios erróneos que rechaza, y ese conjunto no es observable en ninguna métrica de líneas. De ahí la única heurística de inversión que resiste el uso: escribe la prueba cuando la regla que fija es una decisión, no una consecuencia mecánica del compilador.
El segundo término del balance es más incómodo, porque no se paga una vez sino cada semana. Una prueba tiene coste de escritura —minutos, se olvida— y coste de mantenimiento: cada vez que el código legítimamente cambia, alguien debe decidir si el rojo es un fallo o una expectativa caducada. Ese coste se multiplica por la vida del proyecto y por la frecuencia con que la zona cambia, y es el que arruina las suites: no mueren por falta de pruebas sino por exceso de pruebas que nadie sabe interpretar y que se acaban borrando en bloque.
Conviene además separar dos motivos distintos para escribir una prueba, porque no se justifican igual ni envejecen igual. Una prueba de especificación nace antes o junto al código y describe lo que el sistema debe hacer: es documentación ejecutable y su valor principal es de diseño, porque escribirla obliga a decidir la firma, los errores y los casos frontera antes de implementarlos. Una prueba de regresión nace después de un defecto y su único cometido es que ese defecto no vuelva; su valor es de seguro y se mide en la probabilidad de reincidencia. Las dos merecen existir, pero solo la segunda se justifica sola: para escribir una de regresión basta con que el error haya ocurrido una vez.
// Regresion: nace de un fallo concreto y lleva su rastro en el nombre.
@Test func elTotalNoSeRedondeaHaciaArribaEnLaSegundaMoneda() {
var carrito = Carrito(moneda: .yen) // el yen no tiene decimales
carrito.anadir(Articulo(precio: 105), cantidad: 1)
carrito.aplicar(cupon: .porcentaje(10))
#expect(carrito.total == 94) // APP-311: antes devolvia 95
}
Anotar en la prueba el identificador del incidente que la originó parece burocracia y es lo contrario: cuando dentro de dos años alguien la vea ponerse roja, esa línea es la diferencia entre entender qué se está defendiendo y borrarla por incómoda.
En cuanto un número se convierte en objetivo, deja de medir lo que medía. Un umbral de cobertura obligatorio en la integración continua se satisface siempre, y la vía más barata para satisfacerlo es escribir pruebas que ejecutan mucho y afirman poco. El resultado observable es una suite grande, lenta, verde y sin capacidad de rechazo. Usa la cobertura como lo que sirve: un mapa para encontrar zonas sin ninguna prueba, nunca como una nota que haya que aprobar.
Los tres estratos y lo que cuesta cada uno
Una app Apple no tiene un continuo de pruebas sino tres regímenes físicos separados, y la distancia entre ellos es de órdenes de magnitud. Confundirlos es el origen de casi todas las suites insostenibles.
Lógica · milisegundos
Tipos de valor, reductores, formateadores, validaciones, decodificación, repositorios con dependencias sustituidas. Corren en el proceso de pruebas, sin simulador, sin actor principal y sin pantalla. Miles de casos por segundo y determinismo total.
Composición · décimas
El modelo observable junto a su vista, o una vista junto a su instantánea de referencia. Necesitan el entorno gráfico y por tanto el simulador, y son sensibles a fuentes, escala y versión del sistema. Pocas decenas por segundo.
Extremo a extremo · segundos
XCUITest maneja la app desde otro proceso mediante accesibilidad. Es la única capa que prueba el arranque real, los permisos y la navegación completa. Cada caso cuesta segundos y toda su fragilidad es asíncrona.
Sistema · no se prueba
Que List recicle celdas, que URLSession respete un tiempo de espera o que SwiftData persista un objeto son responsabilidad de Apple. Probarlo consume presupuesto y no rechaza ningún cambio tuyo.
La forma de la pirámide se deriva sola de esa tabla. Si un caso de extremo a extremo cuesta cuatro segundos y uno de lógica cuesta un milisegundo, el mismo minuto de suite compra quince casos de interfaz o sesenta mil de lógica. Y como el diagnóstico de un fallo en el estrato alto exige reproducir un flujo entero mientras el del estrato bajo señala una función, la asimetría no está solo en el tiempo de ejecución sino en el tiempo humano que consume cada rojo.
Hay un tercer factor que la comparación de tiempos oculta y que decide la forma final: la localización del fallo. Cuando falla una prueba de lógica, el conjunto de causas posibles es una función y sus dependencias inmediatas. Cuando falla un recorrido de extremo a extremo, el conjunto de causas posibles es la aplicación entera más el simulador más la red más el orden de ejecución. La información que aporta un rojo es inversamente proporcional al tamaño de ese conjunto, de modo que la misma cantidad de minutos invertida abajo compra no solo más casos sino casos que señalan el defecto en lugar de anunciarlo.
flowchart TB d[Que estoy verificando] --> l[Es una regla o un calculo mio] l --> t1[Prueba de logica: rapida y determinista] d --> c[Es como se compone el estado con la vista] c --> t2[Prueba de composicion o instantanea] d --> f[Es un recorrido completo del usuario] f --> t3[Prueba de extremo a extremo con XCUITest] d --> s[Es comportamiento del sistema operativo] s --> t4[No se prueba salvo contrato frontera o regresion] t1 --> b[Presupuesto: la mayoria] t2 --> m[Presupuesto: moderado] t3 --> p[Presupuesto: los caminos criticos y nada mas]
El estrato intermedio es el peor entendido de los cuatro, porque no consiste en probar vistas sino en probar la unión entre el estado y lo que se muestra, que es donde vive la mitad de los defectos de una app declarativa. Una prueba de ese estrato ejercita el modelo observable y comprueba la secuencia de estados que la vista consumirá, sin renderizar nada.
@Test @MainActor func laBusquedaPasaPorCargandoAntesDeMostrarResultados() async {
let sut = BuscadorModel(repositorio: RepositorioFalso(resultados: [.ejemplo]))
let recorrido = await sut.estadosDurante { await sut.buscar("swift") }
#expect(recorrido == [.inactivo, .cargando, .resultados(1)])
}
Fijar la secuencia y no solo el estado final es lo que impide el defecto clásico de la interfaz declarativa: la pantalla que llega al resultado correcto pero parpadea, muestra el estado vacío durante un instante o deja el indicador girando después de terminar.
Las dos deformaciones clásicas tienen nombre y daño distinto. El cono de helado aparece cuando el equipo no puede aislar la lógica y acaba probándolo todo por la interfaz: la suite tarda cuarenta minutos, falla al azar dos veces por semana y nadie confía en su rojo. El reloj de arena aparece cuando hay muchas pruebas unitarias y muchas de extremo a extremo pero nada en medio: los componentes funcionan por separado, el sistema completo también, y los errores viven exactamente en la integración que nadie observa.
El sistema casi nunca, salvo tres excepciones
La regla por defecto es simple: no pruebes código que no puedes arreglar. Comprobar que un Text aparece, que el gestor de persistencia guarda o que el reproductor reproduce solo verifica que instalaste bien el sistema operativo, y esa prueba se rompe cuando Apple cambia algo que tú no controlas, produciendo un rojo que no admite ninguna acción correctiva por tu parte. Hay, sin embargo, tres casos donde cruzar la frontera es correcto.
El primero es la prueba de contrato en la frontera: no verificas el sistema, verificas tu adaptador contra él. Decodificar una carga real guardada como recurso fijo comprueba tu modelo de datos, no el decodificador de Apple. El segundo es la prueba de migración: abrir un almacén creado con el esquema anterior y confirmar que el nuevo lo lee es la única forma de descubrir antes que el usuario que perdiste sus datos. El tercero es la prueba de caracterización: cuando dependes de un comportamiento del sistema que no está documentado, una prueba pequeña que lo fija te avisa el día que cambia, y ese aviso vale precisamente porque el cambio no dependía de ti.
// No prueba el decodificador de Apple: prueba TU modelo contra una carga real.
@Test func decodificaLaRespuestaDeCatalogoDeProduccion() throws {
let datos = try Datos.recurso("catalogo-2026-05.json")
let catalogo = try JSONDecoder.api.decode(Catalogo.self, from: datos)
#expect(catalogo.articulos.count == 42)
#expect(catalogo.articulos.first?.precio == Decimal(string: "19.99"))
#expect(catalogo.actualizado == Date(timeIntervalSince1970: 1_780_000_000))
}
Esa prueba cuesta un milisegundo, no toca la red y es la única defensa barata que existe contra el cambio silencioso de un contrato ajeno: el día que el servidor renombra un campo o convierte un número en cadena, el rojo aparece en tu suite y no en la reseña de la tienda.
Un buen indicador para decidir es preguntarse quién es el sujeto de la afirmación. Si la frase que la prueba defiende empieza por el nombre de un tipo tuyo, la prueba es tuya y merece existir. Si empieza por el nombre de un tipo de Apple, estás escribiendo la suite de regresión de otra empresa con tu presupuesto. La misma pregunta resuelve la duda con librerías de terceros: prueba tu envoltorio, nunca la librería.
Merece la pena entender de dónde sale realmente la forma de la pirámide, porque la explicación habitual —los tests unitarios son más rápidos— es cierta y superficial. La variable que ordena los estratos no es la velocidad sino el determinismo: la probabilidad de que dos ejecuciones idénticas den el mismo resultado. Una función pura tiene determinismo uno por construcción; una vista renderizada depende de fuentes, escala, tema y versión del sistema, y su determinismo es alto pero condicionado a fijar todas esas variables; una prueba de extremo a extremo depende de temporizadores, animaciones, red, planificación de hilos y carga de la máquina, y su determinismo es estructuralmente menor que uno por mucho cuidado que pongas. Ahora bien, el valor de una prueba no es lineal en su determinismo: es discontinuo. Una prueba que falla al azar una vez de cada cien no vale la centésima parte de una fiable, vale cero o menos, porque enseña al equipo a reejecutar ante el rojo, y ese hábito destruye el valor de todas las demás pruebas de la suite al mismo tiempo. Ahí está la razón profunda de que la base sea ancha y la cima estrecha: no se trata de que arriba las pruebas sean caras, sino de que su fiabilidad se degrada con la cantidad, mientras que abajo se mantiene constante. De esa asimetría se siguen las tres lecciones siguientes con necesidad casi lógica. Si el determinismo es la moneda, entonces testear la lógica consiste en construir superficies deterministas donde no las había —inyectar el reloj, la red y el azar—, testear la vista consiste en congelar todo lo congelable antes de comparar píxeles, y testear en integración continua consiste en administrar lo que queda de indeterminismo cuando ya no se puede eliminar más. No son tres técnicas distintas: son tres tramos del mismo esfuerzo por hacer repetible un sistema que por defecto no lo es.
- Ejecuta tu suite midiendo el tiempo de cada objetivo por separado y clasifica cada prueba en uno de los cuatro estratos. Dibuja la figura resultante y ponle nombre.
- Elige tres pruebas al azar y escribe, para cada una, la frase exacta que defienden. Borra las que no admitan una frase con un sujeto tuyo.
- Localiza una prueba que solo aporte cobertura y conviértela en una que fije una decisión de negocio, o elimínala.
- Identifica el bloque de comportamiento del sistema operativo que estés probando sin darte cuenta y calcula cuánto tiempo de suite consume al mes.
- Escribe una prueba de contrato para tu frontera de red usando una carga real guardada como recurso y comprueba que falla si cambias un nombre de campo del modelo.