wandres.dev
TESTING EN ELM · puro es testeable

elm-test: la suite como valor, la expectativa como dato

La herramienta estándar de pruebas de Elm no introduce ninguna sintaxis nueva ni ningún mecanismo mágico de descubrimiento: una suite es un valor ordinario de un tipo llamado Test, una expectativa es un valor ordinario de un tipo llamado Expectation, y el corredor se limita a evaluar el primero para obtener el segundo. Esta lección desmonta esa arquitectura pieza a pieza: por qué el cuerpo de cada caso es una función que difiere la evaluación y qué problema resuelve esa demora, cómo describe agrupa suites en un árbol que puede construirse programáticamente porque es solo una lista, cuál es el vocabulario de expectativas que cubre la práctica totalidad de los casos y cómo componerlas con Expect.all, y qué ciclo de trabajo emerge cuando el corredor observa el sistema de ficheros. Se argumenta además por qué los marcadores de aislamiento y de pendiente tienen un comportamiento deliberadamente incómodo en integración continua.

⏱ 18 min

La primera sorpresa al abrir un fichero de pruebas de Elm es que no hay nada que aprender. No hay anotaciones, no hay una clase base de la que heredar, no hay un descubrimiento por convención de nombres que actúe a tus espaldas, no hay un lenguaje de descripción encadenada que produzca frases en inglés a costa de volverse indepurable. Lo que hay es un valor de tipo Test exportado por el módulo, construido con funciones ordinarias que aceptan cadenas y listas, y un ejecutable que lo evalúa. Esta economía no es minimalismo por gusto: al ser la suite un valor de primera clase, todo lo que sabes hacer con valores vale aquí sin adaptación. Puedes generar casos con List.map, agrupar con la función que quieras, extraer un ayudante que devuelva un Test y reutilizarlo, o construir la lista de casos a partir de una tabla de datos. En la mayoría de los ecosistemas eso exige un mecanismo especial con nombre propio, del estilo de las pruebas parametrizadas; aquí no exige nada porque nunca hubo una barrera entre el código de prueba y el código normal.

🎯 Al terminar esta lección sabrás
  • Describir la anatomía de una suite como valor de tipo Test y explicar por qué el cuerpo de cada caso es una función diferida.
  • Manejar el vocabulario nuclear de expectativas y componer varias sobre un mismo sujeto con Expect.all.
  • Construir suites programáticamente a partir de tablas de datos aprovechando que un grupo es una lista.
  • Adoptar un ciclo de trabajo con el corredor en modo de observación y usar con criterio los marcadores de aislamiento y de pendiente.

Anatomía de una suite

Un proyecto con pruebas tiene un directorio tests hermano de src, y cada fichero que hay dentro expone uno o varios valores de tipo Test. La función test recibe una descripción y el cuerpo del caso; la función describe recibe una descripción y una lista de pruebas, y devuelve otra prueba, de modo que el anidamiento es simple recursión sobre el mismo tipo. No hay diferencia estructural entre un caso y un grupo de mil: ambos son un Test, y por eso pueden mezclarse en la misma lista.

El detalle que más desconcierta al principio es la barra invertida seguida de un guion bajo que precede al cuerpo de cada caso. Ese cuerpo no es una expresión evaluada al construir la suite, sino una función que ignora su argumento y produce la expectativa cuando el corredor decide ejecutarla. La razón es doble y ambas mitades importan: permite que el corredor construya el árbol completo de pruebas sin ejecutar ninguna, lo cual es imprescindible para poder listarlas, filtrarlas y repartirlas entre procesos, y permite que un caso costoso no consuma tiempo si otro marcador lo excluye de esta ejecución.

module CarritoTest exposing (suite)

import Carrito exposing (Item, total)
import Expect
import Test exposing (Test, describe, test)


item : String -> Int -> Int -> Item
item nombre precio unidades =
    { nombre = nombre, precio = precio, unidades = unidades }


suite : Test
suite =
    describe "Carrito"
        [ describe "total"
            [ test "un carrito vacio suma cero" <|
                \_ ->
                    total []
                        |> Expect.equal 0
            , test "multiplica precio por unidades" <|
                \_ ->
                    total [ item "cafe" 350 2 ]
                        |> Expect.equal 700
            , test "acumula varias lineas" <|
                \_ ->
                    total [ item "cafe" 350 2, item "te" 300 1 ]
                        |> Expect.equal 1000
            ]
        ]
🧩

test

Descripción y cuerpo diferido. El cuerpo devuelve una Expectation, que también es un valor corriente.

🗂️

describe

Agrupa una lista de pruebas y devuelve otra prueba. El árbol es recursivo porque el tipo es cerrado.

🔍

Expect

El módulo de expectativas. Cada función produce el mismo tipo, así que todas son intercambiables donde se espera una.

🛠️

El corredor

Compila el proyecto, evalúa el árbol y ejecuta los cuerpos. Si algo no compila, no hay pruebas rojas: no hay pruebas.

El vocabulario de las expectativas

La expectativa más usada compara dos valores por igualdad estructural y, cuando falla, imprime ambos con formato para que la diferencia salte a la vista sin depurar. A su lado hay comparaciones de orden, comprobaciones sobre colecciones y dos expectativas que resuelven casi todo lo demás: la que exige que un resultado sea correcto y la que exige que sea erróneo, sin obligar a construir el error exacto cuando lo que importa es solo que haya fallado.

La pieza que conviene conocer pronto es la que compone varias expectativas sobre un mismo sujeto. Recibe una lista de funciones que van del sujeto a una expectativa y las aplica todas, informando de todas las que fallen en lugar de detenerse en la primera. Esto cambia la calidad del diagnóstico: en vez de arreglar un fallo, volver a ejecutar y descubrir el siguiente, ves de una vez el conjunto de propiedades incumplidas.

import Expect


ejemplos : Test
ejemplos =
    describe "vocabulario de expectativas"
        [ test "igualdad estructural" <|
            \_ -> Expect.equal [ 1, 2, 3 ] (List.sort [ 3, 1, 2 ])
        , test "orden" <|
            \_ -> Expect.lessThan 100 (total [ item "te" 300 1 ] // 10)
        , test "resultado correcto sin fijar el valor" <|
            \_ -> Expect.ok (Carrito.validar [ item "cafe" 350 1 ])
        , test "fallo esperado" <|
            \_ -> Expect.err (Carrito.validar [ item "cafe" 350 0 ])
        , test "varias propiedades sobre el mismo sujeto" <|
            \_ ->
                Carrito.resumen [ item "cafe" 350 2 ]
                    |> Expect.all
                        [ .lineas >> Expect.equal 1
                        , .importe >> Expect.greaterThan 0
                        , .importe >> Expect.equal 700
                        ]
        ]
💡
Genera casos con las funciones que ya conoces

Como un grupo es una lista, una tabla de ejemplos se convierte en pruebas con una sola llamada a List.map. Escribe una lista de tuplas con la entrada, la salida esperada y una etiqueta legible, y transfórmala en la lista de casos. Ganas dos cosas: añadir un ejemplo cuesta una línea de datos en lugar de un bloque de código, y la tabla se lee como una especificación que puede revisar alguien que no programa. Cuida solo que la etiqueta de cada caso sea distinta y describa el ejemplo concreto, porque un fallo se identifica por su descripción y una tabla con etiquetas repetidas destruye ese vínculo.

El ciclo de trabajo

El corredor se invoca sin argumentos para ejecutar todo, con una ruta para restringirse a un fichero, y con la bandera de observación para que vuelva a ejecutar en cuanto un fichero cambie. Esa última modalidad es la que define el ritmo real de trabajo en Elm: el compilador y las pruebas comparten la misma ventana, y el bucle de retroalimentación pasa por dos filtros sucesivos, primero el de tipos y luego el de comportamiento. Merece la pena interiorizar que un fallo de compilación no produce pruebas rojas sino ausencia de pruebas, porque el árbol no se pudo construir; el error que se lee entonces es del compilador y hay que atenderlo antes de mirar nada más.

npx elm-test
npx elm-test --watch
npx elm-test tests/CarritoTest.elm
npx elm-test --seed 4321 --fuzz 500

Existen tres modificadores que se aplican a un caso o a un grupo cambiando su construcción. Uno restringe la ejecución a lo marcado, otro excluye lo marcado, y el tercero declara una prueba pendiente que todavía no tiene cuerpo. Los tres comparten una característica deliberada y saludable: hacen que la ejecución se considere fallida aunque todo lo ejecutado pase. La razón es que su utilidad es local y temporal, para concentrarse en un problema durante minutos, y su permanencia en la rama principal sería un engaño: una suite verde que en realidad no ejecuta la mitad de sus casos es indistinguible, desde el panel de integración continua, de una suite que sí los ejecuta.

flowchart LR
E[Editar codigo] --> C[Compilador]
C -->|error de tipos| E
C -->|compila| S[Arbol de Test]
S --> R[Corredor evalua cuerpos]
R -->|rojo| E
R -->|verde| D[Refactorizar con red]
D --> E
style C fill:#89b4fa,color:#11111b
style R fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b
ℹ️
Por qué la descripción es un argumento y no un comentario

La cadena que acompaña a cada caso no es decoración. El corredor la usa para identificar la prueba en el informe, para filtrarla desde la línea de órdenes y, sobre todo, para detectar descripciones duplicadas dentro de un mismo grupo, que trata como error. Esa severidad parece excesiva hasta que se sufre lo contrario: dos casos con la misma etiqueta convierten un informe de fallo en una adivinanza. Escribe la descripción como la afirmación que el caso demuestra, en tercera persona y sin la palabra debería, de modo que la salida del corredor se pueda leer de arriba abajo como el enunciado del comportamiento del módulo.

Cuando la prueba es un valor, la suite deja de ser un lenguaje aparte

Hay una decisión de diseño en esta herramienta que parece menor y que gobierna todo lo demás: no inventar nada. En la mayoría de los ecosistemas, el marco de pruebas es de facto un segundo lenguaje incrustado en el primero, con su propio vocabulario de registro de casos, su propio ciclo de vida con ganchos que se ejecutan antes y después, su propio mecanismo de parametrización, su propia forma de agrupar y su propio sistema de descubrimiento por convenciones invisibles. Ese segundo lenguaje tiene que ser aprendido, tiene documentación aparte, tiene versiones incompatibles entre sí y, lo que más cuesta, tiene una frontera: dentro de él puedes hacer las cosas que sus autores previeron, y en cuanto necesitas algo que no previeron te quedas sin recursos, porque el código de prueba no es código normal y no admite las técnicas normales. La alternativa que toma Elm consiste en modelar la prueba como lo que es, un dato, y dejar que el lenguaje entero opere sobre ella. Un caso es un valor, un grupo es una lista de valores, una expectativa es otro valor, y en consecuencia una tabla de ejemplos se convierte en casos con la misma función que usarías para cualquier otra transformación, un ayudante que fabrica pruebas se escribe como cualquier función, y un conjunto de casos compartido entre dos módulos se factoriza como se factoriza cualquier cosa. Los ganchos de ciclo de vida desaparecen sin dejar hueco, porque solo existían para compartir estado mutable entre casos y aquí no hay estado que compartir. Y hay una consecuencia final que solo se aprecia con el tiempo: como el árbol de pruebas se construye antes de ejecutar nada y sin efectos, el corredor puede hacer con él cosas que un registro imperativo no permite, desde listar y filtrar hasta paralelizar sin riesgo, porque sabe que ninguna prueba puede interferir con otra. La uniformidad no era una elegancia gratuita; era la condición para que la herramienta pudiera razonar sobre tu suite.

⚔️ Construye la suite como construirías cualquier valor
  1. Crea el directorio de pruebas de un proyecto tuyo y escribe una suite con tres casos sobre una función existente.
  2. Rompe a propósito un tipo en el código de producción y observa la diferencia entre un informe de fallo y un error de compilación.
  3. Convierte cuatro casos casi idénticos en una tabla de tuplas transformada con List.map, cuidando que cada etiqueta sea distinta.
  4. Reúne tres expectativas sobre un mismo resultado con Expect.all y provoca dos fallos simultáneos para comprobar que informa de ambos.
  5. Marca un grupo con el modificador de aislamiento, ejecuta la suite y explica por qué el resultado global se considera fallido.
  6. Escribe dos pruebas pendientes que describan comportamiento aún no implementado y úsalas como lista de tareas del módulo.