Llevamos tres partes construyendo: una feature con efectos, composición y navegación y estado compartido. Falta la parte que sostiene todo lo anterior, que es cómo se testea esto de verdad, y la pregunta que importa antes de meter TCA en un proyecto: ¿vale la pena aquí?
El modelo exhaustivo
Ya vimos TestStore en la parte 1. Lo que no dije es la implicación completa de que sea exhaustivo por defecto: cada cambio de estado y cada efecto tienen que estar declarados en el test, o el test falla.
Eso significa que si mañana alguien agrega un contador de analíticas al reducer, todos los tests de esa feature fallan hasta que alguien decida conscientemente si ese cambio es correcto. La primera vez que me pasó pensé que era una molestia; hoy me parece la propiedad más valiosa del sistema, porque en una suite normal ese cambio pasa silencioso y nadie se entera hasta que produce un bug.
Cuando el test falla, TCA te muestra un diff entre el estado que declaraste y el real. No un XCTAssertEqual con dos structs impresas de golpe, sino la diferencia línea por línea.
Cuando lo exhaustivo estorba
Hay un caso donde esto se vuelve insoportable, y es en los tests de integración de features grandes. Si quieres verificar “el usuario inicia sesión y termina en la pantalla principal con sus datos cargados”, declarar los treinta cambios intermedios convierte al test en algo que se rompe con cada refactor.
Para eso está el modo no exhaustivo:
store.exhaustivity = .off
await store.send(\.login.submitButtonTapped)
await store.receive(\.login.response.success)
// Solo verificamos lo que nos importa del estado final
Con .off, los cambios que no declaras pasan en silencio. Puedes centrarte en el resultado y olvidarte del camino.
Hay una tercera opción que en la práctica es la mejor de las dos:
store.exhaustivity = .off(showSkippedAssertions: true)
El test pasa, pero la consola te muestra en gris todo lo que se saltó. Es una radiografía de lo que tu feature realmente hace, sin obligarte a declararlo. Útil también cuando heredas código ajeno y quieres entenderlo.
Mi regla es sencilla: exhaustivo para tests unitarios de una feature, no exhaustivo para tests de flujo que atraviesan varias.
El tiempo, controlado
Ya usamos ImmediateClock en la parte 1 para saltarnos el debounce. Pero a veces el tiempo es lo que quieres probar: un temporizador, un reintento con espera creciente, un auto-guardado cada treinta segundos.
Ahí entra TestClock, que no elimina el tiempo, lo pone bajo tu control:
@Test
func temporizadorCuenta() async {
let clock = TestClock()
let store = await TestStore(initialState: TemporizadorFeature.State()) {
TemporizadorFeature()
} withDependencies: {
$0.continuousClock = clock
}
await store.send(.iniciarTocado)
await clock.advance(by: .seconds(1))
await store.receive(\.tick) { $0.segundos = 1 }
await clock.advance(by: .seconds(1))
await store.receive(\.tick) { $0.segundos = 2 }
await store.send(.detenerTocado)
}
Ese test verifica dos segundos de comportamiento en microsegundos, de forma determinista. La alternativa clásica, XCTestExpectation con un waitForExpectations(timeout: 3), es lenta y es la fuente número uno de tests intermitentes en CI.
Una diferencia importante entre los dos relojes: ImmediateClock hace que todas las esperas terminen de inmediato, así que un temporizador infinito se volvería un bucle cerrado. TestClock solo avanza cuando tú se lo pides. Para cualquier cosa periódica, TestClock.
Efectos que no terminan
Un temporizador, una suscripción a notificaciones, un observador de conectividad: efectos que corren indefinidamente. Si un test termina con uno de esos vivo, TestStore falla, y con razón, porque significa que la feature deja trabajo huérfano.
Dos herramientas:
- Terminar el efecto en el propio test, como el
.detenerTocadodel ejemplo. Es lo preferible: además de limpiar, verifica que la feature sabe detenerse. await store.finish()cuando el efecto termina por su cuenta y solo necesitas esperar a que lo haga.
Y un consejo que viene de la documentación oficial: crea el store dentro de cada test, no como propiedad de la clase. Un store compartido entre tests arrastra estado y desactiva parte de las comprobaciones.
receive y los tiempos reales
store.receive(...) espera por defecto una décima de segundo a que llegue la acción. Si tu efecto depende de algo que no controlas del todo, puedes subirlo:
await store.receive(\.tick, timeout: .seconds(2)) { $0.contador = 1 }
Pero antes de subir un timeout, pregúntate por qué lo necesitas. Casi siempre es señal de una dependencia sin inyectar. Un timeout largo en un test es deuda: hoy pasa, y dentro de seis meses parpadea en CI los viernes por la tarde.
Comparativa: TCA frente a lo demás
Aquí toca ser honesto: cada una de estas opciones resuelve bien un conjunto distinto de problemas, y la elección depende del proyecto que tengas enfrente.
Frente a MVVM con @Observable
Desde iOS 17, un @Observable con propiedades y métodos async cubre muchísimo terreno. Es menos código, compila más rápido y cualquier desarrollador de Swift lo entiende sin leer documentación.
MVVM gana en velocidad para empezar, curva de aprendizaje, tiempos de compilación y facilidad para contratar.
TCA gana en estados imposibles (el switch exhaustivo y los enums de destino), cancelación de efectos, tests que cubren también los efectos, y navegación reproducible.
El punto de quiebre suele ser el testing. Si tu equipo no escribe tests de lógica, la mayor parte de lo que TCA cobra en ceremonia nunca se cobra de vuelta.
Frente a MVVM + Coordinators
Los coordinators resuelven navegación, que es exactamente el problema donde @Presents y StackState brillan. La diferencia es que un coordinator es un objeto con su propio ciclo de vida, o sea otra cosa que sincronizar, mientras que en TCA la navegación es un valor dentro del estado que ya tenías.
Si ya tienes coordinators funcionando y probados, migrar a TCA solo por navegación no compensa. Si estás empezando y sabes que habrá deep links y restauración de estado, TCA parte con ventaja.
Frente a arquitecturas Redux-like caseras
Mucha gente construye su propio “TCA ligero”: un store, un reducer, acciones. Funciona, y para apps medianas puede ser la decisión correcta.
Lo que se pierde no es el patrón, es la infraestructura alrededor: TestStore exhaustivo, la cancelación automática de efectos al descartar una pantalla, @Shared con locking, el sistema de dependencias con valores de test. Reimplementar eso bien lleva meses, y mantenerlo es un proyecto en sí mismo.
Cuándo TCA vale la pena
Después de todo esto, mi criterio:
Sí, cuando:
- La app tiene flujos complejos con muchos estados intermedios (onboarding, checkout, formularios largos).
- Hay concurrencia real: peticiones que se cancelan, se reintentan, se solapan.
- El equipo escribe tests de lógica y los valora.
- Habrá deep links, restauración de estado o navegación programática.
- El proyecto va a durar años y pasar por varias manos.
No, cuando:
- Es una app de una a cinco pantallas, sobre todo si son de lectura.
- Eres uno solo y no vas a escribir tests. La ceremonia de TCA se paga con tests; sin ellos solo queda la ceremonia.
- El equipo no tiene tiempo de aprender. La curva es real y a medias es peor que no usarlo.
- Los tiempos de compilación ya son un problema en el proyecto.
Lo que duele en producción
Para cerrar, las cosas que solo se aprenden viviéndolas:
- Los errores del compilador con macros son crípticos. Un tipo mal puesto dentro de un
@Reducerpuede producir un error que apunta a la línea equivocada. Se aprende a leerlos, pero los primeros meses cuestan. - Los tiempos de compilación crecen. Cada macro genera código, y con muchas features en un mismo módulo dividir el proyecto deja de ser opcional antes de lo que esperas.
- La librería se mueve. Como vimos en la parte 1, la 1.25 deprecó una parte importante de la API. Point-Free documenta cada migración muy bien, pero hay que dedicarle tiempo.
- Contratar y hacer onboarding es más lento. Un desarrollador iOS con experiencia entra a un proyecto MVVM en un día; a uno de TCA, en una o dos semanas.
Nada de esto es descalificador. Todo esto es el precio, y lo justo es conocerlo antes de pagarlo.
Y ahora, a construir
Con esto cierra la parte conceptual. Sabemos modelar una feature, componerla, navegar con estado, compartir datos y testear todo sin abrir el simulador.
Lo que sigue es lo que da la medida real: reconstruir la Pokédex de la serie anterior entera en TCA. La misma app, el mismo API y las mismas funcionalidades, pero con esta arquitectura. Ahí se ve, sobre código real y con todos sus casos borde, dónde TCA hace el trabajo por ti y dónde te cobra.
Ese es el siguiente proyecto de la casa.
Fuentes: Testing (docs) · swift-composable-architecture (GitHub)