wandres.dev
TESTING NO EXHAUSTIVO · y de integración

Tests de integración: varias features en un mismo store

Un test de integración en TCA es un `TestStore` construido sobre un reducer compuesto, con los hijos reales dentro y las dependencias sobrescritas solo en las hojas. Sirve para cazar la clase de errores que ningún test unitario puede ver, porque no viven en ninguna feature sino entre ellas: un `Scope` mal apuntado, un `delegate` que el padre nunca escucha, un orden equivocado en el `body`, un destino que se cierra sin cancelar sus efectos. Esta lección enseña a enviar y recibir acciones a través del árbol con case key paths, a elegir la raíz mínima que contiene la colaboración y a mantener el test determinista y rápido pese a abarcar media aplicación.

⏱ 21 min

En casi cualquier otra arquitectura, test de integración significa procesos, simuladores, bases de datos temporales y minutos de espera. En TCA significa algo mucho más modesto y mucho más útil: construir un TestStore sobre un reducer que compone a otros y ejercitarlo con los hijos reales dentro. Todo sigue corriendo en el mismo proceso, sin interfaz, sin red y sin reloj real, porque la composición no es un montaje de sistemas sino una operación entre valores. El resultado es una rareza feliz: pruebas que cubren la colaboración entre features y que, aun así, tardan milisegundos y no parpadean. Lo que ganas con ellas es una familia entera de errores que ningún test unitario puede ver, porque no habitan dentro de ninguna feature sino exactamente en las costuras que las unen.

🎯 Al terminar esta lección sabrás
  • Distinguir los errores de cableado —Scope mal apuntado, delegate ignorado, orden del body— que solo aparecen al componer.
  • Enviar y recibir acciones anidadas con case key paths, tanto en presentación en árbol como en pilas de navegación.
  • Sobrescribir dependencias una sola vez en la raíz del test y entender cómo se propagan hacia las hojas.
  • Elegir la raíz del test como el subárbol mínimo que contiene la colaboración que quieres verificar.

Lo que solo falla cuando las piezas se juntan

Cada feature puede estar impecablemente testeada por separado y la aplicación seguir rota. Los errores que sobreviven a una suite unitaria perfecta tienen una firma común: son omisiones o desalineaciones en el punto de unión, y por construcción ningún test que mire a un solo reducer puede detectarlas.

🔌

El delegate que nadie escucha

El hijo emite delegate(.guardado) con toda corrección y el padre no tiene un caso para él. Compila, pasa todos los tests del hijo y en la aplicación no ocurre nada.

🎯

El Scope mal apuntado

El key path del estado o el case key path de la acción señalan a un hermano parecido. El compilador acepta porque los tipos encajan y el hijo recibe acciones que no son suyas.

↕️

El orden en el body

La lógica del padre corre después de ifLet y lee un estado que el hijo ya limpió. La regla es que el padre se pronuncie antes de que el hijo pueda desaparecer.

🧹

El cierre sin apagar

El destino se pone a nil pero los efectos de larga vida que arrancó siguen vivos, o al revés: se cancelan antes de que su última acción llegue.

// El padre compone al hijo con toda corrección y aun así lo ignora
var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .view(.fichaPulsada(let id)):
      state.destination = .detalle(Detalle.State(ficha: state.fichas[id: id]!))
      return .none
    case .destination:
      return .none          // aquí cae también el delegate del hijo, y se pierde
    }
  }
  .ifLet(\.$destination, action: \.destination) {
    Detalle()
  }
}

El caso .destination que devuelve .none es sintácticamente irreprochable y semánticamente desastroso: absorbe por igual las acciones internas del hijo —que efectivamente no le incumben— y las de delegación, que son justo las que el hijo emitió para hablarle. El compilador no puede ayudarte porque ambas viajan por el mismo canal, los tests del hijo pasan porque el hijo cumplió su parte, y la aplicación pierde silenciosamente los datos que el usuario guardó.

Ninguna de las cuatro es un error de lógica: las cuatro son errores de contrato. Y el contrato entre dos features no vive en el código de ninguna de ellas, sino en la composición, que es precisamente el sujeto que un test de integración toma bajo prueba. Por eso la pregunta que estos tests responden nunca es qué hace este reducer sino qué ocurre cuando este usuario recorre este camino, y por eso, siguiendo la lección anterior, se escriben casi siempre con la exhaustividad apagada salvo en el punto exacto donde está la garantía.

Hablar con el árbol

Enviar una acción a un hijo es nombrar el camino que la lleva hasta él. Los case key paths de TCA componen ese camino de forma legible y con verificación de tipos: cada segmento es un caso del enum de acciones del nivel correspondiente.

@Test
func editarUnaFichaSeReflejaEnLaLista() async {
  let ficha = Ficha.demo
  let store = TestStore(initialState: Lista.State(fichas: [ficha])) {
    Lista()
  } withDependencies: {
    $0.almacen.guardar = { _ in }
    $0.uuid = .incrementing
  }
  store.exhaustivity = .off

  await store.send(\.view.fichaPulsada, ficha.id)
  await store.send(\.destination.presented.detalle.tituloEscrito, "Revisado")
  await store.send(\.destination.presented.detalle.guardarPulsado)
  await store.receive(\.destination.presented.detalle.delegate.guardado)

  store.assert {
    $0.destination = nil
    $0.fichas[id: ficha.id]?.titulo = "Revisado"
  }
}

Lee el test como una frase: el usuario abre una ficha, cambia el título, guarda, y al volver la lista muestra el cambio. Los tres primeros pasos son atrezo y no llevan aserción; el assert final contiene la garantía completa, y contiene además las dos mitades del cableado que este test existe para vigilar —que el destino se cierra y que el dato sube al padre—. Nótese que el receive del delegate no lleva closure: no nos importa qué mutó al procesarse, solo que ocurrió y que su consecuencia acumulada es la correcta.

Sobre la sintaxis del camino, dos notas. En presentación en árbol, \.destination.presented.detalle.guardarPulsado es la forma explícita, y TCA admite también la abreviada que omite presented cuando no hay ambigüedad; para simular que el usuario cierra la hoja arrastrando, se envía \.destination.dismiss, que es la misma acción que el dismiss inyectado produce desde dentro del hijo. En pilas, el segmento de elemento se escribe con subíndice por identidad.

await store.send(\.path[id: 0].ajustes.cerrarSesionPulsado)
await store.receive(\.path[id: 0].ajustes.delegate.sesionCerrada) {
  $0.path.removeAll()
  $0.sesion = nil
}

Que la pila se vacíe entera al cerrar sesión es un ejemplo perfecto de garantía que ningún test unitario puede formular: el reducer de ajustes no sabe que existe una pila, y el reducer que posee la pila no sabe qué es cerrar sesión. La afirmación solo tiene sentido en el punto donde ambos se encuentran, que es exactamente el sujeto de este test.

Las dependencias, en cambio, no se nombran por camino: se sobrescriben una sola vez en la construcción del TestStore y quedan disponibles para todo el árbol, porque el store propaga su contexto de dependencias hacia abajo al ejecutar los reducers hijos. Esa propagación es la que mantiene el test determinista pese a su alcance: por muy profunda que sea la composición, la única puerta al mundo sigue estando en las hojas y sigue estando cerrada con llave desde la raíz.

Elegir la raíz del test

El error más caro al escribir tests de integración no es de sintaxis, es de encuadre: montar el TestStore sobre la feature raíz de la aplicación para verificar un apretón de manos entre dos pantallas. Al hacerlo pagas el arranque completo —sesión, migraciones, sincronización, ajustes— y además obligas al test a conocer dependencias que no tienen ninguna relación con lo que verifica; en cuanto alguien añada una llamada al inicio de la aplicación, tus veinte tests de integración fallarán por una dependencia sin implementar en un camino que ni siquiera te importa.

La regla es tomar como raíz el subárbol mínimo que contiene la colaboración. Si lo que quieres es que al guardar en el detalle la lista se actualice, la raíz es la lista, no la aplicación. Si lo que quieres es que al cerrar sesión desde ajustes la pila se vacíe y la sesión desaparezca, la raíz es la feature que posee la pila y la sesión, no el contenedor de pestañas que la envuelve. Cuando esa raíz mínima resulta ser, de verdad, la aplicación entera, suele ser una señal valiosa: significa que el dato compartido está viajando demasiado lejos y que quizá el problema es de modelado y no de test.

💡
Los tests de integración también sirven para diseñar

Si escribir el camino de una acción exige seis segmentos de key path, el árbol es demasiado profundo. Si para verificar una colaboración entre dos hermanos tienes que subir hasta la raíz, es que el estado que comparten debería estar más abajo o en otro sitio. La dificultad de escribir el test no es un obstáculo entre tú y la verificación: es la primera lectura honesta de tu jerarquía, y la lección de diseño que ofrece suele valer más que el test que la provocó.

flowchart TD
T[TestStore de integracion sobre la raiz minima] --> P[Reducer padre real]
P --> H1[Hijo detalle real]
P --> H2[Hijo lista real]
H1 --> D1[Dependencias sobrescritas en la hoja]
H2 --> D1
P --> W[Cableado bajo prueba]
W --> W1[Scope y key paths]
W --> W2[Delegate escuchado por el padre]
W --> W3[Orden del body]
W --> W4[Cierre y cancelacion]
style W fill:#f9e2af,color:#11111b
style D1 fill:#a6e3a1,color:#11111b

Verificar el flujo, no la coreografía

Un buen test de integración se distingue de uno malo por lo que decide afirmar. El malo afirma la secuencia interna: que se emitió tal acción, luego tal otra, y que el indicador de carga pasó por true. El bueno afirma los puntos de contacto observables del flujo, que son siempre pocos y siempre nombrables sin mencionar nombres internos: el dato subió, la pantalla se cerró, la lista quedó ordenada, el efecto pendiente se canceló.

Hay un tipo de aserción que sí merece la pena en integración aunque suene a interna: la que fija el contrato de delegación. Recibir explícitamente \.destination.presented.detalle.delegate.guardado documenta que ese es el canal por el que el hijo habla con el padre, y rompe si alguien lo renombra o lo elimina; a diferencia de las acciones internas, el delegate es interfaz publicada y por tanto es exactamente el tipo de cosa que un test debe congelar. La distinción que la lección anterior planteaba en términos de autoridad reaparece aquí en términos de contrato: afirma lo que has publicado, abstente sobre lo que has encapsulado.

En TCA la integración deja de ser una categoría de infraestructura y pasa a ser una categoría de composición

En la industria, la pirámide de tests se define por el sustrato: unitario es lo que corre en memoria, integración lo que atraviesa procesos o bases de datos, extremo a extremo lo que arranca la aplicación entera; y de esa definición se siguen todos los males conocidos, porque el coste, la lentitud y la inestabilidad crecen con el nivel y obligan a un equilibrio doloroso entre cobertura y velocidad. TCA rompe esa correlación de una forma que merece un momento de atención, porque no es un truco de herramienta sino una consecuencia directa de las decisiones de arquitectura que llevas veintidós niveles pagando. Si una feature es un valor —un reducer que se combina con otros mediante operadores y produce otro reducer—, entonces integrar dos features no es montar dos sistemas, es aplicar una función; y probar esa integración no requiere infraestructura porque no hay infraestructura implicada, solo aritmética de tipos. Si el mundo entra exclusivamente por dependencias declaradas y esas dependencias se propagan desde la raíz del store, entonces el alcance del test no aumenta su indeterminismo: un test que cubre seis features tiene exactamente la misma superficie no controlada que uno que cubre una, porque la superficie no la fija el número de features sino el número de puertas al exterior, y esas están todas en las hojas y todas cerradas. Y si la navegación es un dato dentro del estado, entonces recorrer cinco pantallas no exige un simulador ni un motor de interfaz, solo mutar un valor. El resultado es que la escala de integración, que en otras arquitecturas se paga en segundos y en parpadeos, aquí se paga únicamente en legibilidad de las aserciones, que es justo el problema que el modo no exhaustivo resuelve. Conviene extraer la moraleja completa: no tienes tests de integración baratos porque TCA traiga un buen TestStore, sino porque decidiste que el estado fuera un valor, el efecto un dato y la dependencia un parámetro. El TestStore solo cobra la factura de esas decisiones; el crédito lo abriste mucho antes.

⚔️ Caza un error de cableado que tu suite unitaria no ve
  1. Elige un padre con un destino presentado y escribe un test de integración con exhaustivity = .off que recorra el flujo entero y afirme solo el estado final con assert.
  2. Borra en el padre el caso que maneja el delegate del hijo. Comprueba que todos los tests unitarios siguen verdes y que el de integración se pone rojo. Ese contraste es el argumento entero de esta lección.
  3. Intercambia el orden del Reduce del padre y el operador ifLet en el body y observa qué test lo detecta y con qué mensaje.
  4. Añade un efecto de larga vida en el hijo, cierra el destino desde el padre y usa finish para comprobar que se cancela de verdad en lugar de quedar vivo.
  5. Repite el mismo test montándolo sobre la feature raíz de la aplicación. Cuenta cuántas dependencias adicionales tienes que sobrescribir y decide con ese número cuál de los dos encuadres mantienes.