Mis mocks no podían fallar: inyección de errores y un crash de aislamiento en Swift 6
Esta app se renombró después a Deep Cut Atlas. Abajo se la llama “Discoverer” en todo momento, porque así se llamaba el día que pasó esto.
Una revisión de código de mi proyecto paralelo de iOS sacó a la luz un hallazgo incómodo: cada rama de manejo de errores en la app tenía cero cobertura de pruebas. No una cobertura “delgada”. Cero.
La causa era simple. Mi servicio simulado, contra el que corren todas las pruebas de los view models, solo podía tener éxito. Cada método devolvía datos de muestra predefinidos. Así que cada rama catch { state = .failed(...) } en cada view model era código muerto en las pruebas. Una regresión que manejara mal los errores pasaría por CI en verde sin problema.
La costura
No quería un único interruptor global de “fallar todo”. Las pruebas de falla reales necesitan precisión: la primera página carga bien, y luego la segunda falla. El fetch tiene éxito, el write falla. Así que la costura es un set de identificadores de método:
enum MockMethod: Hashable {
case fetchLibraryArtists, fetchPlaylistContents, fetchRecentlyPlayed,
createPlaylist, addTracksToLibrary, removeTracksFromPlaylist // ...
}
var failingMethods: Set<MockMethod> = []
var injectedError: any Error = SimulatedError()
private func throwIfFailing(_ method: MockMethod) throws {
if failingMethods.contains(method) { throw injectedError }
}
Cada método del protocolo recibe una línea al inicio: try throwIfFailing(.fetchRecentlyPlayed). Una prueba que quiere que la paginación se rompa a la mitad hace esto:
await vm.load() // first page: fine
service.failingMethods = [.fetchRecentlyPlayed] // now the network "dies"
await vm.loadMore()
#expect(vm.state == .loaded) // the visible list survives
#expect(!vm.hasMore) // pagination ends quietly
La propiedad injectedError también se gana su lugar: si se cambia por CancellationError() se puede verificar que una carga cancelada no cambia la UI a un estado de falla ni cachea un feed a medio terminar. Ese camino salió del trabajo de cancelación del issue anterior y era imposible de probar hasta ahora.
Veintiséis pruebas nuevas después, los estados de falla, los toasts de error y el comportamiento de mantener el contenido cacheado ante una falla en segundo plano quedaron todos cubiertos.
Y entonces el test runner explotó
Uno de los archivos de prueba nuevos cubría un pequeño helper Array.chunked(into:). Función pura, sin estado, sin actores. Así que, a diferencia de cualquier otra suite del proyecto, no la marqué @MainActor. ¿Para qué haría eso?
Primera corrida completa: 20 pruebas fallidas, todas en exactamente 0.000 segundos, repartidas en archivos que no había tocado. Ese patrón olía menos a 20 bugs y más a un solo crash tumbando el runner, así que fui a buscar en ~/Library/Logs/DiagnosticReports/. El reporte de crash fue directo:
EXC_BREAKPOINT (SIGTRAP)
_dispatch_assert_queue_fail
closure #1 in Array.chunked(into:)
El proyecto se compila con el aislamiento de actores por defecto de Swift 6 fijado en MainActor. Bajo esa configuración, mi extensión “pura” de Array queda implícitamente @MainActor, el aislamiento viene de un build setting, no de nada visible en el código. Swift Testing corre las suites que no son MainActor en executors en segundo plano, así que mi única suite sin anotar llamó a una función MainActor desde el executor equivocado y el runtime hizo trap. Todo el proceso murió, y cada prueba en curso se reportó como fallida.
El arreglo fue una sola anotación: marcar la suite como @MainActor, igual que todas las demás. Resultó que esa convención del proyecto era estructural, no solo de estilo.
Lecciones
- Si los mocks no pueden fallar, el manejo de errores queda sin probar por construcción. Conviene construir la costura de fallas temprano, son 15 líneas.
- Un set con clave por método le gana a un flag global de falla. Las pruebas de falla más interesantes necesitan que algunas llamadas tengan éxito primero.
- Fallas masivas de pruebas en 0.000 segundos significan un runner crasheado, no regresiones masivas. Conviene leer el reporte de crash antes de “arreglar” veinte pruebas.
- En un proyecto con MainActor por defecto, que “este código se vea puro” no dice nada sobre su aislamiento. Conviene revisar el build setting antes de saltarse la anotación.
Lecturas relacionadas
Siete arreglos pequeños en Deep Cut Atlas, y un test que casi se borra
Un repaso del backlog de menor a mayor: portadas con ScaledMetric, un ícono de pulgar equivocado, chevrons muertos, y un test roto cuyo comentario describía una carrera que el arreglo había acotado pero no cerrado.
El error de sugerencias que sobrevivió porque mi propia investigación me mintió dos veces
Una instantánea congelada, una afirmación plausible de que 'el historial está a salvo' que se desmoronó en una segunda revisión escéptica, y la capa de fusión de llamadas que casi construí y que ya existía una capa más abajo.
El error que reporté estaba mal planteado, y el fixture que lo corrigió rompió otras cuatro pruebas
Una protección en tiempo de escritura para un error que no podía ocurrir, la exclusión en tiempo de lectura que en realidad se necesitaba, y cuatro pruebas silenciosamente dependientes de un conteo de fixtures.