Testear funciones puras: la desaparición del andamiaje
Buena parte de lo que en otros ecosistemas se llama testing unitario no prueba el programa sino el andamiaje construido para poder probarlo: dobles, espías, contenedores de inyección, relojes falsos, bases de datos en memoria y una capa de configuración previa que suele pesar más que la función examinada. Esta lección argumenta que ese andamiaje no es una consecuencia de la disciplina de probar, sino un impuesto que cobra el efecto oculto, y que en un lenguaje donde toda función es una relación total entre sus argumentos y su resultado el impuesto simplemente no existe. Se analiza qué desaparece exactamente cuando desaparece el efecto implícito, por qué el test recupera su forma canónica de tabla de entradas y salidas esperadas, qué papel juega el compilador como primera línea de verificación y cuál es entonces la parte verdaderamente difícil que queda en pie: decidir qué merece ser probado y con qué oráculo se compara el resultado.
Quien haya mantenido una batería de pruebas en un lenguaje de efectos libres reconoce el síntoma sin necesidad de que se lo describan: el fichero de test es más largo que el fichero probado, y la mayor parte de esa longitud no habla del dominio sino de fontanería. Hay una sección de preparación que construye un mundo artificial, otra que sustituye colaboradores por imitaciones, otra que fija el tiempo, otra que restaura lo que las anteriores ensuciaron. Al final, en el centro geométrico del fichero, hay tres líneas que sí comprueban algo. La lectura habitual de este fenómeno es que probar es caro y hay que resignarse. La lectura correcta es otra: ese coste no lo genera la prueba, lo genera el hecho de que la función bajo examen depende de cosas que no aparecen en su firma. Cada dependencia invisible hay que fabricarla a mano antes de poder llamar a la función, y cada fabricación es código que puede equivocarse, envejecer y mentir. En Elm esa deuda no se paga porque nunca se contrae: una función depende exactamente de lo que declara y no puede observar ni alterar nada más. El test vuelve entonces a ser lo que la palabra sugiere: llamar a la función con un valor y mirar el que sale.
- Explicar por qué los dobles de prueba son un impuesto del efecto oculto y no un requisito de la disciplina de probar.
- Reconocer qué construcciones desaparecen del fichero de test cuando la función examinada es total y determinista.
- Situar al compilador como primera línea de verificación y delimitar qué clase de fallos deja para las pruebas.
- Formular la pregunta que sí sigue siendo difícil: qué comportamiento merece un test y contra qué oráculo se compara.
Qué se compra exactamente con un doble de prueba
Un doble existe para responder a un problema muy concreto: la función que quiero examinar hace algo que no quiero que ocurra durante la prueba, o depende de algo que durante la prueba no está disponible. Se escribe una red, se envía un correo, se lee un reloj, se consulta una tabla. Como la llamada a esa capacidad está incrustada dentro del cuerpo de la función y no aparece entre sus argumentos, la única manera de neutralizarla es interceptarla desde fuera, sustituyendo el objeto real por uno de mentira, o inyectándolo por un canal preparado de antemano. El doble no es entonces una herramienta de verificación sino una herramienta de acceso: sirve para poder ejecutar la función en absoluto.
El precio de ese acceso se cobra en tres monedas distintas y todas se pagan durante años. La primera es que el doble es una segunda implementación de una interfaz, escrita a mano, que puede divergir de la real en cualquier momento sin que nada avise; una prueba verde contra un doble desactualizado es peor que ninguna prueba, porque transmite confianza falsa. La segunda es que la prueba deja de hablar del qué y empieza a hablar del cómo: al afirmar que cierto colaborador recibió cierta llamada con ciertos argumentos, se congela la estructura interna de la función y cualquier refactorización honesta rompe pruebas que deberían ser indiferentes a ella. La tercera es la más silenciosa: el sistema real acaba diseñándose para ser sustituible, con capas de indirección cuya única razón de existir es la propia prueba.
Sin preparación previa
No hay estado global que reiniciar ni mundo que construir antes de llamar. El argumento de la función es todo el contexto que existe.
Sin dobles
No hay nada que imitar porque no hay colaborador oculto. Si un dato hace falta, aparece en la firma y se pasa como valor.
Sin orden ni tiempo
Dos pruebas nunca interfieren, se pueden ejecutar en cualquier orden y en paralelo, y una que pasa hoy pasa mañana con la misma entrada.
Sin limpieza
Nada que restaurar al terminar, porque la ejecución no dejó huella fuera del valor devuelto.
Merece la pena insistir en que nada de esto es una crítica a quienes escriben dobles. Dado un lenguaje donde cualquier función puede leer el reloj del sistema, abrir un fichero o consultar una variable global sin declararlo en ninguna parte, el doble es la respuesta racional al problema, y renunciar a él significa renunciar a probar. La observación es de otro orden: el doble no es una técnica de verificación que Elm haya sustituido por otra mejor, es la reparación de un daño que en Elm no llega a producirse. Por eso el consejo habitual de escribir código que sea fácil de probar, que en otros lenguajes se queda en recomendación y se incumple bajo presión de calendario, aquí no hace falta enunciarlo: el lenguaje no ofrece la alternativa incómoda.
El test recupera su forma canónica
Cuando la firma agota las dependencias, escribir una prueba deja de ser un ejercicio de ingeniería y pasa a ser una lectura de la especificación. La función recibe unos valores y devuelve otro; la prueba escribe los primeros, nombra el segundo que espera y compara. No hay más partes móviles, y esa ausencia se nota sobre todo en el momento en que una prueba falla: como la ejecución no tiene contexto, el fallo solo puede provenir de la función o de la expectativa, de modo que la investigación empieza y termina en el mismo fichero.
module PrecioTest exposing (suite)
import Expect
import Precio exposing (Cupon(..), aplicarCupon)
import Test exposing (Test, describe, test)
suite : Test
suite =
describe "aplicarCupon"
[ test "un porcentaje reduce el importe" <|
\_ ->
aplicarCupon (Porcentaje 20) 1000
|> Expect.equal 800
, test "un descuento fijo nunca deja el importe por debajo de cero" <|
\_ ->
aplicarCupon (Fijo 1500) 1000
|> Expect.equal 0
, test "un cupon caducado deja el importe intacto" <|
\_ ->
aplicarCupon Caducado 1000
|> Expect.equal 1000
]
Repara en lo que no aparece en ese fichero. No hay una función que se ejecute antes de cada caso, no hay una variable compartida entre casos, no hay un objeto construido en tres pasos, no hay una aserción sobre llamadas recibidas. Cada caso es una expresión completa y autónoma que podría copiarse a una consola interactiva y evaluarse tal cual. Esa propiedad, que parece estética, tiene una consecuencia práctica muy concreta: la prueba y el uso real de la función se escriben igual, así que el fichero de pruebas funciona además como catálogo de ejemplos de uso, y no se desincroniza porque un ejemplo equivocado no compila.
Hay una segunda ausencia menos visible y quizá más valiosa: no hay ninguna afirmación sobre el proceso. En ningún punto se comprueba que cierto colaborador fue invocado, ni cuántas veces, ni en qué orden. La prueba habla solo del resultado, y por tanto es indiferente a cómo se obtenga. Si mañana la función se reescribe de arriba abajo, con otra estructura interna, otras funciones auxiliares y otro algoritmo, la suite entera sigue siendo válida y sigue siendo útil: eso es precisamente lo que hace posible refactorizar con red. Una batería acoplada al proceso ofrece lo contrario, un freno que se activa cada vez que alguien intenta mejorar el código sin cambiar su comportamiento.
Cuando al escribir una prueba sientas la tentación de construir un contexto elaborado, léelo como un diagnóstico sobre el diseño y no como una molestia del testing. Casi siempre significa que la función está haciendo dos cosas: decidir y obtener. Extrae la decisión a una función que reciba ya los datos obtenidos y observa cómo la prueba se reduce a una línea. Lo que quede fuera será una función de fontanería tan simple que probarla aporte poco, y esa asimetría es exactamente el objetivo: concentrar la complejidad en el sitio donde probarla es barato.
El compilador ya probó otra clase de cosas
Hay una segunda razón, menos comentada, por la que las baterías de pruebas en Elm son más cortas: una fracción considerable de lo que en otros lenguajes se comprueba en tiempo de ejecución aquí ya está comprobado antes de que exista el ejecutable. No hace falta un test que verifique que una función devuelve algo cuando no encuentra el elemento buscado, porque la ausencia está en el tipo y todo consumidor está obligado a tratarla. No hace falta comprobar que se han contemplado todos los casos de una enumeración, porque un case incompleto no compila. No hace falta verificar que un campo del registro tiene el tipo esperado, ni que una función recibe los argumentos en el orden correcto, ni que un valor nulo no se cuela por una rama olvidada.
Lo que queda para las pruebas es, por tanto, un residuo bien delimitado y mucho más interesante: la lógica. Un tipo puede garantizar que la función devuelve un entero, pero no que ese entero sea el importe correcto. Puede garantizar que se han considerado los tres estados del cupón, pero no que el descuento se calcule sobre la base imponible y no sobre el total. Puede garantizar que la lista de resultados existe, pero no que esté ordenada. Todo eso es aritmética, reglas de negocio, invariantes de dominio y decisiones de producto, y ninguna de esas cosas la deduce un compilador.
-- El compilador ya garantiza la forma del resultado
buscar : String -> List Usuario -> Maybe Usuario
-- Lo que ningun tipo puede garantizar es el criterio
-- Que la busqueda ignore mayusculas y acentos
-- Que devuelva el primero por antiguedad y no por orden de lista
-- Que un nombre vacio no case con todo el mundo
La frontera entre ambas garantías tiene además una utilidad práctica al decidir qué probar, que es la pregunta que más tiempo hace perder en cualquier proyecto. Antes de escribir un caso, conviene preguntarse si el fallo que ese caso detectaría es siquiera expresable en el lenguaje. Si el compilador ya lo impide, la prueba es ruido que habrá que mantener a cambio de nada. Si el compilador no puede impedirlo porque depende de una decisión humana sobre el dominio, entonces esa decisión merece quedar escrita en algún sitio, y una prueba es el mejor sitio disponible: es el único documento que el corredor obliga a mantener verdadero.
Este reparto explica también por qué las suites de Elm son proporcionalmente más pequeñas sin ser menos exigentes. No se ha renunciado a comprobar nada: se ha movido una parte de las comprobaciones al momento de compilar, donde son exhaustivas por construcción y no dependen de que alguien recordara escribir el caso. Una prueba comprueba un ejemplo; un tipo comprueba todos. Cuando las dos herramientas cubren lo mismo, la del tipo es estrictamente mejor y la prueba sobra; cuando el tipo no llega, la prueba es imprescindible y merece cuidado.
flowchart TD F[Funcion pura] --> A[Argumentos declarados] F --> R[Resultado determinista] A --> T[Test como tabla] R --> T C[Compilador] --> X[Tipos casos y ausencias] T --> L[Logica y reglas del dominio] X --> G[Garantias antes de ejecutar] L --> G style F fill:#89b4fa,color:#11111b style T fill:#f9e2af,color:#11111b style G fill:#a6e3a1,color:#11111b
Conviene separar dos propiedades que suelen confundirse. Determinista significa que la misma entrada produce siempre la misma salida; total significa que la función devuelve un valor para toda entrada admitida por su tipo, sin lanzar nada ni quedarse colgada. La primera es la que permite que una prueba sea reproducible y que un fallo intermitente sea inconcebible. La segunda es la que permite que la prueba no tenga que contemplar rutas excepcionales invisibles, porque no existen: si una operación puede fallar, ese fallo está en el tipo de retorno y se prueba como cualquier otro valor. Juntas explican por qué una prueba en Elm no necesita nunca la construcción que en otros lenguajes verifica que algo lanza determinada excepción.
Aquí hay un principio que sobrevive a Elm y que conviene enunciar en su forma general, porque cambia lo que uno hace cuando una prueba resulta incómoda de escribir. La dificultad de probar una unidad de código es proporcional a la distancia entre lo que su firma declara y lo que su cuerpo realmente necesita. Una función cuya firma dice la verdad completa se prueba invocándola, porque la firma es literalmente el contrato de la prueba: los argumentos son el caso y el retorno es la expectativa. Una función cuya firma miente por omisión, porque además de sus argumentos consulta un reloj, un entorno, una conexión o un estado compartido, no se puede invocar sin antes reconstruir todo lo que calló, y esa reconstrucción es el andamiaje. Los dobles, las inyecciones, los contenedores y los relojes falsos no son técnicas de prueba: son el precio que se paga por firmas incompletas, cobrado en el único momento en que la incompletitud se hace visible. De ahí se sigue una regla de trabajo que vale en cualquier lenguaje, con o sin sistema de tipos que la fuerce: cuando escribir la prueba duele, no busques una biblioteca de dobles más expresiva, mueve la frontera. Separa la decisión del efecto, deja que la decisión reciba datos y devuelva datos, y empuja el efecto hacia un borde tan delgado que probarlo unitariamente carezca de sentido. En Elm esa separación no es una recomendación de estilo que se pueda ignorar bajo presión de calendario, es la única forma en que el lenguaje deja escribir el programa: los efectos se describen como valores y los ejecuta el runtime, así que la totalidad de la lógica del dominio queda, por construcción, en el lado barato de probar. La consecuencia es que una batería de pruebas en Elm tiende a hablar solo del dominio, y por eso envejece bien: no está acoplada a una estructura interna que cambiará, sino a un comportamiento que, si cambia, es que el producto cambió.
- Toma un fichero de pruebas de otro lenguaje que tengas a mano y cuenta qué porcentaje de sus líneas construye contexto en lugar de afirmar comportamiento.
- Elige una de esas funciones y reescribe su firma de modo que declare todo lo que consulta; anota cuántos argumentos nuevos aparecen.
- Escribe en Elm una función de dominio con tres casos y sus pruebas correspondientes sin usar ninguna preparación previa.
- Localiza en tu proyecto una función que mezcle decisión y obtención de datos, sepárala en dos y compara la longitud de las pruebas antes y después.
- Enumera tres comprobaciones que hacías con pruebas en otro lenguaje y que aquí realiza el compilador; explica en cada caso qué garantiza exactamente.
- Formula una prueba que falle por una regla de negocio mal implementada y argumenta por qué ningún sistema de tipos razonable la habría detectado.