Testear update: la transición como afirmación verificable
La función de actualización es el único lugar donde una aplicación construida sobre esta arquitectura cambia de estado, y es además una función total y determinista, de modo que probarla consiste literalmente en escribir un modelo, enviarle un mensaje y afirmar el modelo siguiente. Esta lección desarrolla las consecuencias de esa simplicidad: cómo se construye el modelo de partida sin ceremonias, por qué el comando devuelto es opaco y no admite comparación por igualdad, qué dos estrategias existen para verificar el efecto solicitado y por qué la que modela el efecto como dato propio del dominio cambia el alcance de lo que se puede afirmar, cómo se prueban secuencias completas de mensajes plegando una lista sobre el modelo, y por qué una suite escrita así funciona como especificación ejecutable que no puede desincronizarse del código que describe.
En una aplicación imperativa, preguntar qué le pasa al estado cuando el usuario pulsa un botón no tiene respuesta local. El estado vive repartido en varios objetos, la pulsación dispara un manejador que llama a un servicio que muta un almacén que notifica a un observador, y para saber el resultado hay que recorrer esa cadena entera con la esperanza de que nadie más la haya tocado por el camino. En esta arquitectura la misma pregunta tiene una respuesta que cabe en una línea, porque existe exactamente una función que la responde y su firma lo dice todo: recibe un mensaje y un modelo, devuelve un modelo y un comando. No hay otra vía por la que el estado pueda cambiar, no hay observadores que se enteren primero, no hay orden de ejecución que negociar. Esa unicidad convierte la prueba de la lógica de estado en el caso más favorable imaginable: se construye el modelo de partida como se construye cualquier registro, se llama a la función con el mensaje que interesa y se compara el resultado con el modelo que la regla de negocio exige. Nada más forma parte del experimento, porque nada más participa en el fenómeno.
- Probar una transición construyendo el modelo de partida, aplicando un mensaje y afirmando el modelo resultante.
- Explicar por qué el comando devuelto es opaco y elegir entre ignorarlo o modelar el efecto como dato del dominio.
- Verificar secuencias de mensajes plegando una lista sobre el modelo para reproducir recorridos completos de usuario.
- Redactar descripciones de casos que enuncien reglas de negocio, de modo que la suite se lea como especificación viva.
La transición como experimento cerrado
Probar la función de actualización no requiere ninguna técnica especial porque es una función corriente. Lo único que conviene establecer pronto es una manera cómoda de fabricar modelos de partida, ya que un modelo real tiene bastantes campos y repetirlos completos en cada caso enterraría la afirmación bajo el ruido. La solución idiomática consiste en definir un modelo base en el fichero de pruebas y modificarlo por campos en cada caso concreto, de forma que lo escrito en cada prueba sea exactamente lo relevante para esa prueba y nada más.
module ContadorTest exposing (suite)
import Contador exposing (Model, Msg(..), update)
import Expect
import Test exposing (Test, describe, test)
base : Model
base =
{ cuenta = 0, limite = 10, bloqueado = False, historial = [] }
aplicar : Msg -> Model -> Model
aplicar msg modelo =
Tuple.first (update msg modelo)
suite : Test
suite =
describe "update"
[ test "incrementar suma uno a la cuenta" <|
\_ ->
aplicar Incrementar base
|> .cuenta
|> Expect.equal 1
, test "incrementar en el limite no pasa del limite" <|
\_ ->
aplicar Incrementar { base | cuenta = 10 }
|> .cuenta
|> Expect.equal 10
, test "un contador bloqueado ignora los incrementos" <|
\_ ->
aplicar Incrementar { base | bloqueado = True }
|> Expect.equal { base | bloqueado = True }
, test "reiniciar conserva el limite configurado" <|
\_ ->
aplicar Reiniciar { base | cuenta = 7, limite = 99 }
|> Expect.all
[ .cuenta >> Expect.equal 0
, .limite >> Expect.equal 99
]
]
Modelo base
Un valor completo en el fichero de pruebas, modificado por campos en cada caso. Lo escrito es lo pertinente.
Afirmar el campo
Comparar solo lo que la regla toca evita que un campo nuevo rompa veinte pruebas que no hablaban de él.
Afirmar el modelo entero
Útil cuando la regla dice que nada cambia. La igualdad total es aquí la afirmación exacta.
Ayudante local
Una función que aplica el mensaje y descarta el comando ahorra ruido en cada caso y documenta la intención.
El comando devuelto y sus dos estrategias
La segunda componente del resultado plantea un problema real: un comando es un tipo opaco cuyo contenido el runtime interpreta, y no admite comparación por igualdad ni inspección. No se puede afirmar que la función devolvió una petición concreta a una dirección concreta, porque desde el código no hay forma de mirar dentro. La primera estrategia, perfectamente legítima y la más común en proyectos pequeños, consiste en aceptar esa limitación y probar únicamente la parte del estado, descartando el comando con el ayudante que toma el primer elemento del par.
La segunda estrategia cambia la firma de la función para que no devuelva un comando sino un valor de un tipo propio que enumera los efectos que la aplicación sabe pedir. La función de actualización pasa a ser una relación entre datos y datos por completo, ese tipo de efectos se traduce a comandos reales en un único punto del programa, y las pruebas recuperan la capacidad de afirmar qué se pidió, con qué argumentos y en qué circunstancias. El coste es una capa de traducción; el beneficio es que la decisión de efectuar algo, que suele ser la parte con reglas de negocio, deja de ser invisible para las pruebas.
type Efecto
= Ninguno
| GuardarBorrador String
| PedirUsuario Int
| Navegar String
update : Msg -> Model -> ( Model, Efecto )
update msg modelo =
case msg of
EscribioTexto texto ->
if String.length texto > 20 then
( { modelo | borrador = texto }, GuardarBorrador texto )
else
( { modelo | borrador = texto }, Ninguno )
PulsoGuardar ->
( { modelo | guardando = True }, GuardarBorrador modelo.borrador )
-- En la prueba, el efecto ya es un valor comparable
test "un borrador largo se guarda solo" <|
\_ ->
update (EscribioTexto (String.repeat 30 "a")) base
|> Tuple.second
|> Expect.equal (GuardarBorrador (String.repeat 30 "a"))
No conviertas todos los comandos en un tipo propio por sistema: si la función siempre pide lo mismo tras el mismo mensaje, la traducción añade ruido sin añadir garantías. El criterio es otro: hazlo cuando exista una regla que decida si se pide el efecto, cuál de varios, o con qué argumentos. Guardar solo si el borrador supera cierta longitud, navegar solo si la sesión sigue viva, reintentar solo hasta tres veces. Esas condiciones son lógica de negocio disfrazada de fontanería, y mientras vivan dentro de un valor opaco ninguna prueba puede tocarlas.
Secuencias de mensajes y especificación viva
Muchas reglas interesantes no se manifiestan en una transición aislada sino en una sucesión: escribir, borrar, volver a escribir y comprobar que el historial tiene un solo elemento; abrir el diálogo, cancelarlo y verificar que el formulario quedó como estaba. Como la función es pura y devuelve el modelo siguiente, una secuencia se expresa plegando una lista de mensajes sobre el modelo inicial, sin ninguna maquinaria adicional. El recorrido de usuario se convierte así en un dato, y el dato se lee de un vistazo.
recorrido : List Msg -> Model -> Model
recorrido mensajes modelo =
List.foldl aplicar modelo mensajes
test "cancelar tras editar deja el formulario intacto" <|
\_ ->
base
|> recorrido
[ AbrirDialogo
, EscribioTexto "borrador a medias"
, CancelarDialogo
]
|> Expect.equal base
flowchart LR M0[Modelo inicial] --> U1[update con Msg uno] U1 --> M1[Modelo intermedio] M1 --> U2[update con Msg dos] U2 --> M2[Modelo final] M2 --> A[Expect sobre el estado] U1 --> E[Efecto como dato] U2 --> E E --> B[Expect sobre lo solicitado] style U1 fill:#89b4fa,color:#11111b style U2 fill:#89b4fa,color:#11111b style A fill:#a6e3a1,color:#11111b style B fill:#f9e2af,color:#11111b
Compara dos etiquetas para el mismo caso. Una dice que enviar el mensaje de incrementar con la cuenta igual al límite devuelve la cuenta igual al límite; la otra dice que un contador que alcanzó su límite no admite más incrementos. Ambas describen la misma prueba, pero solo la segunda sirve a quien llega al proyecto sin conocerlo, aparece útil en un informe de fallo y detecta por sí sola que la regla cambió. La primera es una paráfrasis del código y envejece con él sin aportar información. La disciplina es simple: la descripción debe poder leerla el responsable de producto y reconocer en ella una decisión que tomó.
El valor de esta forma de probar no está en la comodidad, aunque sea considerable, sino en una propiedad estructural de la arquitectura que conviene ver entera. En un sistema donde el estado cambia por muchas vías, una batería de pruebas de estado es necesariamente incompleta, no porque falten casos sino porque falta cierre: aunque probaras exhaustivamente cada manejador, nada garantiza que un tercero no modifique lo mismo desde otro sitio, y por eso las pruebas de esos sistemas tienden a crecer hacia la integración, buscando cubrir combinaciones que la estructura no acota. Aquí la estructura sí acota. Todo cambio de estado pasa por una función, esa función es total, y su argumento contiene la totalidad del estado anterior. De ahí se sigue que el conjunto de transiciones posibles sea el producto de los constructores del tipo de mensajes por los estados alcanzables del modelo, un espacio enumerable sobre el que se puede razonar, y que el compilador te recuerde su tamaño exacto cada vez que añades un constructor. Una suite que cubre las transiciones no está muestreando el comportamiento: está describiendo la máquina completa. Y aquí aparece la propiedad que justifica el nombre de documentación viva, que suele usarse con ligereza y aquí es literal. Un documento que describe reglas de negocio se desincroniza del código en cuanto alguien cambia el código, y no hay manera de enterarse salvo leyendo ambos. Una suite escrita como aquí no puede desincronizarse por dos motivos independientes: si el tipo de mensajes o el modelo cambian, la prueba deja de compilar y el fallo es inmediato; si la regla cambia sin cambiar los tipos, la prueba se pone roja y el fallo es igual de inmediato. La consecuencia es que el fichero de pruebas de la función de actualización es, en un proyecto maduro, el mejor documento que existe sobre lo que la aplicación hace, mejor que cualquier especificación redactada aparte, porque es el único que el compilador y el corredor obligan a mantener verdadero.
- Define un modelo base en un fichero de pruebas y escribe cuatro casos que modifiquen solo el campo pertinente en cada uno.
- Prueba una regla de no cambio afirmando el modelo entero y explica por qué aquí la igualdad total es la afirmación exacta.
- Sustituye el comando de tu función de actualización por un tipo propio de efectos en un módulo pequeño y prueba qué efecto se pide.
- Escribe un recorrido de cinco mensajes con un plegado y afirma una propiedad que ninguna transición aislada revelaría.
- Añade un constructor nuevo al tipo de mensajes y anota cuántas pruebas y cuántos errores de compilación aparecen.
- Reescribe las descripciones de tus casos como reglas de negocio y comprueba si el informe del corredor se puede leer como una especificación.