Diseñar buenas dependencias: granularidad, frontera y fidelidad
Registrar una dependencia es fácil; diseñarla bien decide si tu suite de tests dice algo verdadero. Esta lección trata las tres decisiones que importan: la granularidad correcta entre el cliente-dios y el cliente por función, la frontera que impide que URLResponse, Data o los errores del SDK se filtren al dominio, y la obligación de testear la implementación viva aparte, porque el testing exhaustivo solo demuestra corrección relativa al modelo del entorno que asumiste.
Las cuatro lecciones anteriores te dieron el mecanismo completo: la struct de closures, la llave, la tríada de valores y la macro. Con eso ya puedes registrar cualquier cosa, y ahí empieza el problema de verdad, que no es mecánico sino de diseño. Una dependencia mal dimensionada convierte cada test en un ritual de siete sobrescrituras; una dependencia que deja escapar Data y códigos de estado al dominio contamina tu reducer con vocabulario de HTTP; y una implementación viva que nadie ejercita jamás es código sin probar que sostiene, en silencio, todas tus demostraciones. Esta lección es la que separa un proyecto que usa dependencias de uno que las diseña, y su tesis es incómoda: la calidad de tu testing no la fija el TestStore, la fija la fidelidad de la frontera que dibujaste.
- Dimensionar una dependencia por capacidad, evitando el cliente-dios y el cliente por función.
- Impedir que tipos de red, persistencia o SDK de terceros se filtren a la firma del cliente.
- Traducir errores de infraestructura a errores del dominio en el borde y no en el reducer.
- Diseñar pruebas de la implementación viva y entender qué garantía añaden a la suite exhaustiva.
Granularidad: la unidad es la capacidad
El error de bulto es el cliente-dios: un único valor con treinta endpoints que abarca red, disco, notificaciones y analítica. Su síntoma es inconfundible: cada test empieza sobrescribiendo media docena de campos que no tienen nada que ver entre sí, y cualquier cambio en una zona obliga a recompilar features que no la usan. El error opuesto es menos común y también real: un cliente por cada llamada individual, con lo que el ámbito de sobrescritura se llena de diez dependencias que siempre aparecen juntas y que, por tanto, eran una sola.
Entre ambos extremos hay una asimetría que conviene conocer: unir dos dependencias que resultaron ser una sola es una refactorización mecánica de diez minutos, mientras que partir un cliente-dios que ya está referenciado por treinta features es un proyecto. Ante la duda, empieza más granular de lo que crees necesitar; el error barato es el de fragmentar.
La regla que funciona es dimensionar por capacidad del dominio, no por tecnología ni por pantalla. Un ClienteArticulos que carga, busca, guarda y borra es una capacidad. Un ClienteHTTP genérico no lo es: es una tecnología, y usarlo como dependencia significa que en el test tendrás que simular rutas y cuerpos en vez de intenciones. Una DependenciaPantallaAjustes tampoco lo es: es una pantalla, y morirá con ella.
| Señal | Diagnóstico | Corrección |
|---|---|---|
| Diez sobrescrituras en cada test | Cliente-dios | Partir por capacidad |
| Tres clientes siempre juntos | Fragmentación | Unificarlos en uno |
| Un cliente por pantalla | Frontera equivocada | Reorganizar por dominio |
| El test simula rutas y cuerpos | Dependencia técnica | Subir el nivel de abstracción |
Hay una prueba sencilla para saber si acertaste con el tamaño: intenta describir la dependencia en una frase que no mencione ninguna tecnología. Sabe leer y escribir artículos pasa la prueba; envuelve la API REST de artículos no la pasa, y su fallo anticipa que el día que ese servicio se sustituya por otro tendrás que tocar features en vez de tocar un liveValue. Esa frase es también el mejor nombre para el tipo, porque un nombre que describe la capacidad envejece con el dominio y no con el proveedor.
Conviene recordar que TCA ya vivió el extremo grande. Durante años la librería enhebraba un único tipo Environment por todo el árbol de reducers, y componer features exigía proyectarlo con transformaciones tediosas; la migración a @Dependency fue, entre otras cosas, la renuncia explícita al entorno monolítico en favor de muchas capacidades pequeñas, cada una accesible solo por quien la nombra.
La frontera: el dominio no habla HTTP
La firma del cliente es el punto exacto donde termina la infraestructura y empieza tu dominio. Todo lo que cruce esa línea contamina lo que hay al otro lado, y contamina de forma irreversible, porque los tipos filtrados se propagan hacia arriba por el Action y acaban dentro del State.
// Filtra: el reducer acaba conociendo Data, codigos de estado y URLError.
var cargar: @Sendable (URLRequest) async throws -> (Data, URLResponse)
// Frontera limpia: el dominio pide artículos y recibe artículos.
var cargar: @Sendable (_ pagina: Int) async throws -> [Articulo]
La versión de abajo obliga a que la decodificación, el manejo de códigos de estado y la construcción de la petición vivan en el liveValue, que es donde deben vivir. La de arriba desplaza todo eso al reducer, donde se vuelve lógica de negocio aparente, se testea con datos JSON crudos y ata tu feature a un protocolo de transporte que cambiará antes que tu dominio.
Los errores merecen la misma disciplina. Dejar salir un URLError significa que el reducer tendrá que interpretarlo para decidir si muestra sin conexión o inténtalo más tarde, lo que es exactamente la traducción que el borde debía haber hecho. Define un error del dominio con los casos que a tu interfaz de verdad le importan y traduce dentro de la implementación viva.
enum ErrorArticulos: Error, Equatable {
case sinConexion
case noEncontrado
case servidor(reintentable: Bool)
}
Un Equatable en ese error no es un capricho: es lo que permite que el TestStore afirme el caso exacto de fallo sin comparar mensajes de texto. Y fíjate en que los casos elegidos son los que la interfaz distingue, no los que el transporte produce: agrupar veinte códigos de estado en servidor con reintento posible es precisamente el trabajo de traducción que justifica que la frontera exista.
Escribe primero la firma que tu dominio querría tener, como si el servicio ideal existiese, y solo después implementa el liveValue que la satisface con lo que hay. Ese orden —dominio primero, infraestructura después— es lo que impide que la comodidad del SDK dicte la forma de tu aplicación, y es también lo que hace que cambiar de proveedor sea reescribir un valor en lugar de renegociar veinte tipos repartidos por las features.
Los modelos que cruzan la frontera merecen la misma vigilancia que las firmas. Si el tipo que devuelve el cliente es el mismo que decodifica el JSON, con sus campos opcionales, sus nombres del servidor y sus fechas en texto, entonces la frontera no existe aunque la firma lo aparente. Mantén un tipo de transferencia interno al liveValue y un modelo del dominio limpio, y traduce entre ambos en el borde; el coste es un archivo pequeño y aburrido, y el beneficio es que un cambio de esquema deja de propagarse a tu State.
flowchart LR R[reducer y dominio] --> P[cliente como frontera] P --> LV[implementacion viva] LV --> SDK[URLSession y decodificacion y SDK] SDK --> LV LV -->|traduce a error de dominio| P style P fill:#89b4fa,color:#11111b style SDK fill:#f38ba8,color:#11111b
Más allá de la red: el resto del mundo también entra por aquí
Es fácil pensar en dependencias solo cuando hay un servidor de por medio, y esa estrechez deja fuera la mitad de la impureza de una app. Cualquier capacidad del sistema que tu lógica consulte o afecte merece el mismo tratamiento, con la misma frontera y el mismo vocabulario de dominio.
Persistencia
Expón guardarBorrador y cargarBorrador, no un contexto de base de datos. El reducer no debe conocer identificadores de fila, transacciones ni modelos gestionados: pide y recibe valores de su propio dominio.
Analítica
Un solo endpoint que acepte un evento de tu dominio basta. En tests podrás afirmar exactamente qué eventos se emitieron, algo imposible cuando el SDK se llama directamente desde la vista.
Permisos y sistema
Notificaciones, ubicación o cámara se modelan como capacidades con estados propios —concedido, denegado, sin determinar—, no como envoltorios del framework. Así puedes probar el flujo del rechazo sin tocar los ajustes del simulador.
Estas capacidades comparten un rasgo que las hace especialmente rentables como dependencias: sus estados de fallo son justamente los que en producción rompen la app y en desarrollo nadie prueba. El disco lleno, el permiso denegado tras haber sido concedido, la escritura que falla a la mitad. Modelarlas como clientes convierte esos escenarios en una línea de sobrescritura, y con ello pasan de ser hipótesis a ser casos cubiertos.
El mismo criterio explica por qué la librería trae ya registrados el reloj, el generador de identificadores y la fecha: son impurezas transversales que aparecen en casi toda feature, y tenerlas normalizadas evita que cada equipo invente su propia versión incompatible. Cuando dudes de si algo merece ser dependencia, hazte una pregunta operativa: ¿podría este trozo de código dar un resultado distinto ejecutado dos veces con el mismo estado? Si la respuesta es sí, ya es una dependencia, la hayas declarado o no.
Testear la implementación viva aparte
Aquí está la grieta que casi nadie tapa. Tus tests de reducer sustituyen la dependencia, luego jamás ejecutan el liveValue; y el liveValue es precisamente donde vive el código más frágil del sistema: la construcción de la petición, la decodificación, el mapeo de errores, la paginación. Una suite exhaustiva y verde es perfectamente compatible con un cliente vivo que no decodifica nada.
Merece la pena cuantificar la grieta antes de taparla. En una feature típica, la lógica del reducer son unas decenas de líneas que tus tests cubren de forma exhaustiva, mientras que la implementación viva puede tener la mitad de volumen y concentrar la mayor parte de las causas reales de incidencia: un campo que llegó nulo, una fecha con otro formato, una paginación que empieza en uno en vez de en cero. Que la parte mejor probada sea la más fácil de razonar y la peor probada sea la más impredecible es exactamente el reparto contrario al que querrías.
La respuesta no es dejar de sustituir, sino añadir una segunda familia de pruebas con otro propósito y otro ritmo. Las de contrato verifican que la implementación viva cumple lo que su firma promete, y buena parte de ellas no necesita red: guarda respuestas reales del servidor como ficheros de muestra y comprueba que la decodificación produce el modelo esperado, incluidos los casos raros que rompieron algo alguna vez.
func testDecodificaRespuestaReal() throws {
let datos = try Data(contentsOf: .muestra("articulos_pagina_1.json"))
let articulos = try JSONDecoder().decode([Articulo].self, from: datos)
XCTAssertEqual(articulos.count, 20)
XCTAssertEqual(articulos.first?.titulo, "Composicion")
}
Ese estilo de prueba tiene una virtud adicional: los ficheros de muestra documentan el contrato con el servidor mejor que cualquier página de documentación, porque son ejecutables y fallan cuando dejan de ser ciertos. Guardados en el repositorio y versionados junto al código, se convierten en la memoria del equipo sobre cómo respondía de verdad ese endpoint el día que se escribió.
Las de integración, más caras, sí tocan un entorno real de pruebas y confirman que el contrato con el servidor sigue vigente. Van en un objetivo de test aparte y en un plan que no corre en cada pull request sino a diario, porque su valor es detectar deriva del servidor, no bloquear el desarrollo.
La forma más práctica de organizarlo es por velocidad y no por categoría teórica: un plan rápido con los tests de reducer y de decodificación, que corre siempre y debe tardar segundos, y un plan lento con lo que sale de la máquina, que corre en un horario fijo y cuyo fallo abre una incidencia en vez de detener una fusión. Mezclar ambos en un solo plan garantiza el peor resultado, porque la lentitud y la intermitencia del segundo acaban erosionando la confianza en el primero.
La forma más eficiente de conseguir ficheros de muestra fieles es capturarlos una vez desde el servicio real y guardarlos versionados junto a los tests. Inventar el JSON a mano produce muestras que confirman tus suposiciones en vez de la realidad, que es justo lo contrario de lo que buscas; y cuando el servidor cambie un campo, la muestra vieja te dará el fallo que necesitabas.
Dimensiona cada dependencia por capacidad del dominio: ni un cliente-dios que obliga a sobrescribir media docena de campos por test, ni un cliente por llamada que fragmenta lo que siempre viaja junto. La firma del cliente es la frontera: que hable de artículos y de errores de dominio, nunca de Data, códigos de estado o tipos del SDK, y que traduzca los fallos de infraestructura dentro del liveValue. Y como los tests de reducer nunca ejecutan la implementación viva, esa parte necesita pruebas propias —decodificación contra respuestas reales guardadas, integración contra un entorno de pruebas en un plan aparte—, porque una suite exhaustiva demuestra corrección relativa al modelo del entorno y no la fidelidad de ese modelo.
Vale la pena enunciar con precisión qué prueba y qué no prueba una suite de TCA, porque la confianza mal calibrada es más peligrosa que la desconfianza. Un TestStore verde establece un condicional: si el entorno se comporta como declararon las sobrescrituras, entonces el reducer produce estos estados y estos efectos, y ninguno más. Es un teorema fuerte y limpio sobre la lógica, y es también un teorema estrictamente relativo a sus premisas. Las premisas son los valores de test que escribiste, es decir, tu modelo del mundo; y ningún test de reducer, por exhaustivo que sea, puede validar ese modelo, porque el modelo es lo que el test asume. De ahí que la disciplina se parta necesariamente en dos obligaciones distintas: la corrección de la lógica dado el modelo, que resuelven las sobrescrituras y la exhaustividad, y la fidelidad del modelo respecto al mundo, que solo pueden resolver las pruebas de contrato e integración sobre la implementación viva. Descuidar la segunda produce el fenómeno más frustrante del testing serio, el de una suite impecable acompañada de fallos en producción, y su causa no es que los tests fueran malos sino que se estaba demostrando el teorema equivocado. Esto ilumina también por qué la frontera importa tanto: cuanto más se parece la firma del cliente al vocabulario del dominio, más pequeño y más verificable es el trozo de mundo que el modelo debe capturar, mientras que una firma que devuelve Data y códigos de estado empuja al modelo a describir el protocolo entero, tarea en la que ningún doble será jamás fiel. La granularidad, la frontera y las pruebas del cliente vivo no son tres consejos independientes: son tres formas de reducir y controlar la misma cantidad, la distancia entre lo que tus tests suponen y lo que el mundo hace. Diseñar dependencias es, en el fondo, elegir dónde poner esa distancia para que quepa en algo que puedas mirar.
- Lista tus dependencias y clasifica cada una como capacidad, tecnología o pantalla; renombra o reorganiza las dos últimas categorías.
- Cuenta las sobrescrituras del test más ruidoso de tu suite y decide si el diagnóstico es cliente-dios o acoplamiento del reducer.
- Busca en las firmas de tus clientes cualquier
Data,URLResponseo tipo de un SDK de terceros y empújalo dentro delliveValue. - Define un error del dominio
Equatablecon los casos que tu interfaz distingue de verdad y traduce los del transporte en el borde. - Guarda una respuesta real como fichero de muestra, escribe la prueba de decodificación y crea un plan de test aparte para lo que sí toca la red.