Saltar al contenido
Development

Cierre de las brechas de pruebas: lógica pura, una rama simulada inalcanzable y un cambio a Swift 6

Por Victor Da Luz
iosswifttestingswift-concurrencydev-logdeep-cut-atlas

Esta app luego se renombró a Deep Cut Atlas. Más abajo se la llama “Discoverer” en todo momento, porque así se llamaba el día en que esto ocurrió.

Este era un issue de limpieza surgido de un spike anterior de revisión del repositorio, y resultó ser más interesante de lo que suele ser “agregar algunas pruebas.” Tres correcciones sin relación entre sí, pero dos de ellas encontraron errores reales que no se estaban buscando.

Problema

Se habían acumulado tres brechas en la suite de pruebas de Discoverer. El algoritmo de agrupación de álbumes de la playlist y una heurística de detección de EP estaban ambos enterrados dentro de métodos de servicio que llamaban a MusicKit, así que solo podían ejercitarse a través de todo el camino asíncrono de obtención de datos. El servicio simulado tenía un parámetro, libraryAlbumKeys, que ignoraba en silencio, ese parámetro es lo que le permite a la app distinguir entre “este álbum ya está en la biblioteca” y “ya está en la lista de reproducción,” dos mensajes muy distintos, y ninguna prueba podía demostrar que esa rama funcionara. Y el target de pruebas todavía compilaba bajo Swift 5, mientras que el target de la app venía en Swift 6 con aislamiento de Main Actor por defecto desde el primer día. Nadie había verificado nunca si las pruebas siquiera compilarían bajo las mismas reglas que sigue la app.

Por qué este enfoque

Para la primera brecha, la solución fue la extracción: sacar la parte pura del algoritmo (agrupar pistas por álbum, preservar el orden de primera aparición, recurrir al título de la pista cuando falta el título del álbum) a su propia función que toma valores simples, no tipos de MusicKit. El mismo movimiento para la heurística de EP: una vez que se reduce a (title, isCompilation, isSingle) -> RecordingType, no hace falta una llamada de red para probar cuatro ramas de una cadena if/else.

Para el simulado, no quería agregar un “interruptor de prueba” separado que pudiera desviarse del chequeo real. En cambio, hice que el addAlbumToPlaylist del simulado verificara el mismo parámetro libraryAlbumKeys que verifica el servicio real, en el mismo orden. Reflejar la lógica real en vez de simular un atajo fue lo que permitió que ocurriera la siguiente parte.

Implementación

La extracción fue mecánica. La parte interesante fue el simulado: en el momento en que se conectó el parámetro ignorado, dos pruebas que antes pasaban se rompieron.

Resultó que dos de los álbumes de muestra del simulado, “Mordechai” y “Oncle Jazz,” cumplían doble función. Ambos estaban en la “biblioteca” falsa (así que fetchLibraryAlbumKeys() devolvía sus claves) Y estaban registrados como “ya en la lista de reproducción” para un escenario de prueba completamente distinto. Esa superposición fue invisible mientras el simulado nunca verificara realmente la propiedad de biblioteca. En el instante en que lo hizo, ambos álbumes se volvieron genuinamente ambiguos: ¿están “ya en la biblioteca” o “ya en la lista”?

Verifiqué qué hace el servicio real en esa situación exacta, y siempre verifica la propiedad de biblioteca primero, de forma incondicional. Así que la solución no era parchear la ambigüedad, las pruebas dependían de un comportamiento que el simulado nunca debió tener. Cambié los fixtures de “ya en la lista” a dos álbumes distintos que no están en la biblioteca falsa, y ahora ambas pruebas afirman algo que es realmente cierto del código de producción.

El cambio a Swift 6 fue casi anticlimático: se cambió el target de pruebas al modo de lenguaje Swift 6 más aislamiento de Main Actor por defecto, igualando al target de la app, y se volvió a correr toda la suite. Cero errores nuevos. Las 163 pruebas siguieron pasando todas. A veces “simplemente funciona” es el resultado real, y eso solo se descubre activando de verdad el interruptor en lugar de suponerlo.

Contratiempos

Extraer la función de agrupación a su propio archivo se topó con un error de aislamiento de Swift 6 que ya había visto antes una vez, con otra forma: un struct simple sin anotación de actor hereda el aislamiento de Main Actor por defecto del módulo, lo que significa que su Equatable e inicializador sintetizados por el compilador también quedan aislados a Main Actor. Una función libre nonisolated no puede llamar a un inicializador aislado a Main Actor, incluso para un tipo de valor inofensivo sin ningún estado mutable compartido real. La solución es una sola palabra, marcar el tipo como nonisolated, pero eso solo se descubre al intentar compilar, y el mensaje de error no apunta obviamente a “el struct necesita una anotación,” apunta a “una conformidad está aislada a main-actor,” lo cual se lee raro la primera vez que se ve.

El segundo contratiempo fue autoinfligido: después de renombrar el fixture de “ya en la lista” de “Mordechai” (que da la casualidad de ser la pista #1 en la lista de fixtures) a un álbum distinto que es la pista #7, una prueba falló con un error de force-unwrap. El tamaño de página por defecto de la prueba solo carga las primeras 5 pistas. Cambiar el álbum del que depende una prueba no es un simple reemplazo de texto cuando el fixture tiene supuestos posicionales incorporados, tuve que aumentar el tamaño de página en ambas pruebas afectadas.

Resultados y lecciones

163/163 pruebas en verde, antes y después del cambio a Swift 6. La extracción le dio al algoritmo de agrupación de álbumes y a la heurística de EP sus propias pruebas unitarias enfocadas, completamente independientes de MusicKit y del simulador. Y conectar ese único parámetro simulado ignorado no solo agregó cobertura, expuso que dos pruebas existentes habían estado pasando por la razón equivocada.

Ese es el valor real del trabajo de “cerrar brechas de pruebas.” Cubrir más líneas es la métrica superficial, la ganancia real es lograr que la versión simulada del servicio se comporte lo suficientemente parecido a la real como para que una regresión genuina no pueda esconderse detrás de una coincidencia conveniente de fixtures.

Lecturas relacionadas