Testear la vista y los decodificadores: los dos bordes del programa
Quedan por cubrir los dos extremos de la aplicación, y ambos comparten una virtud que los hace más fáciles de probar de lo que su fama sugiere: la vista no es una pantalla sino un valor que describe una pantalla, y un decodificador no es un proceso sino un plan que se ejecuta sobre un texto. Esta lección muestra cómo consultar el árbol devuelto por la vista con selectores y afirmar presencia, ausencia y recuento sin abrir ningún navegador; cómo elm-program-test eleva el sujeto de la prueba del componente al programa entero, simulando pulsaciones, escritura en campos y respuestas de red para escribir pruebas que se leen como recorridos de usuario; y por qué un decodificador debe probarse contra una respuesta real capturada del servicio, convirtiendo esa prueba en el único mecanismo capaz de detectar automáticamente que un contrato externo cambió.
Los dos bordes de una aplicación tienen mala reputación en materia de pruebas, y en la mayoría de los ecosistemas está justificada. Probar la interfaz suele significar arrancar un navegador de verdad, esperar a que aparezcan elementos, luchar con esperas que fallan una vez de cada treinta y mantener una batería tan lenta que nadie la ejecuta antes de subir código. Probar la deserialización suele significar comprobar que una biblioteca de anotaciones hace lo que promete, ejercicio de escaso interés. En Elm ninguna de las dos cosas es así, y el motivo es el mismo en ambos casos: los dos bordes están hechos de datos. La vista es una función pura que devuelve una descripción del árbol, no una pantalla pintada, de modo que se puede llamar y examinar su resultado como se examina cualquier estructura. Un decodificador es un valor que describe un plan, y ejecutarlo sobre un texto devuelve un resultado que puede ser correcto o erróneo, de modo que la prueba consiste en ejecutar el plan contra un documento real y mirar qué salió. En ninguno de los dos casos hace falta un navegador, un servidor ni una espera.
- Consultar el árbol devuelto por una función de vista con selectores y afirmar presencia, ausencia y recuento de elementos.
- Elevar el sujeto de la prueba al programa completo con
elm-program-testy simular interacción y respuestas de red. - Probar decodificadores contra respuestas reales capturadas del servicio y verificar también los documentos que deben fallar.
- Justificar por qué la prueba del decodificador es el detector automático de cambios en un contrato externo.
La vista es un valor consultable
La función de vista devuelve un Html msg, que no es una pantalla sino la descripción de una. Sobre esa descripción se puede lanzar una consulta, buscar un nodo por etiqueta, clase, texto o atributo, y afirmar lo que corresponda. El módulo de consultas trabaja con dos operaciones básicas, encontrar un nodo único o recoger todos los que casan, y con expectativas que comprueban si el nodo tiene ciertas características o cuántos elementos contiene la colección hallada.
Conviene fijar desde el principio el criterio sobre qué afirmar. Una prueba que verifica clases de maquetación, orden de contenedores o profundidad del anidamiento se rompe con cada retoque visual sin haber detectado nunca un fallo real. Una prueba que verifica que un modelo con la lista vacía produce el mensaje de estado vacío, o que un modelo con un error muestra el texto del error, comprueba una decisión de producto y sobrevive a cualquier rediseño. La regla práctica es afirmar sobre lo que el usuario percibe y sobre las bifurcaciones de la vista, nunca sobre su estructura interna.
module VistaTest exposing (suite)
import Html.Attributes as A
import Lista exposing (view)
import Test exposing (Test, describe, test)
import Test.Html.Query as Query
import Test.Html.Selector exposing (attribute, class, tag, text)
suite : Test
suite =
describe "view"
[ test "sin elementos muestra el estado vacio" <|
\_ ->
view { items = [], cargando = False, error = Nothing }
|> Query.fromHtml
|> Query.has [ text "Todavia no hay nada aqui" ]
, test "pinta una fila por elemento" <|
\_ ->
view { items = [ "uno", "dos", "tres" ], cargando = False, error = Nothing }
|> Query.fromHtml
|> Query.findAll [ tag "li" ]
|> Query.count (\n -> Expect.equal 3 n)
, test "mientras carga desactiva el boton" <|
\_ ->
view { items = [], cargando = True, error = Nothing }
|> Query.fromHtml
|> Query.find [ tag "button" ]
|> Query.has [ attribute (A.disabled True) ]
]
Query
Convierte el árbol en un sujeto consultable. find exige un único nodo; findAll recoge una colección.
Selector
Etiqueta, clase, atributo o texto. Prefiere el texto y los atributos accesibles frente a las clases de estilo.
Event
Dispara un evento sobre un nodo y afirma qué mensaje habría emitido. La vista se prueba conectada a su tipo de mensajes.
ProgramTest
Sube el sujeto al programa entero: estado, vista y efectos simulados, en pruebas que leen como recorridos.
El programa entero como sujeto
Consultar la vista comprueba que un modelo produce cierta pantalla, y probar la actualización comprueba que un mensaje produce cierto modelo, pero entre ambas cosas queda un hueco: que el botón que se ve emita el mensaje que corresponde y que la pantalla resultante sea la esperada. elm-program-test cubre ese hueco tratando el programa completo como sujeto y ofreciendo operaciones escritas en el vocabulario del usuario, no en el del código: pulsar un botón identificado por su texto, rellenar un campo identificado por su etiqueta, y afirmar qué se ve después.
La pieza que hace todo esto viable es la simulación de efectos, y aquí se recoge el fruto de haber modelado los efectos como datos. Si la función de actualización devuelve un valor de un tipo propio, la biblioteca puede traducirlo a un efecto simulado, interceptar la petición de red y dejar que la prueba decida qué responde el servidor. La prueba se convierte entonces en un guion completo con su bifurcación de fallo, sin servidor, sin espera y sin intermitencia.
inicio : ProgramTest Model Msg Efecto
inicio =
ProgramTest.createElement
{ init = Pagina.init
, update = Pagina.update
, view = Pagina.view
}
|> ProgramTest.withSimulatedEffects Pagina.simular
|> ProgramTest.start ()
test "guardar un nombre valido muestra la confirmacion" <|
\_ ->
inicio
|> ProgramTest.fillIn "nombre" "Nombre completo" "Ada Lovelace"
|> ProgramTest.clickButton "Guardar"
|> ProgramTest.simulateHttpOk
"POST"
"https://api.example.com/usuarios"
"{ \"id\": 7 }"
|> ProgramTest.expectViewHas [ text "Guardado correctamente" ]
Las operaciones de esta biblioteca buscan los elementos igual que los busca una persona o un lector de pantalla: el botón por su texto, el campo por la etiqueta asociada. Esa elección no es casual y tiene un efecto colateral excelente. Si una prueba no encuentra el campo porque no hay etiqueta asociada, acabas de descubrir un problema de accesibilidad real y no un capricho de la herramienta. Adopta la costumbre de localizar siempre por texto visible, etiqueta o rol, y tu batería quedará acoplada a la interfaz que el usuario percibe en lugar de a los nombres internos de las clases.
Los decodificadores contra documentos reales
Un decodificador es una función pura de texto a resultado, así que su prueba es un caso particular de lo que ya sabes hacer. Lo que decide su valor no es la técnica sino la procedencia del documento con el que se prueba: si el JSON de la prueba lo escribiste tú mirando tu propio tipo, la prueba solo demuestra que sabes copiar, y pasará en verde el día en que el servicio cambie un campo. La prueba útil usa una respuesta capturada del servicio real, guardada literalmente, con sus campos sobrantes, sus nulos y sus nombres tal como llegan.
Conviene además probar los documentos que deben fallar. Un decodificador que acepta lo que debería rechazar es un fallo más grave que uno que rechaza de más, porque el primero deja entrar datos mal formados hasta el fondo de la aplicación y el segundo se manifiesta en la frontera con un error legible.
respuestaReal : String
respuestaReal =
"""
{ "id": 42
, "nombre": "Ada"
, "plan": "pago"
, "meses": 12
, "metadatos": { "creado": "2024-03-01", "origen": "web" }
}
"""
suite : Test
suite =
describe "usuarioDecoder"
[ test "decodifica una respuesta real del servicio" <|
\_ ->
Decode.decodeString Usuario.decoder respuestaReal
|> Expect.equal
(Ok { id = 42, nombre = "Ada", plan = Pago 12 })
, test "rechaza un plan desconocido" <|
\_ ->
Decode.decodeString Usuario.decoder """{ "id": 1, "nombre": "X", "plan": "vitalicio" }"""
|> Expect.err
, fuzz Generadores.usuario "codificar y decodificar devuelve el original" <|
\u ->
Encode.encode 0 (Usuario.encoder u)
|> Decode.decodeString Usuario.decoder
|> Expect.equal (Ok u)
]
flowchart TD J[JSON real capturado] --> D[Decoder ejecutado] D --> R[Result con valor o error] R --> A1[Expect sobre el dominio] M[Model] --> V[view devuelve un arbol] V --> Q[Query con selectores] Q --> A2[Expect sobre lo visible] P[ProgramTest] --> V P --> D P --> A3[Recorrido de usuario completo] style D fill:#89b4fa,color:#11111b style V fill:#cba6f7,color:#11111b style P fill:#f9e2af,color:#11111b
Vale la pena entender qué se está comprando exactamente con estas pruebas, porque no es lo que parece. No comprueban que la biblioteca funcione, cosa que ya sabemos. Comprueban que el documento que el servicio envía hoy sigue encajando con el tipo que la aplicación espera. Actualiza el fichero capturado cada vez que toques la integración y, mejor aún, obtén la muestra del contrato publicado por el servicio. El día en que alguien renombre un campo o convierta un entero en cadena, esa prueba se pondrá roja en tu integración continua en lugar de convertirse en una pantalla de error para un usuario a las tres de la madrugada.
Merece la pena cerrar el nivel señalando lo que estas dos técnicas tienen en común, porque ahí está la lección transferible a cualquier lenguaje. La razón de que probar una interfaz sea normalmente lento y frágil no es que las interfaces sean intrínsecamente difíciles de examinar; es que en la mayoría de los marcos la vista no existe como valor. El código de vista muta un árbol vivo, y para observar el efecto de ese código hay que reconstruir el entorno donde el árbol vive, es decir, un navegador, con su tiempo, su asincronía y sus condiciones de carrera. La lentitud y la intermitencia no son propiedades de la prueba: son consecuencias de que el sujeto no fuera un valor. Cuando la vista se define como una función pura del modelo a una descripción inmutable, todo eso desaparece de golpe, no porque la herramienta sea mejor, sino porque el problema que la herramienta resolvía dejó de existir. Lo mismo ocurre en el otro borde: la deserialización es difícil de probar cuando está esparcida en anotaciones sobre clases y en constructores que se ejecutan durante la carga, y es trivial cuando es un valor que se pasa como argumento a una función. La generalización es la misma que atraviesa todo este nivel: la testabilidad no es una cualidad que se añade a un sistema escribiendo más pruebas, es una consecuencia de dónde se colocó la frontera entre lo que es dato y lo que es efecto. Un sistema en el que la lógica es dato, la vista es dato, el efecto solicitado es dato y el documento entrante es dato tiene pruebas rápidas, deterministas y legibles no por virtud de su equipo, sino por construcción. Y a la inversa, ninguna cantidad de disciplina de pruebas rescata a un sistema donde esa frontera se dibujó mal: se acaba comprando velocidad con dobles que mienten, o fidelidad con baterías que tardan veinte minutos y fallan sin motivo. Elegir bien la frontera es, por tanto, la decisión de testing más importante que se toma en un proyecto, y se toma antes de escribir la primera prueba.
- Escribe tres pruebas de vista que afirmen presencia, ausencia y recuento, y ninguna que dependa de una clase de estilo.
- Dispara un evento sobre un nodo y comprueba qué mensaje emite; después renombra el mensaje y observa qué falla primero.
- Monta un recorrido con
elm-program-testque rellene un campo, pulse un botón y verifique la pantalla resultante. - Simula la respuesta de error del servicio en ese mismo recorrido y afirma que se muestra el mensaje adecuado.
- Captura una respuesta real de un servicio que uses, guárdala literalmente y prueba tu decodificador contra ella.
- Escribe tres documentos que deban ser rechazados y justifica en cada caso por qué aceptarlo sería peor que fallar.