Sustituir dependencias en el test con withDependencies
Un efecto solo es testeable si todo lo que consulta del mundo entra por una puerta que el test controla. Esta lección recorre esa puerta entera: por qué el valor de test no implementado convierte cada dependencia olvidada en un fallo ruidoso en lugar de una llamada real silenciosa, cómo dar respuestas fijas y fallos a demanda a un cliente de red, cómo capturar los argumentos con los que el reducer llamó a la dependencia para afirmar la petición y no solo la respuesta, cómo hacer deterministas los identificadores con generadores incrementales y qué diferencia hay entre sustituir en el constructor del `TestStore`, a mitad de test o con la función libre `withDependencies` para objetos que capturan sus dependencias al nacer.
El reloj era la dependencia más difícil de ver, pero no es la única que decide si un efecto puede verificarse. Un efecto que carga datos consulta la red; uno que crea elementos pide identificadores nuevos; uno que registra eventos mira la fecha. Cada una de esas consultas es una entrada del sistema, y un test solo merece llamarse determinista cuando todas están bajo control. El mecanismo es siempre el mismo bloque de sustitución, pero su alcance real se entiende mal con frecuencia: no se trata de simular el mundo con la mayor fidelidad posible, sino de construir un universo pequeño y perfectamente conocido donde la única variable libre sea el comportamiento que quieres estudiar. Esta lección detalla las cuatro operaciones que componen ese trabajo —fijar respuestas, forzar fallos, capturar argumentos y hacer predecible el azar— y el mapa de dónde aplicarlas.
- Explicar por qué el valor de test no implementado convierte una dependencia olvidada en un fallo explícito.
- Dar respuestas fijas y forzar errores en un cliente de red para ejercitar ambos caminos del reducer.
- Capturar los argumentos de la llamada para afirmar la petición emitida y no solo la respuesta recibida.
- Distinguir la sustitución en el constructor, a mitad de test y con la función libre
withDependencies.
La dependencia olvidada tiene que gritar
La propiedad más valiosa del sistema de dependencias en un test no es que permita sustituir, sino lo que ocurre cuando no sustituyes. El valor de test por defecto de una dependencia bien definida no es la implementación real: es una implementación que falla en cuanto alguien la invoca, con el nombre del punto de acceso en el mensaje.
let store = TestStore(initialState: Listado.State()) {
Listado()
} withDependencies: {
$0.clienteAPI.cargar = { [.demo] }
// No sustituimos $0.clienteAPI.borrar
}
// Si el reducer llama a borrar, el test falla con el nombre del endpoint
Piensa en la alternativa. Si el valor por defecto fuese el real, un reducer que llamara a un punto de acceso que el autor del test no previó saldría a la red de verdad desde la suite: el test sería lento, dependería de un servidor, fallaría en el avión y —lo peor— podría pasar dando una falsa impresión de cobertura. Con el defecto que falla, la situación se invierte: el conjunto de dependencias que la feature usa realmente se descubre de forma empírica, ejecutando y leyendo los fallos, y queda documentado de manera exhaustiva en el bloque de sustitución. El bloque de un test bien escrito es, literalmente, el inventario de todo lo que esa feature necesita del mundo.
Esa propiedad tiene un efecto de segundo orden que conviene notar: cuando alguien añade una llamada nueva a una dependencia dentro de un reducer, los tests existentes se ponen rojos. No porque el comportamiento antiguo se haya roto, sino porque la superficie de contacto con el mundo ha crecido y nadie lo había declarado. Es un aviso de arquitectura disfrazado de fallo de test, y merece leerse como tal antes de silenciarlo añadiendo una línea al bloque.
Respuestas fijas, fallos a demanda, argumentos capturados
Sustituir un punto de acceso es reemplazar una closure, así que la expresividad es total: puedes devolver un valor fijo, lanzar, contar llamadas o inspeccionar lo que recibiste.
@Test
func laBusquedaEnviaElTextoYPintaLosResultados() async {
let peticiones = LockIsolated<[String]>([])
let store = TestStore(initialState: Busqueda.State()) {
Busqueda()
} withDependencies: {
$0.clienteAPI.buscar = { texto in
peticiones.withValue { $0.append(texto) }
return [.demo]
}
}
await store.send(.buscarPulsado("swift")) { $0.cargando = true }
await store.receive(\.respuesta) {
$0.cargando = false
$0.resultados = [.demo]
}
#expect(peticiones.value == ["swift"])
}
La captura de argumentos merece atención porque cubre una mitad del contrato que la mayoría de los tests ignora. Afirmar el estado resultante verifica qué hace el reducer con la respuesta; afirmar los argumentos verifica qué le pidió al mundo. Sin lo segundo, un reducer que enviara el texto en mayúsculas, que olvidara recortar los espacios o que llamara dos veces pasaría todos los tests, porque el cliente falso devuelve lo mismo pase lo que pase. El recuento de llamadas es la variante más barata y la que más regresiones atrapa: un contador que debe valer uno delata de golpe la petición duplicada, el efecto no cancelado y el bucle accidental.
El camino de fallo se ejercita con la misma facilidad, y hay que ejercitarlo, porque es donde vive el código que nadie mira.
} withDependencies: {
$0.clienteAPI.buscar = { _ in throw ErrorDeRed.sinConexion }
}
// ...
await store.receive(\.respuesta) {
$0.cargando = false
$0.error = .sinConexion
}
Repara en que el error viaja como una acción normal y se afirma como cualquier otro cambio de estado. Esa es la recompensa de haber modelado el fallo como parte del dominio en lugar de como una excepción que se propaga: el camino triste tiene exactamente la misma testabilidad que el feliz, y no hace falta ninguna ceremonia adicional para llegar a él.
Identificadores deterministas y diffs legibles
El azar entra en las features más inocentes por la puerta de los identificadores. Un reducer que crea un elemento nuevo pide un UUID, y un UUID real hace imposible escribir la aserción, porque el valor esperado no existe hasta después de ejecutar.
} withDependencies: {
$0.uuid = .incrementing
}
// ...
await store.send(.anadirPulsado) {
$0.items.append(Item(id: UUID(0), texto: ""))
}
Con el generador incremental el primer identificador es todo ceros, el siguiente termina en uno, y así sucesivamente; la biblioteca ofrece además el atajo que construye esos valores desde un entero para que la aserción se lea. El beneficio evidente es que el test se puede escribir. El menos evidente, y quizá más importante en el día a día, es la legibilidad del fallo: cuando un test exhaustivo rompe, el diagnóstico enfrenta el estado esperado con el real, y una lista de identificadores aleatorios convierte ese diagnóstico en un muro de caracteres hexadecimales indistinguibles. Con identificadores incrementales se ve de un vistazo que el elemento tercero está donde debía ir el segundo.
flowchart TD TS[Bloque withDependencies del TestStore] --> DV[Contexto de dependencias del test] DV --> RD[Reducer padre] DV --> RH[Reducers hijos por composicion] DV --> EF[Efectos lanzados con run] EF --> AC[Acciones de vuelta al store] style TS fill:#89b4fa,color:#11111b style EF fill:#a6e3a1,color:#11111b
El diagrama enuncia una garantía que se da por supuesta y no debería: la sustitución no se queda en el reducer que construiste, sino que alcanza a los reducers hijos añadidos por composición y a los efectos lanzados desde ellos. Ese alcance es la razón de que la dependencia deba entrar por su ruta de clave y no por el inicializador; un valor pasado a mano llega hasta donde alguien lo pase, y en la primera feature compuesta se pierde.
Tres lugares donde sustituir
| Dónde | Forma | Cuándo usarlo |
|---|---|---|
| Constructor del store | Bloque withDependencies del TestStore |
Escenario base del test | la mayoría de los casos |
| A mitad del test | Asignar sobre store.dependencies |
La segunda llamada debe fallar | cambia el escenario |
| Fuera del store | Función libre withDependencies con su operación |
Objetos que capturan dependencias al construirse |
El caso intermedio resuelve una necesidad muy concreta y muy frecuente: probar que un reintento funciona exige que la primera llamada falle y la segunda tenga éxito, y eso no se expresa con un único valor fijo. Cambiar la dependencia entre dos envíos deja el test contando una historia en dos actos, que es exactamente la forma del comportamiento que se quiere fijar.
await store.send(.cargar) { $0.cargando = true }
await store.receive(\.respuesta) { $0.error = .sinConexion }
store.dependencies.clienteAPI.buscar = { _ in [.demo] }
await store.send(.reintentar) { $0.cargando = true }
await store.receive(\.respuesta) {
$0.error = nil
$0.resultados = [.demo]
}
Merece una advertencia el orden de las asignaciones a mitad de test: sustituir la dependencia después de haber enviado la acción que ya lanzó el efecto no cambia nada, porque el efecto capturó el contexto vigente cuando empezó a correr. La asignación tiene que ocurrir entre el envío que cierra el primer acto y el que abre el segundo, y cuando alguien se salta esa regla el síntoma es un test que parece ignorar la sustitución más reciente.
El tercer lugar cubre a los objetos que leen sus dependencias en el momento de construirse en vez de en el momento de usarlas. Para ellos no basta con sustituir después: hay que envolver la propia construcción en el bloque, de modo que el contexto vigente durante el inicializador sea el del test. Reconocer esa distinción evita una tarde entera de desconcierto ante un test donde la sustitución parece no aplicarse.
Hay una manera equivocada y muy extendida de entender la sustitución de dependencias, y consiste en verla como el arte de imitar el mundo real con la mayor fidelidad posible: clientes falsos que reproducen la latencia, respuestas que replican la forma exacta del servidor, escenarios cada vez más elaborados en busca del realismo. Ese camino conduce a un sistema paralelo caro de mantener y, paradójicamente, a tests peores, porque cuanto más rico es el doble más entradas incontroladas vuelve a introducir. El propósito real es el opuesto y es el del método experimental: un test es un experimento, y un experimento vale por la cantidad de variables que fija, no por su parecido con la calle. El químico no reproduce la atmósfera dentro del matraz; la elimina, para que lo único que varíe sea aquello sobre lo que quiere concluir. Sustituir una dependencia es exactamente eso: no imitar el servidor, sino declarar por decreto que en este universo la red devuelve esta lista, el reloj está en este instante y el siguiente identificador es el cero. Bajo esas condiciones, si el estado final no es el esperado, la causa está necesariamente en el reducer, y esa implicación —que solo se sostiene si el cierre del universo es completo— es el producto entero del ejercicio. De ahí se sigue por qué el valor de test que falla ruidosamente es la pieza clave y no un detalle de ergonomía: es el guardián del cierre. Cada dependencia que se cuela sin declarar abre una rendija por la que entra una variable no controlada, y basta una para que el razonamiento anterior deje de valer y el verde del test vuelva a ser una correlación. Por eso la lectura correcta de un test que falla diciendo que falta implementar algo no es tengo que añadir otra línea, sino mi feature depende de una cosa más de las que yo creía, que es información arquitectónica de primer orden llegando por el canal más barato posible.
- Elige la feature con más efectos de tu proyecto y escribe un test sin sustituir nada. Ejecuta, anota cada fallo por dependencia no implementada y construye con ellos el inventario real de su contacto con el mundo.
- Añade la captura de argumentos a un test de búsqueda y afirma tanto la petición como la respuesta. Cambia después el reducer para que envíe el texto sin recortar espacios y comprueba que el test lo detecta.
- Introduce un contador de llamadas y verifica que vale uno. Duplica a propósito el efecto en el reducer y observa cómo el contador delata lo que la aserción de estado no veía.
- Ejercita el camino de fallo forzando un error en la dependencia y afirma el estado resultante campo a campo. Comprueba qué porcentaje de tu suite lo hacía ya.
- Escribe un test de reintento cambiando la dependencia a mitad de camino: primera llamada con error, segunda con éxito. Explica por qué esto no podía expresarse con una sola sustitución.
- Instala el generador incremental de identificadores en una feature con listas y compara la legibilidad del diff de fallo antes y después. Guarda ambos mensajes como argumento para el resto del equipo.