Por que TCA es tan testeable
La testeabilidad de TCA no es una caracteristica que alguien anadio al final: es la sombra inevitable de tres decisiones tomadas desde el primer nivel. El reducer puro aisla la logica del mundo, el efecto como valor vuelve describible el trabajo asincrono y la dependencia controlada con `@Dependency` hace el entorno sustituible; juntas colapsan la feature entera a una funcion determinista que un test compara igual que se comparan dos numeros. Esta leccion sintetiza el track: conecta cada pieza de ceremonia con la capacidad concreta de testeo que habilita, defiende por que un reducer con dependencias inyectadas es literalmente una funcion pura y discute cuando el pago justifica la ceremonia y cuando no amortiza. Es el cobro final del cheque que firmaste al negarte a mezclar la logica con el mundo.
A lo largo de este track has pagado una cuenta larga: reducers que no pueden tocar el mundo, efectos que hay que devolver como valores en vez de simplemente ejecutar, dependencias que se declaran e inyectan en lugar de usarse a mano, navegación modelada como datos, macros y key paths por todas partes. Cada decisión tuvo un coste en ceremonia. Esta lección cobra el pago. Porque las tres primeras —reducer puro, efecto como valor, dependencia controlada— no son tres buenas prácticas independientes: son tres caras de una sola propiedad, el determinismo, y el determinismo es lo que hace que toda la lógica de una app TCA sea verificable con la exactitud que viste en el TestStore. La testeabilidad de TCA no es una característica que alguien añadió; es la sombra inevitable de haberse negado, desde el primer nivel, a mezclar la lógica con el mundo.
- Identificar los tres pilares del determinismo en TCA: reducer puro, efecto como valor y dependencia controlada.
- Explicar por qué un reducer con dependencias inyectadas es una función pura y, por tanto, trivialmente verificable.
- Conectar cada pieza de ceremonia con la capacidad concreta de testeo que habilita.
- Argumentar cuándo el pago justifica la ceremonia y cuándo no llega a amortizar.
Tres pilares, una sola propiedad
La testeabilidad no descansa sobre un truco sino sobre tres decisiones que solo funcionan juntas. Ninguna basta por sí sola: un reducer puro que ejecutara sus efectos al vuelo seguiría siendo intestable, y un efecto como valor que leyera el reloj real seguiría siendo no determinista. Encajadas, las tres cierran todas las fugas de indeterminación.
Reducer puro
La mutación vive en Reduce, una función de (inout State, Action) que no lee reloj, red ni azar. Su salida solo depende de sus entradas: misma acción sobre el mismo estado, mismo resultado. Eso es lo que permite predecir el estado siguiente en un test.
Efecto como valor
El trabajo asíncrono no se ejecuta dentro del reducer: se describe y se devuelve como un Effect. Un valor se puede inspeccionar, encolar y —en el TestStore— esperar y afirmar, en lugar de dispararse a espaldas del test.
Dependencia controlada
El mundo entra por @Dependency, con una versión viva y otra controlada. En un test sustituyes el reloj, los UUID y la red por valores fijos, y el último resquicio de no determinismo desaparece.
La pureza aísla la lógica, el efecto como valor vuelve describible lo asíncrono y la dependencia controlada hace el entorno sustituible. Cada pilar tapa una fuente distinta de indeterminación, y solo al taparlas las tres queda algo que se puede afirmar sin ambigüedad.
Un reducer es una función, y las funciones se comparan
Reunidos los tres pilares, un reducer tiene la forma (State, Action, Dependencias) -> (State, Effect). Y como las dependencias se inyectan, en un test las fijas: con ellas clavadas, el reducer se reduce a (State, Action) -> (State, Effect), es decir, una función pura. Testear una función pura es lo más viejo y sencillo del oficio: le das entradas y comparas salidas. Sin mocks de mocks, sin esperas, sin intermitencias.
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .incrementar:
state.cuenta += 1
return .none
case .guardarPulsado:
return .run { [cuenta = state.cuenta] send in
try await clienteAPI.guardar(cuenta)
await send(.guardado)
}
}
}
La rama pura muta y devuelve .none —un valor—; la rama con efecto devuelve .run, que es una descripción de trabajo, no el trabajo. Ambas ramas son salidas de una función cuyas únicas entradas son state, action y el clienteAPI inyectado. Fija esas tres entradas y las salidas quedan fijas. Esa fijeza es la testeabilidad: no hay nada que dependa del momento, de la máquina ni de la red.
Esa transparencia referencial es lo que el TestStore explota sin más ceremonia: si el estado siguiente es función de las entradas, el diff exhaustivo del nivel anterior tiene siempre una respuesta única y estable que afirmar. Y es la misma propiedad que regala la reproducción de bugs. Como una sesión no es más que una secuencia de acciones aplicadas sobre un estado inicial —y todo eso son valores—, grabar el guion de acciones que condujo a un fallo y volver a pasarlo reproduce el fallo idéntico, sin adivinar y sin no puedo replicarlo en mi máquina. El determinismo no sirve solo para que los tests pasen: sirve para que el pasado se pueda volver a vivir tal cual fue.
flowchart LR S[State] --> R[Reducer puro] A[Action] --> R D[Dependencias inyectadas] --> R R --> S2[State siguiente] R --> E[Effect como valor] E --> A2[Action de vuelta] A2 --> R style R fill:#89b4fa,color:#11111b style D fill:#f9e2af,color:#11111b
Lo que los mocks nunca dieron
El mundo anterior a este enfoque persiguió el mismo objetivo con otras herramientas y nunca llegó del todo. Para testear una clase inyectabas protocolos y escribías implementaciones falsas a mano —una clase mock por colaborador y por test— que se desviaban de la interfaz real en cuanto esta cambiaba. Lo asíncrono se domaba con XCTestExpectation y esperas reales, así que los tests eran lentos e intermitentes. El tiempo se fingía con trucos sobre DispatchQueue que se filtraban entre tests.
// El mundo de antes: un protocolo, un mock a mano y una espera real
protocol ClienteAPI { func cargar() async throws -> [Item] }
final class ClienteAPIMock: ClienteAPI {
var resultado: [Item] = []
func cargar() async throws -> [Item] { resultado }
}
func testCarga() {
let mock = ClienteAPIMock()
mock.resultado = [.demo]
let vm = ListaViewModel(cliente: mock)
let esperada = expectation(description: "carga")
vm.onCargado = { esperada.fulfill() }
vm.aparecer()
wait(for: [esperada], timeout: 1.0) // espera real: fuente de intermitencia
XCTAssertEqual(vm.items, [.demo])
}
La raíz del problema no eran las herramientas sino la forma de la cosa a testear: un objeto que hacía cosas, que solo podías observar desde fuera y nunca sostener como un valor. El movimiento de TCA es más profundo que un mejor tooling: cambia esa forma. Lo que está bajo test deja de ser un objeto que ejecuta efectos y pasa a ser una función que devuelve descripciones de efectos.
// El mismo test en TCA: sin protocolo falso, sin mock, sin espera
await store.send(.aparecer) { $0.cargando = true }
await store.receive(\.datosRecibidos) {
$0.cargando = false
$0.items = [.demo]
}
Una vez el comportamiento es un valor, no lo simulas con un mock, no esperas a que ocurra, no falsificas el tiempo a su alrededor: le das entradas y comparas salidas. Los mocks eran un parche para una forma que se resistía al test; TCA no mejoró el parche, eliminó la resistencia.
La ceremonia y su dividendo
El coste es real y TCA no lo esconde: macros, key paths, indirección, una curva de aprendizaje empinada. La pregunta honesta no es si hay ceremonia —la hay— sino qué compra. Y compra, pieza por pieza, capacidades de testeo concretas.
- El reducer puro compra la aserción de estado exacta, campo a campo.
- El efecto como valor compra el
receivedeterminista, sin esperas reales. - La dependencia controlada compra un tiempo, un azar y una red sustituibles.
- La composición de los niveles anteriores compra tests por feature en aislamiento.
- La navegación como datos compra el testing de flujos como simple comparación de valores.
Un impuesto se paga y no vuelve; una inversión se paga y rinde. La ceremonia de TCA es lo segundo, y como toda inversión tiene un punto de amortización. En lógica compleja, de vida larga y donde la corrección importa —flujos de varios pasos, dinero, apps grandes con muchas features—, el coste inicial se diluye entre cientos de tests fiables y años de refactors sin miedo: amortiza de sobra. En un prototipo desechable, una pantalla casi sin lógica o un equipo que no sostendrá la disciplina, el pago no llega a rendir antes de que la app muera o la práctica se abandone. La madurez no es aplicar TCA siempre ni nunca, sino saber leer de qué lado del punto de amortización cae lo que tienes delante.
Casi toda arquitectura construye primero para el camino feliz y atornilla los tests después, para descubrir que es casi imposible, porque el mundo —reloj, red, azar, navegación— quedó soldado por todas partes y ya no hay por dónde sustituirlo. TCA hizo lo contrario: tomó como restricción de diseño, desde la primera línea, que el mundo fuera siempre sustituible, y dejó que todo lo demás se dedujera de ahí. Por eso la testeabilidad no es una característica de TCA; es la consecuencia de una negativa. Cada ceremonia que pagaste fue un abono al mismo fondo: el reducer no toca el mundo para que su salida sea predecible; el efecto es un valor para que el trabajo asíncrono sea inspeccionable; la dependencia se inyecta para que el entorno sea elegible; la navegación es un dato para que un flujo se afirme comparando estados. Y el dividendo no es solo que los tests salgan verdes. Es que puedes refactorizar sin miedo, porque el test exhaustivo es una especificación completa que se rompe al primer desvío. Es que puedes reproducir cualquier bug, porque estado y acción son valores que se serializan y se vuelven a reproducir. Es que puedes construir una feature aislada en una preview, abrir la app en cualquier pantalla por deep link, viajar en el tiempo por el historial de acciones. Todas esas capacidades son la misma propiedad —el comportamiento de la app es una función determinista de valores— vista desde ángulos distintos. La ceremonia famosa es el precio de entrada a un mundo donde el comportamiento de tu software es una cosa que puedes sostener en la mano, inspeccionar, guardar, reproducir y verificar, en vez de una niebla que solo existe mientras la app corre. Ese es el pago que justifica todo: dejas de esperar que tu código sea correcto y empiezas a saberlo.
- Toma una pieza de lógica que hoy viva en un view model o controlador con servicios inyectados, temporizadores y callbacks. Enumera cada razón por la que es difícil de testear ahora: reloj real, red real, efectos disparados en línea.
- Redáctala como reducer: mueve la mutación a un
Reducepuro, convierte cada efecto secundario en unEffectdevuelto y esconde cada impureza tras un@Dependency. - Para cada uno de los tres pilares, nombra la capacidad de test concreta que desbloqueó en tu reescritura: aserción de estado exacta,
receivedeterminista, mundo sustituible. - Estima el coste de la ceremonia en líneas y conceptos frente al número de tests fiables que hizo posibles. Decide con honestidad si esta feature caía del lado que amortiza.
- Anota un tipo de app o de feature donde no pagarías esta ceremonia, y defiende por qué el dividendo no llegaría a rendir ahí.