Testear la lógica sin la UI
La testabilidad de una aplicación no se decide el día que se escribe el primer test, sino mucho antes, cuando se decide dónde vive el estado. Esta lección defiende la tesis que sostiene todo el nivel: un test cuesta exactamente lo que cuesta preparar su escenario, y si el estado está enredado con la vista ese escenario exige montar un árbol de componentes, simular eventos del navegador y esperar a que el renderizado se estabilice, con lo que cada aserción trivial se paga en segundos, en dependencias y en fragilidad. Se disecciona la anatomía de ese coste —montaje, asincronía, acoplamiento al marcado, no determinismo— y se propone el corte clásico entre un núcleo funcional, hecho de funciones puras sobre datos, y una cáscara imperativa que se limita a traducir entre ese núcleo y el mundo. Se muestra cómo el mismo comportamiento pasa de necesitar un test de interfaz lento y frágil a necesitar una llamada de función y una comparación de valores, y se reordena la pirámide alrededor de esa frontera: muchísimos tests de núcleo, algunos de adaptadores, muy pocos de recorrido completo. La conclusión no es que los tests de interfaz sobren, sino que solo son honestos cuando el núcleo ya está probado en otro sitio.
Hay una frase que se repite en los equipos y que casi siempre es un diagnóstico equivocado: esta app es difícil de testear. La app no es difícil de testear; la app está construida de un modo que hace caros los tests, que no es lo mismo. La testabilidad no es una virtud que se añade al final con la herramienta adecuada, sino una consecuencia mecánica de una decisión de arquitectura tomada mucho antes: si el estado vive dentro de la vista o fuera de ella. Cuando vive dentro, cada comprobación de una regla de negocio se cobra el peaje entero de la interfaz —montar componentes, simular clics, esperar renderizados, consultar el marcado— y ese peaje no se paga una vez, se paga en cada ejecución de la suite y en cada refactor del maquetado. Cuando vive fuera, la misma regla se comprueba llamando a una función con un dato y comparando el resultado con otro dato, en microsegundos y sin navegador. Este nivel entero trata de testear el estado, y empieza por lo único que hace posible todo lo demás: separarlo de la vista. No es un consejo de higiene, es la condición de posibilidad. Todo lo que verás en las cuatro lecciones siguientes —tablas de transiciones, invariantes, modelos, limpieza de efectos— presupone que existe un lugar donde la lógica se puede invocar sin pintar un solo píxel.
- Entender que la testabilidad es una propiedad del diseño y no del test, y que su unidad de medida es el coste de preparar el escenario.
- Descomponer el peaje real de testear a través de la interfaz: montaje, asincronía, acoplamiento al marcado y no determinismo.
- Aplicar el corte entre núcleo funcional y cáscara imperativa para dejar la lógica de estado invocable sin vista.
- Reordenar la pirámide de tests alrededor de esa frontera y saber qué comprueba honestamente cada anillo.
La testabilidad es una propiedad del diseño, no del test
Empecemos por deshacer el orden de causalidad que la mayoría de los equipos da por supuesto. Se cree que primero se escribe la aplicación y después se decide cómo testearla, de modo que la testabilidad sería una propiedad emergente que se descubre al final. La realidad es la inversa: la testabilidad queda fijada en el momento en que se decide dónde vive cada decisión, mucho antes de que exista una sola aserción, y ninguna herramienta posterior puede revertir esa decisión. Por eso los debates sobre qué framework de testing adoptar suelen ser una distracción cara: cambian la ergonomía de la escritura, no el coste estructural de la comprobación.
Un test hace siempre tres cosas: prepara un escenario, ejecuta una acción y compara un resultado. La segunda y la tercera son casi gratis en cualquier arquitectura; toda la economía de una suite se decide en la primera. Preparar el escenario de una regla de negocio que vive dentro de un componente significa instanciar un entorno de navegador simulado, construir el árbol de componentes con sus proveedores de contexto, montar el nodo raíz, disparar eventos sintéticos y esperar a que el ciclo de renderizado se aquiete antes de mirar nada. Preparar el escenario de esa misma regla cuando vive en una función pura significa escribir un objeto literal. La diferencia entre ambos costes es de tres a cuatro órdenes de magnitud, y esa diferencia no es una molestia de rendimiento: es la que decide si la suite se ejecuta en cada guardado o una vez al día, y una suite que no se ejecuta en cada guardado deja de proteger nada.
El coste tiene además una segunda componente, más insidiosa que la temporal: el acoplamiento. Un test que interroga a la interfaz afirma cosas sobre el texto de un botón, sobre la jerarquía de nodos o sobre un atributo de accesibilidad; es decir, afirma cosas sobre decisiones de presentación que van a cambiar sin que la regla cambie. Ese test fallará el día que un diseñador renombre una etiqueta, y no habrá detectado ningún error: habrá producido una falsa alarma. La tercera componente es el no determinismo. La interfaz es asíncrona por naturaleza —transiciones, temporizadores, peticiones, animaciones, medición de layout— y toda asincronía no controlada es una fuente de intermitencia. Un test que a veces pasa y a veces no es peor que ningún test, porque entrena al equipo a ignorar el rojo.
Observa el mismo comportamiento escrito de las dos maneras. La regla es trivial —no se puede aplicar un descuento a un carrito vacío— pero el contraste es exactamente el que se repite a escala en cualquier aplicación real.
// Enredado: la regla vive dentro del componente y solo se alcanza por la UI
// El test necesita montar, hacer clic, esperar y leer el DOM.
await user.click(screen.getByRole('button', { name: /aplicar/i }))
expect(await screen.findByText(/carrito vacio/i)).toBeVisible()
// Separado: la regla vive en una funcion pura sobre datos
const carrito = { lineas: [], descuento: null }
expect(aplicarDescuento(carrito, 'VERANO')).toEqual({
ok: false,
motivo: 'CARRITO_VACIO',
})
Los dos tests comprueban la misma intención, pero el segundo no sabe que existe un botón, ni un texto, ni un navegador. Sobrevive a un rediseño completo de la pantalla y falla únicamente cuando la regla se rompe, que es la única señal que un test debería emitir.
Hay además una diferencia de calidad diagnóstica que suele pasarse por alto y que importa tanto como la de velocidad. Cuando el primer test falla, el mensaje de error dice que no se encontró un elemento con cierto texto, lo cual es compatible con una docena de causas distintas: la regla cambió, el texto cambió, el botón está deshabilitado, el evento no llegó, el componente no llegó a montarse, una petición se quedó colgada. El ingeniero que recibe ese fallo tiene que investigar antes de saber siquiera en qué capa mirar. Cuando falla el segundo, el mensaje dice que se esperaba un motivo concreto y se recibió otro, y la localización es inmediata porque el test solo podía fallar por un motivo. Un test que puede fallar por muchas razones distintas no es un test más completo: es un test que informa peor.
El núcleo funcional y la cáscara imperativa
La disciplina que hace posible ese segundo test tiene nombre propio y una formulación célebre: núcleo funcional, cáscara imperativa. La idea es partir el sistema en dos regiones con propiedades opuestas y una frontera explícita entre ellas. El núcleo contiene todas las decisiones: qué transición corresponde a un evento, qué es válido, qué se deriva de qué, qué invariantes se conservan. Está hecho de funciones puras que reciben datos y devuelven datos, sin leer el reloj, sin tocar la red, sin conocer el DOM. La cáscara no contiene ninguna decisión: recoge eventos del mundo, los convierte en valores para el núcleo, toma lo que el núcleo devuelve y lo empuja al mundo otra vez —pinta, guarda, envía—.
La consecuencia práctica es una asimetría deliberada. El núcleo es difícil de equivocar y trivial de testear, porque su superficie de contrato es un par de tipos. La cáscara es fácil de equivocar pero tiene tan poca lógica que sus errores son visibles a simple vista, y además es el único lugar donde hace falta un test lento. Cuando alguien dice que un código es difícil de testear, casi siempre lo que ocurre es que ha dejado decisiones dentro de la cáscara: un if de negocio en un manejador de eventos, un cálculo de totales en el cuerpo del render, una validación en el propio formulario.
flowchart LR MUNDO[Mundo exterior: eventos, red, reloj] --> CASCARA[Cascara imperativa] CASCARA -->|traduce a datos| NUCLEO[Nucleo funcional puro] NUCLEO -->|devuelve estado nuevo| CASCARA CASCARA -->|efectos| MUNDO NUCLEO --> T1[Tests rapidos y deterministas] CASCARA --> T2[Pocos tests lentos de integracion] style NUCLEO fill:#a6e3a1,color:#11111b style CASCARA fill:#f9e2af,color:#11111b style T1 fill:#89b4fa,color:#11111b
La frontera se reconoce por una prueba sencilla: si una función necesita algo que no le han pasado por parámetro, está en la cáscara. Leer Date.now, invocar fetch, consultar localStorage, mirar el ancho de la ventana son todas señales de cáscara. Empujarlas hacia fuera —recibiendo el instante como argumento, recibiendo los datos ya cargados, recibiendo la anchura como número— es literalmente la operación que convierte código no testeable en código testeable, y no cuesta más que mover un parámetro.
Conviene notar que esta partición no es una invención del mundo del testing ni una moda de una comunidad concreta. Es la misma idea que aparece bajo nombres distintos en tradiciones que no se hablaban entre sí: la arquitectura hexagonal la llama puertos y adaptadores, la arquitectura limpia la llama regla de dependencia hacia dentro, la programación funcional la llama simplemente separar el cálculo del efecto, y el patrón unidireccional que estudiaste en niveles anteriores la encarna sin nombrarla al exigir que el reducer sea puro y que los efectos se declaren como datos. Que cuatro linajes independientes converjan en la misma frontera es una señal fuerte de que la frontera no es arbitraria, sino que responde a una asimetría real: hay una parte del software que se puede razonar y otra que solo se puede observar, y mezclarlas hace que toda la mezcla se comporte como la peor de las dos.
// Antes: decision y efecto entrelazados, imposible de invocar sin mundo
async function alPulsarPublicar() {
const ahora = Date.now()
if (ahora - ultimoEnvio < 30_000) return mostrarAviso('Espera un poco')
await fetch('/api/publicar', { method: 'POST', body: borrador })
}
// Despues: la decision es una funcion pura que devuelve una intencion
type Intencion =
| { tipo: 'PUBLICAR'; cuerpo: string }
| { tipo: 'AVISAR'; texto: string }
function decidirPublicacion(
borrador: string,
ahora: number,
ultimoEnvio: number,
): Intencion {
if (ahora - ultimoEnvio < 30_000) return { tipo: 'AVISAR', texto: 'Espera un poco' }
return { tipo: 'PUBLICAR', cuerpo: borrador }
}
El segundo bloque no ejecuta nada: decide y devuelve la decisión como un dato. La cáscara recibe esa intención y la cumple. El test de la regla de los treinta segundos pasa entonces a ser una comparación de dos objetos literales, sin red, sin reloj y sin espera, y el test de la cáscara se reduce a comprobar que a cada intención le corresponde la llamada adecuada, que es un cableado y no una regla.
Cuando te encuentres con una función imposible de testear, no busques una librería de dobles: busca el argumento que le falta. Casi todo lo que hace que una función sea impura es una dependencia implícita que podría ser explícita. Un reducer que consulta el reloj se vuelve puro si el evento trae su marca de tiempo. Un cálculo que llama al servidor se vuelve puro si recibe la respuesta ya resuelta. Esta técnica tiene un nombre pomposo, inyección de dependencias, pero en la práctica es solo esto: mover hacia el borde lo que no se puede reproducir, para que el centro sea reproducible siempre.
Qué comprueba honestamente cada anillo
Separar el núcleo no elimina los tests de interfaz: los reubica y los reduce a su función legítima. Cada anillo del sistema responde a una pregunta distinta, y confundir las preguntas es la causa habitual de suites lentas que aun así no detectan nada. La confusión más cara consiste en pedirle al anillo exterior que verifique reglas de negocio, porque es el que peor las verifica y el que más cobra por intentarlo: es lento, es frágil y, sobre todo, solo puede visitar las combinaciones que alguien tuvo tiempo de escribir a mano, que en un dominio real son una fracción despreciable del total.
Núcleo: la lógica es correcta
Reducers, transiciones, selectores, validaciones y derivaciones. Son funciones puras, se prueban por miles, tardan microsegundos y no necesitan entorno. Aquí es donde vive la cobertura real de reglas de negocio y donde tienen sentido las tablas de casos, las invariantes y los modelos de las lecciones siguientes.
Adaptadores: la traducción es fiel
El código que convierte una respuesta del servidor en el estado del núcleo, o un evento del navegador en una acción. Se prueba con dobles del mundo exterior y responde a una única pregunta: si el mundo dice esto, el núcleo recibe aquello. Ni una regla de negocio aquí.
Recorridos: las piezas están conectadas
Un puñado de tests de extremo a extremo sobre los flujos que ganan dinero. No verifican reglas, sino cableado: que el botón esté conectado a la acción y la acción a la vista. Son caros y lentos por naturaleza, así que su número debe justificarse uno a uno.
Vista: lo que se pinta es lo esperado
Comprobaciones sobre la proyección: accesibilidad, estados visuales, instantáneas del marcado. Responden a preguntas de presentación y deben poder borrarse enteras el día de un rediseño sin que nadie tema haber perdido cobertura de negocio.
Esta reordenación explica por qué la discusión eterna entre la pirámide y el trofeo de tests casi siempre se plantea mal. La pregunta importante no es cuántos tests de cada tipo escribir, sino dónde se ha puesto la lógica. En una arquitectura con el estado enredado en la vista no hay ninguna proporción que funcione, porque no existe un nivel barato en el que probar las reglas: todo test que valga algo tiene que atravesar la interfaz. En una arquitectura con el núcleo separado, la proporción se resuelve sola: la enorme mayoría de las comprobaciones caben en el anillo barato, y el anillo caro queda para lo que solo él puede ver.
Tres síntomas de que la frontera está mal trazada
La teoría es fácil de suscribir y difícil de aplicar, porque la lógica se filtra hacia la cáscara de forma gradual y por motivos siempre razonables. Conviene, por eso, tener a mano tres síntomas concretos que delatan la filtración antes de que sea cara de revertir.
El primero es el test que necesita un doble para comprobar una regla. Si para verificar que un descuento no se aplica dos veces hay que simular el servidor, es que la regla está mezclada con la llamada. Una regla de negocio no debería requerir nunca un doble, porque una regla no habla con nadie: recibe datos y produce una decisión. Cuando aparece un doble en un test de reglas, la señal no es que falte una librería de simulación mejor, sino que hay una decisión al otro lado de una frontera equivocada.
El segundo es el test que espera. Cualquier espera en un test de lógica —un retardo fijo, un sondeo, una promesa que se resuelve cuando el renderizado termina— indica que la comprobación depende de un proceso que no se controla. La lógica pura no tarda: se ejecuta y devuelve. Si hay que esperar para observarla, es que lo que se está observando no es la lógica sino su proyección en un sistema asíncrono.
El segundo tiene un corolario incómodo que conviene aceptar pronto: la asincronía no desaparece por moverla de sitio, pero sí se concentra. En un sistema bien partido, todo lo que espera vive en la cáscara, que es delgada y tiene pocos tests; en un sistema mal partido, la espera está repartida por todas partes y cada test la hereda. Concentrar la asincronía en un punto no es solo una mejora de los tests, es también la razón por la que resulta posible razonar sobre el orden de las cosas.
El tercero, y el más revelador, es el test que se rompe al refactorizar sin que el comportamiento cambie. Ese síntoma significa que la aserción está anclada a un detalle estructural en vez de a un contrato, y el detalle suele ser precisamente de presentación: el nombre de una clase, el orden de dos nodos, el texto de una etiqueta. Un buen conjunto de tests debe permitir reescribir por completo la interfaz sin que ni uno solo se ponga en rojo, porque la interfaz no es lo que se prometió al usuario, sino cómo se le enseña.
Un malentendido frecuente convierte esta lección en una excusa para una explosión de capas: carpetas de dominio, de aplicación, de infraestructura, interfaces para todo y una indirección por cada llamada. Nada de eso es necesario y casi todo estorba en una aplicación de tamaño medio. La frontera que importa se puede materializar con dos archivos y ninguna abstracción adicional: uno con funciones puras que reciben datos y devuelven datos, otro con el componente que las llama. La prueba de que la separación existe no es la forma del árbol de directorios, sino un hecho verificable en un segundo: si puedes importar tu lógica desde un test sin importar nada del framework de vista, la frontera está trazada, aunque los dos archivos sean vecinos.
Aquí está la inversión conceptual que define el nivel. Instintivamente crees que testear una aplicación consiste en usarla muy rápido y muchas veces, de forma automática: pulsar botones, rellenar campos, leer pantallas. Bajo esa creencia, un framework de testing de interfaz parece la herramienta central y el resto, accesorios. Pero esa creencia contiene un error de categoría. Una aplicación no es la pantalla; la pantalla es una proyección. Lo que puede estar bien o mal —lo que constituye una regla, y por tanto lo único que un test puede confirmar o refutar— es el modelo de estado que hay debajo: qué configuraciones son posibles, qué transiciones las conectan, qué invariantes se conservan. La pantalla no es más que una función de ese modelo, y una función, además, que cambia por motivos estéticos cada pocos meses. Testear la app a través de la pantalla es como verificar un teorema mirando la tipografía del papel donde está impreso: puedes hacerlo, pero estás observando el soporte en vez del contenido, y por eso cada cambio de soporte rompe tus tests sin que nada sustancial se haya movido. La consecuencia es dura y liberadora a la vez. Si tu lógica no se puede invocar sin un navegador, el problema no lo va a resolver ninguna herramienta de testing, por sofisticada que sea, porque el problema no está en cómo pruebas sino en dónde pusiste las decisiones. Y al revés: en cuanto la lógica es un conjunto de funciones que reciben datos y devuelven datos, todo el aparato del testing riguroso —tablas exhaustivas, generación aleatoria, propiedades universales, comparación contra un modelo de referencia— se vuelve aplicable de golpe, porque todas esas técnicas presuponen poder ejecutar la lógica millones de veces por segundo. Separar el estado de la vista no es, por tanto, una preferencia de estilo: es la puerta de entrada al único testing que de verdad demuestra algo.
- Elige un componente real de tu proyecto que contenga al menos tres decisiones de negocio mezcladas con el marcado y enuméralas por escrito, una por línea.
- Cronometra tu suite actual y anota cuántos milisegundos cuesta el test más barato que atraviesa la interfaz. Ese número es tu presupuesto por aserción hoy.
- Extrae esas tres decisiones a funciones puras que reciban datos y devuelvan datos, sin importar nada del framework de vista, y déjalas en un archivo aparte.
- Escribe los tests equivalentes contra las funciones extraídas y vuelve a cronometrar. Compara el coste por aserción antes y después.
- Busca en el componente resultante toda dependencia implícita —reloj, red, almacenamiento, tamaño de ventana— y conviértela en un argumento explícito.
- Reduce ahora tus tests de interfaz sobre esa pantalla a uno solo, el que comprueba que el cableado existe, y justifica en un comentario por qué los demás ya no aportan información.