Tabla de contenidos
- Acerca de este libro
- Introducción: Del ojímetro al tdd
- Testing desde cero
- Testing en contexto
- Psicología del testing
- Primer test
- Un ejercicio para aprender TDD
- Desarrollar un algoritmo paso a paso con TDD: Luhn Test kata
- Clean testing
- Test doubles (1)
- Test doubles (2) Principios de diseño
- Test doubles 3: un proyecto desde cero
- Resolver problemas con baby-steps
- Usar el code coverage para mejorar los tests
- Testeando lo impredecible
- TDD en PHP. Un ejemplo con colecciones (1)
- TDD en PHP. Un ejemplo con colecciones (2)
- TDD en PHP. Un ejemplo con colecciones (3)
- TDD en PHP. Un ejemplo con colecciones (4)
- TDD en PHP. Un ejemplo con colecciones (5)
- Apéndices
- Apéndice 1: PhpUnit
- Apéndice 2: PhpSpec
- Apéndice 3: Codeception
- Apéndice 4: Talking Bit dojo + PHPStorm
- Notas
Acerca de este libro
Este libro nació un poco sin querer. O, al menos, no empezó siendo un libro, sino una colección de unos cuantos artículos en el blog The Talking Bit.
Hace varios años que me acostumbré a escribir las cosas que aprendo o sobre las que intento profundizar. Escribir para uno mismo puede ser difícil porque lo aplazas, o lo haces de una manera desorganizada y acabas perdiendo las cosas. O acabas dejándolo.
Por eso, un buen día decidí hacer un blog.
Para mí un blog tiene varias ventajas:
- La primera es que tienes todas la notas en un sólo sitio.
- Ese sitio está en la nube, accesible desde cualquier dispositivo, así que cuando necesitas consultar algo ahí está.
- Una vez que escribes, te puedes quitar el problema de la cabeza.
- Como cabe la remota posibilidad de que alguien más que tú lo lea, tienes más cuidado al escribir. Te diriges a una persona diferente a ti y procuras no dar demasiadas cosas por sentadas.
- También procuras tener más rigor, y no es la primera vez que empiezo un artículo con una idea y acabo defendiendo la contraria porque al analizarla me doy cuenta de que mi planteamiento inicial era el equivocado.
- Eso también hace que te sientas más comprometido a escribir cosas con cierta frecuencia.
En general, no hay nada mejor para aprender algo que intentar enseñar a otros, aunque los “otros” sean una entidad más o menos abstracta.
El caso es que un buen día me di cuenta de que había escrito un puñado de artículos sobre testing en el blog. Al fin y al cabo es un tema que siempre me ha llamado la atención y que, profesionalmente, me interesa muchísimo.
Entonces se me ocurrió pensar en que podría hacerse un libro con ellos. Por un lado, es verdad que los artículos están disponibles en la web, pero también lo es que muchas personas prefieren tenerlo todo en un mismo lugar.
Como conocía LeanPub pensé en hacer la prueba de crear un prototipo de libro, por ver si tenía entidad suficiente. Resultó que sí: salieron más de trescientas páginas de prosa y código.
Obviamente, una colección de artículos escritos a lo largo de casi tres años no se convierte automáticamente en un libro. Hay que hacer algunos arreglos, como intentar eliminar las referencias al blog o a cuestiones temporales que serían incomprensibles en el libro. Por otro lado, los artículos no tenían un plan, por lo que fue necesario darles un poco de orden y de unidad.
La progresión de artículos dejaba algunas lagunas, como algún tipo de proyecto sencillo para introducir a los potenciales lectores al testing en general y a la metodología TDD en particular. Por esa razón, el libro incluye algunos elementos que no están en el blog.
Además, hubo algún artículo que tuve que cambiar porque el código ni siquiera estaba en PHP.
Finalmente, quería incluir algunos detalles más básicos de instalación y configuración, en forma de apéndices.
Y aquí tienes el resultado.
Algunas limitaciones del libro
Dado que el origen del libro ha sido un poco caótico, es posible que notes que falta una cierta sistemática, un hilo o una organización. He intentando que haya bastantes temas cubiertos, pero soy consciente de que algunos han quedado cojos. Por otro lado, hay cuestiones que aparecen de forma repetida.
El libro no es, ni pretende ser, un curso o un manual definitivo. Tómalo como una recopilación de ideas acerca de cómo testear y, particularmente, sobre cómo practicar Test Driven Development en PHP.
A partir de algunas de las lagunas más significativas quizá me anime a desarrollar algún volumen más. Para empezar, uno sobre el testing de clases con dependencias, es decir con test doubles, y otro sobre Behavior Driven Development, también orientados a PHP. El tema de los test doubles es interesante porque genera un montón de dudas y afecta mucho a la calidad de los tests y, aunque en el libro se toca, quizá falta profundidad y entrar en detalle.
Como usar el libro
Pues como quieras. No hay un orden más o menos recomendable. He intentado agrupar los aspectos más teóricos y de definición de términos en los primeros capítulos.
En la Introducción: Del ojímetro al tdd encontrarás un resumen de prácticamente toda la teoría y terminología básica de testing.
Testing desde cero es un capítulo que extiende la introducción, siendo algo más detallado, en el que se explican todos los aspectos fundamentales del proceso de test.
Testing en contexto busca explicar los distintos niveles de testing y qué información nos proporcionan.
Psicología del testing es un capítulo en el que especulo sobre las dificultades técnicas y no técnicas de testear. Lo cierto es que ser consciente de estos problemas, me ha ayudado mucho a situar el testing en mi práctica diaria.
A partir de ahi, empiezan los capítulos más orientados a la práctica con ejemplos de código y pequeños proyectos, aunque con alguna cuestión más teórica intercalada:
Primer test explica cómo testear una clase desde cero. Es el capítulo que deberías leer si no has hecho nunca tests o has empezado, pero no lo tienes nada claro todavía.
Un ejercicio para aprender TDD es un ejercicio muy sencillo, pero potente que nos ayuda a introducir y desarrollar la metodología Test Driven Development.
Desarrollar un algoritmo paso a paso con TDD: Luhn Test kata la Luhn Test kata es un ejercicio de TDD muy bonito, algo más complejo que el del artículo anterior aunque basado en el mismo tipo de problema, y que te servirá para afianzar la metodología y descubrir cómo testear por partes algoritmos más complejos.
Clean testing discute algunos aspectos sobre cómo escribir tests que sean más expresivos y útiles.
Test doubles (1) y Test doubles (2) Principios de diseño estudian los distintos tipos de dobles de tests, sus aplicaciones y cómo se relacionan con los principios de diseño.
Test doubles(3) un proyecto desde cero es, como indica su título un proyecto de TDD usando doubles.
Resolver problemas con baby-steps es un capítulo que vuelve a incidir sobre la base de la metodología TDD.
Usar el code coverage para mejorar los tests busca reflexionar sobre la utilidad de la medida de la cobertura de código para mejorar nuestra batería de tests.
Testeando lo impredecible se trata de un ejercicio teórico y práctico sobre cómo testear situaciones no determinísticas, como el azar, en el que desarrollamos un generador de contraseñas. Además, es un buen ejercicio sobre el uso de los test doubles. Es un proyecto en el que también se puede ver cómo trabajar con dependencias y dobles.
TDD en PHP. Un ejemplo con colecciones (1 a 5) es un proyecto para desarrollar una clase Collection usando la metodología TDD y que sirve como resumen de todo, o casi todo, el contenido del libro.
Lo ideal sería que te animases a realizar los ejemplos por tu cuenta y en tu propio estilo a medida que lees el libro.
Finalmente, una serie de apéndices aportan información de carácter más práctico sobre algunos de los frameworks de testing y TDD disponibles para PHP, así como un dojo, un proyecto básico PHP/Symfony dockerizado que puedes usar para practicar tanto los ejemplos del libro como cualquier otro que se te ocurra.
Gracias
Posiblemente este proyecto nació, aunque entonces no lo sabía, en un meetup de PHPVigo en el que hice una charla introduciendo el tema del testing y que, de hecho, es más o menos el primer capítulo de introducción. Todo lo demás, el blog y el propio libro son, por así decir, extensiones de esa charla.
Así que gracias a la gente de PHPVigo, en particular Sergio Carracedo, Félix Gómez y Rolando Caldas, por haber confiado en un perfecto desconocido aquel día de hace ya un par de años.
Hablando de confiar en desconocidos, poco después de esa charla me vine a trabajar a Barcelona. Gracias a Ricard Clau por hacer que eso fuera posible. Y gracias especiales a Raúl Araya por un montón de cosas, que mejoraron mucho mi vida aquí.
Aprendí muchísimo en los coding dojo de la Software Crafters en donde, además, me sentí muy acogido. No os menciono en detalle porque seguro que me olvido de alguien y sería muy injusto.
Gracias a Javi y Rafa, de CodelyTV, por apoyar el libro como lo están haciendo.
Gracias a Alexey Pyltsyn, que está preparando la versión en ruso del libro.
Y, por supuesto, gracias a todo el equipo de HolaluzEng, por haberme hecho sentir en casa desde el primer día, por ayudarme a crecer profesionalmente y por estar ahí siempre que lo he necesitado.
Introducción: Del ojímetro al tdd
Porque todos tenemos spaghetti apestando en el armario y en algún momento hay que limpiarlo.
Cuando empiezas a programar en PHP (o en cualquier otro lenguaje, para el caso) sin tener una formación sistemática muchas veces te guías por ocurrencias: abres un editor y comienzas a picar código como si te fuera la vida en ello. Al final del día obtienes una gran bola de lodo que, más o menos, funciona y que a ti te parece la versión informática de la Gioconda. O algo así.
Claro que, al día siguiente, algo falla. Siempre falla. Vuelves a la gran bola de lodo, te pringas, y arreglas lo que fallaba.
El problema es cuando falla unos días después, y ya no tienes ni idea de qué iba dónde y cómo funcionaba la cosa, así que añades código hasta que la aplicación deja de gotear.
Y el ciclo se repite.
¿Y cómo sabes que la cosa funciona? Bueno… Pues viendo que subido a producción “No falla” (hasta que lo hace). Lo que viene siendo un “testing manual” o, con un lenguaje más técnico, testing a ojímetro.
Todos y todas tenemos código basura en alguna parte. Da igual los años de experiencia: las prisas, un análisis descuidado del problema, el enrocarnos en una solución y otros muchos motivos hacen que hagamos código que, visto en retrospectiva, nos parece una basura.
Y eso está bien. Lo importante es tener la capacidad de aprender a partir de ahí.
Ocurre lo mismo con el testing. Es posible que hayas pasado años escribiendo código que funciona en producción sin haber escrito un sólo test que lo cubra. Simplemente ocurre que ahora quieres tener más garantías, por ti, por tu equipo y por tu proyecto.
Entonces descubres los tests
Mi primer contacto con las suites de tests fue con SimpleTest, indirectamente a través de la suite de tests de CakePHP.
No puedo decir que fuese una epifanía. Al principio no entendía ni torta, con todo el rollo de las aserciones y los mocks. Al fin y al cabo, testear ActiveRecord no es precisamente ni lo más fácil para uno que empieza ni lo más recomendable, y en un framework MVC es casi inevitable. Incluso algo tan simple como la idea de ejecutar un trozo de código dentro de otro código (el test) resultaba extraña.
Sencillamente dicho: un test no es más que un programa simple que comprueba si el resultado de otro programa (o unidad de software, ya sea una función o un método de un objeto) devuelve el resultado que se espera tras pasarle unos parámetros.
Al principio haces post-tests: tienes un montón de código escrito y te has dado cuenta de que es necesario saber si cada unidad funciona como esperas en los casos que debería manejar.
Los post-tests no son perfectos pero son útiles y son el primer paso para poner un poco de orden en lo que escribes. Gracias a esos tests empiezas a manejar el refactoring, aunque no lo llames así todavía.
Michael Feathers, que de refactoring y legacy sabe un rato, llama a estos tests “Tests de caracterización”. Son los que se hacen para describir y/o descubrir el comportamiento actual de un módulo de software legacy y como primer paso para reescribirlo. Con este test tendríamos una red de seguridad para ir haciendo los cambios necesarios.
Pero en realidad, estoy siendo demasiado impreciso. Es necesario parar un momento y ser un poco más sistemático.
Control de calidad
El control de calidad del software engloba un montón de tipos de pruebas, que podemos agrupar en dos familias principales:
Pruebas funcionales: que tratan de medir lo que hace el software en base a unas especificaciones previas. Es decir: las pruebas funcionales nos sirven para asegurar que el software hace aquello que queremos que haga.
Pruebas no funcionales: que tratan de medir cómo lo hace a través de métricas más genéricas como el rendimiento, la capacidad de carga, la velocidad, la capacidad de recuperación, etc.
En realidad, usamos muy alegremente la palabra test, con sentidos más genéricos o más restringidos, lo que puede llevar a cierta confusión.
Muchas veces, cuando hablamos de tests, nos estamos refiriendo únicamente a un subconjunto de los variados tipos de pruebas funcionales y, precisamente, ese es el contenido principal de este libro.
Muchos programadores hemos empezado con la metodología de “Ojímetro testing(tm)”, es decir: observar el output del programa, con frecuencia recargando una página en un navegador.
El hecho de simplemente observar el output de nuestro programa no suele ser suficiente para constituir una prueba funcional válida.
Para empezar, no estamos definiendo de forma precisa, objetiva y reproducible (operacional) lo que queremos observar.
Lo que vemos al recargar una página es el resultado de un conjunto de operaciones, y sólo una de ellas es la pieza concreta de código de la cual queremos saber si funciona. Por lo tanto, no tenemos garantía de que el resultado se produce por las razones que pensamos, es decir, por efecto del algoritmo que hemos escrito, sino que podría haber efectos colaterales de diversos componentes del programa.
Digámoslo honestamente: no tenemos ni idea de lo que estamos midiendo.
Necesitamos introducir un poco de método científico en nuestro trabajo. Como he señalado antes, los tests no son más que programas sencillos que comparan el resultado generado por nuestra unidad de software con el resultado que esperamos obtener dadas unas condiciones iniciales. Los tests nos permiten definir con precisión las condiciones bajo las que ejecutamos la prueba, las acciones que se van a ejecutar y los resultados que deberíamos obtener, todo de una manera replicable.
Lo que viene a continuación es prácticamente un resumen del libro entero. En los siguientes capítulos verás las ideas más desarrolladas, así como ejemplos de código.
Tests funcionales
Existen varios tipos de tests funcionales y aquí me voy a centrar en algunos. Eso sí, no sin advertir que las fronteras, a veces son un poco difusas:
Tests unitarios son los que evalúan lo que hace una unidad de software en aislamiento, es decir, sin que otras unidades intervengan realmente. Una unidad de software es un término que normalmente designa una función, una clase o, más bien, un método de una clase.
En último término es frecuente tener dependencias de otras unidades de software, por lo que en situación de tests tenemos que usar “dobles” en su lugar, de modo que podamos mantener bajo control sus efectos.
Por poner un ejemplo un poco bruto: si tenemos una clase que utiliza un Logger
para comunicar el resultado de una operación y observamos que no se añade nada al log, tenemos que poder diferenciar si el problema es de la clase o del Logger
. El Logger
tiene diversos motivos para fallar que no tienen nada que ver con la clase que estamos probando, así que necesitamos un Logger
que no falle, preferiblemente uno que no haga nada de nada, neutralizando así sus efectos colaterales. Ese falso Logger es el que usamos en la situación de test.
Hablaremos de ello dentro de un rato.
De este modo, podemos afirmar que el resultado devuelto por la unidad es producido por el algoritmo que ésta contiene y no por otros agentes.
Tests de integración: evalúan lo que hacen las unidades de software en interacción. Es posible que nuestras unidades funcionen bien por separado, pero ¿qué ocurre si las juntamos? Los tests de integración dan respuesta a esa pregunta.
Volviendo al ejemplo de antes, si tanto la clase probada como el Logger
funcionan bien por separado, probamos a hacerlas funcionar juntas, para ver si aparecen fallos en su comunicación.
Tests de caracterización: ya los mencionamos antes. Los tests de caracterización son tests que se escriben para tratar de describir el comportamiento de una unidad de software ya escrita. En muchos casos llamar unidad de software a cierto código legacy puede ser un poco impreciso, lo correcto sería denominarlo “bola de lodo”, ya que seguramente se trata de código muy acoplado y desestructurado.
Tests de regresión: son tests que detectan los efectos negativos de los cambios que realizamos en el software. Es decir, si estos tests fallan es que hemos tocado algo que no debíamos.
Hasta cierto punto podríamos decir que todo test, una vez que ha pasado, se convierte en un test de regresión a partir del momento en que comenzamos a modificar un software.
Tests de aceptación: responden a la pregunta de si la funcionalidad está implementada en términos de los interesados en el software, también llamados stakeholders. Los tests de aceptación son, también, tests de integración.
Anatomía de un test
En general, los tests tienen una estructura muy simple. De hecho, los tests deberían ser siempre muy simples. Esta estructura tendría tres grandes partes:
Dado…
En esta parte definimos el escenario del test, preparando las condiciones en las que vamos a probar la funcionalidad.
Cuando…
Es la ejecución de la unidad de software en la que estamos interesados (o subject under test).
Entonces…
Es cuando comparamos el resultado obtenido con el esperado, habitualmente a través de Aserciones, es decir, condiciones que debería cumplir el resultado para ser aceptado. Por ejemplo, ser igual a, ser distinto, no estar vacío, etc…
Manejo de las dependencias
En POO es habitual que una clase utilice colaboradores para realizar un comportamiento, lo que nos plantea la pregunta de cómo manejar esta situación si los tests deberían probarse en aislamiento a fin de garantizar que evaluamos el comportamiento de esa unidad de software concreta.
Por otra parte, algunas de esas dependencias pueden ser bastante complicadas de obtener en una situación de desarrollo, como pueden ser el acceso a servicios web, servidores de correo, etc. Por no hablar, de la posibilidad de que haya fallos, de las distintas respuestas posibles, de la lentitud, del proceso de instanciación, de la configuración y un largo etcétera de dificultades.
Para eso, están los dobles o test doubles.
Se trata de objetos creados para reemplazar las dependencias en situación de test. Son éstos:
Dummies: son objetos que no hacen nada más que implementar una interfaz, pero sin añadir comportamiento. Los usamos porque necesitamos pasar la dependencia para poder instancias el objeto bajo test.
Por cierto, los test doubles, en general, ponen en valor el principio de Inversión de Dependencias. Si nuestro objeto bajo test depende de una interfaz, crear un Dummy es trivial. Si la dependencia es de una implementación completa la cosa puede complicarse, porque tendrías que extender la clase y neutralizar todo su comportamiento.
Así que suele ser mejor estrategia, en ese caso, extraer la interfaz y crear un Dummy. Y esto es bueno, porque te ayuda a reconsiderar tu diseño.
Stubs: los dummies son útiles, pero muchas veces necesitamos que la dependencia nos de una respuesta. Los Stubs son como los dummies en el sentido de que implementan una interfaz, pero también devuelven respuestas fijas y conocidas cuando los llamamos.
Por ejemplo, podemos tener una dependencia de una clase Mailer
que devuelve true cuando un mensaje ha sido enviado con éxito. Para testear nuestra clase consumidora de Mailer
, podemos tener un MailerStub que devuelve true
(o false
) sin tener que enviar ningún mensaje real, permitiéndonos hacer el test sin necesidad de configurar servidor de correo, ni importunar a media empresa con un correo de pruebas que se te escapa, ni tener conexión de red, si me apuras.
Así que podríamos decir que los Stubs tienen un poco de comportamiento superficial a medida para los fines del test.
Spies: los spies son test doubles capaces de recoger información sobre cómo son usados y con ellos empezamos a movernos en aguas un poco cenagosas. Por ejemplo, los spies podrían registrar el número de veces que los hemos llamado. De este modo, al final del test obtenemos esa información y la comparamos con el número de llamadas esperado.
El problema es que este tipo estrategias de test implican un acoplamiento del test a la implementación, lo que genera un test frágil. Y esto merece una explicación:
Los test funcionales deben ser de “caja negra”: conocemos las entradas (inputs) y las salidas (outputs) pero no deberíamos tener ni idea del proceso que se sigue dentro de ellas. Sí, ya lo sé, lo hemos escrito nosotros, pero se trata de actuar como si no tuviésemos acceso al código.
Por tanto, un test basado en cómo o cuándo se llama a un método en un colaborador está irremediablemente ligado a esa implementación. Si se cambia la implementación, por ejemplo porque se puede hacer lo mismo con menos llamadas u otras distintas, el test fallaría incluso aunque el output fuese correcto. Por eso decimos que es un test frágil: porque puede fallar por motivos por los que no debería hacerlo.
Nuestros siguientes dobles, tienen el mismo problema, pero agravado:
Mocks: por desgracia el término mock se usa mucho para referirse a todo tipo de test double, pero en realidad es un tipo particular y no demasiado conveniente. Un Mock es un Spy con expectativas, es decir, los Mocks esperan específicamente ser llamados de cierta manera, con ciertos parámetros o con cierta frecuencia, de modo que ellos mismos realizan la prueba o aserción.
De nuevo, tenemos el problema de acoplamiento a la implementación, pero agravado, ya que el Mock puede comprobar que lo has llamado con tal o cual argumento y no con otro, o que has llamado antes a tal método o a tal otro, etc. Si con un Spy el test es frágil, con Mock puede ser un “mírame y no me toques”.
Al final, el test reproduce prácticamente el algoritmo que estás probando por lo que no nos vale de mucho. En el momento en que hagas un cambio en la implementación, el test fallará.
El uso de spies y mocks en los tests puede revelar problemas de diseño. Por ejemplo, podrían indicar que una unidad de software tiene un exceso de dependencias o de responsabilidades. O tal vez, te está diciendo que deberías encapsular alguna funcionalidad que estás pidiendo a las dependencias.
Fake: El último miembro de la familia de los Tests Doubles es el Fake, un impostor. En realidad es una implementación de la dependencia creada a medida para la situación de test.
Es decir, tú quieres probar una unidad de software que tiene una dependencia complicada, como puede ser una base de datos o el sistema de archivos. Pues tú vas y creas una implementación de esa base de datos “pero más sencilla” para poder testar tu clase. Lo malo, es que el Fake en sí también necesita tests, porque no sólo es la implementación tonta de la interfaz (como un Dummy) o de un comportamiento básico (como un Stub), sino que es una implementación funcional concreta y completa, que hasta podría llegar a usarse en producción.
Los ejemplos clásicos son las implementaciones en memoria de repositorios, bases de datos, sistemas de archivos, etc.
Robert C. Martin explica toda la problemática de los test doubles en este artículo bastante socrático.
Cuándo toca hacer tests
A primera vista puede parece que el mejor momento de hacer tests es una vez que hayamos creado nuestras unidades de software y así comprobar que funcionan bien.
Pues no. Aunque siempre es mejor tener tests a posteriori que no tener ninguno, hay un momento aún mejor: escribir los tests antes de escribir el código.
Si lo piensas bien es muy lógico: antes de escribir el código tienes unas especificaciones, tienes un problema que resolver y unos datos para hacerlo. Los tests pueden entenderse como una manera formal de expresar esas especificaciones. Luego escribes el código con el objetivo de que los tests pasen.
Psicológicamente hablando, los tests creados después del código pueden resultar más difíciles de hacer por la sensación de “trabajo terminado” e incluso el miedo a que aparezca algún fallo inesperado. Por el contrario, los tests previos proporcionan una guía de trabajo y un refuerzo positivo al ir marcando nuestros éxitos a medida que vamos escribiendo el código.
Llevada al extremo, esa idea se denomina Test Driven Development o TDD.
Test Driven Development
El Test Driven Development es un modelo o disciplina de desarrollo que se basa en la creación de tests previamente a la escritura de código pero siguiendo un bucle de cambios mínimos y reescritura.
Me explico:
Al principio, no hay nada más que la especificación sobre lo que vas a construir. Tal vez decidas que lo primero que necesitas es una cierta clase.
Y lo primero que haces no es escribir la clase, sino un test para probar la clase. Es la primera ley de TDD, tal cual las recopila Robert C. Martin:
No escribirás código de producción sin antes escribir un test que falle.
Hemos dicho que TDD se basa en un bucle de “cambios mínimos”, así que el test mínimo será que exista la clase. Por lo tanto, te vas a tu IDE y creas un test que requiere la clase que vas a probar. Bueno. Si quieres, puedes instanciarla, que tampoco vamos a ser tan estrictos.
Ahora ejecutas el test.
– ¡Pero si va a fallar, alma de dios! Primero tendremos que definir la clase aunque esté vacía, ¿no?
Pues no. Primero escribes el test suficiente para fallar. El test tiene que fallar. Es la segunda ley de TDD:
No escribirás más de un test unitario suficiente para fallar (y no compilar es fallar)
Bueno, en PHP lo de no compilar como que no, pero podemos asumir que errores del tipo “class not found” equivalen a esto. En general, cualquier error nos vale.
La filosofía es la siguiente: cada test que falla te dice qué es lo que tienes que hacer a continuación. No tienes que poner nada nuevo hasta que no tengas un test que te pida hacerlo, ni siquiera el esqueleto de la nueva clase. Si la clase no se encuentra, es que tienes que crearla; si no tiene un método, es que tienes que crearlo; si el método no hace nada con el parámetro que recibe, es que tienes que escribir código que lo maneje…
Así que lo que haces ahora es crear un archivo para la nueva clase y escribes lo mínimo para que el test pueda cargarla e instanciarla. Y esa es la tercera ley:
No escribirás más código del necesario para hacer pasar el test.
Bien. Una vez has conseguido pasar este primer test hay que seguir un poco más. Tal vez tu clase va a tener un método público por el que vamos a empezar a trabajar y que debería devolver cierto valor.
Pues nuestro siguiente paso consiste en crear un test que simplemente intenta utilizar ese método. Y ejecutamos los tests.
Y al ver que falla, escribimos el método aunque no haga nada.
Y luego escribimos otro test que espera que el susodicho método devuelva cierto valor. Y como falla, vamos a la clase y hacemos que el método devuelva el valor esperado (sí, como has leído) para que pase el test.
– ¡Me estás tomando el pelo!
No. No te estoy tomando el pelo. Lo que ocurre es con cada pequeña iteración “test mínimo-código mínimo para que pase” estamos modelando nuestra clase. Cada nuevo test construye sobre lo existente y avanza un poco más hacia la implementación deseada.
Lo cierto es que si escribimos un nuevo test y vemos que no falla no nos dice nada útil, ya que no prueba nada que no hayamos probado ya. O bien nos indica que es el test el que falla.
Nuestro siguiente test tal vez tenga que probar la condición de que si instanciamos la clase con cierto parámetro el valor devuelto por el método será otro.
Así que tendremos que reescribir ese método para tener en cuenta esa circunstancia. Ahí empezarás a sentirte mejor porque vas a escribir comportamiento, pero no te aceleres: ve paso por paso. Aunque este ciclo parece un coñazo, en realidad cada iteración es muy rápida. Algunas herramientas, como PHPSpec, incluyen un generador de código que automatiza algunos de estos pasos.
TDD es una disciplina. No digo que sea fácil conseguirla, incluso al principio suena un poco descabellada. Si consigues convertirla en un hábito mental tienes una herramienta muy poderosa en tus manos.
A partir de cierto momento, comienzas a introducir el refactoring. Cuando el código alcanza cierta complejidad, comienzas a plantearte soluciones más generales y debes comenzar a ajustar la arquitectura. Creas métodos privados para resolver casos específicos, cláusulas de guarda, etc, etc. Los tests que ya has hecho te protegen para realizar esos cambios, aunque en algún momento puede que decidas refactorizar los test porque la solución ha avanzado hacia otro planteamiento diferente.
TDD favorece lo que se llama diseño emergente: al ir añadiendo tests, que evalúan que nuestro diseño cumpla ciertas especificaciones, van definiéndose diferentes aspectos del problema y nos impulsa hacia soluciones cada vez más generales.
La consecuencia de seguir la disciplina TDD es que tu código está automáticamente documentado y respaldado por tests en todo momento. No hay que esperar. Si un cambio introduce un problema, habrá un test que falle. El código estará listo para funcionar casi en el mismo momento de escribirlo. ¿Qué no te gusta la arquitectura y podrías hacerlo mejor? Los tests te protegen de los cambios porque cuando rompas algo lo sabrás de inmediato.
Las consecuencias en la calidad de tu código también se notarán. Para escribir tests de esta manera tienes que crear código desacoplado. Seguir los principios SOLID como guía en la toma de decisiones es lo que te va a permitir lograr justamente eso.
Es posible que, al principio, no te veas capaz de llegar a este nivel de trabajo y micro-progresión y tu práctica sea más desorganizada. Pero no te preocupes, avanza hacia ese objetivo. Un paso cada vez.
Qué incluir en los tests y qué no
Depende.
No hay una regla de oro, aunque puedes encontrar en Internet montones de reglas más o menos prácticas.
Yo más bien diría que hay que aprender a priorizar los tests necesarios en cada caso y momento particular.
Cosas más o menos claras
No se hacen tests de métodos o propiedades privadas. Los tests se hacen sobre la API pública de las clases.
La complejidad ciclomática de una unidad de software (la cantidad de cursos posibles que puede seguir el código) nos indicaría un mínimo de tests necesarios para cubrirla con garantía.
El dominio/negocio debería tener la máxima cobertura de tests que puedas, dado que constituye el core de tu aplicación.
Hay código trivial, como getters/setters, controllers si están bien realmente bien hechos, y otras partes del código que puedes no testear o diferir su testing, pero nunca se sabe, porque un getter podría llegar a dejar de ser trivial.
Haz tests cuando tengas que hacer algún cambio (test de caracterización) o corregir errores (test de regresión, que evidencie el error).
Testear librerías de terceros que ya están bien testeadas no merece la pena. Más bien nos interesa evaluar la interacción de nuestro código con ellas. Se supone que Doctrine va a devolver esos datos que le pides, el problema estaría en cómo se los pides o qué haces con ellos, y eso podría necesitar un test.
Code Coverage
El code coverage nos da una medida de las líneas de código que se han ejecutado en un test o batería de tests. Un Code Coverage de 100% nos dice que los tests han recorrido todos los caminos posibles de la unidad evaluada.
Sin embargo, no debe tomarse como objetivo. Muchas clases tienen métodos triviales cuyo testing tal vez nos convenga posponer. En un proyecto mediano es difícil llegar a ese 100%, lo cual no invalida que intentes conseguir el máximo posible, pero usando la cabeza.
El code coverage por sí mismo no dice mucho sobre la calidad de los tests. Éstos pueden cubrir el 100% del código y ser malos o frágiles.
El Code Coverage es una buena herramienta para decidir qué testear, ya que podemos identificar recorridos de la unidad que han quedado sin pruebas y que, por tanto, podrían mantener escondidos problemas.
Y esto es casi todo
Esta introducción ha quedado casi como un resumen del libro. No está mal. A partir de aquí, los diferentes capítulos te mostrarán una visión más detallada de cada aspecto del testing, sobre todo desde un punto de vista del test driven development.
Habrá capítulos más teóricos y otros más orientados a la práctica. Cosas con las que estarás de acuerdo y cosas con las que no. Podemos discutirlas cuando quieras en el blog o en twitter @talkingbit1.
Gracias por seguir leyendo.
Testing desde cero
En realidad, ya sabes testear software. Lo haces a todas horas. En último término, testear software es comprobar que un programa funciona como se desea que funcione y hace aquello que esperamos que haga. Y esto es algo que sucede cada vez que escribimos una pieza de código.
Ahora bien, esta forma de testeo manual o natural no resulta ni lo suficientemente sólida ni rigurosa como para permitirnos argumentar que nuestro código funciona correctamente. El mismo código trasladado a otro entorno podría funcionar mal, o generar errores porque no hemos tenido en cuenta controlar las diferencias entre nuestro espacio de desarrollo y el de producción.
Hace falta, por tanto, una metodología sistemática para verificar el funcionamiento de un programa. Esa metodología comprende una serie de conceptos, actividades y técnicas que agrupamos bajo la denominación de software testing.
Qué es software testing
Software testing define un conjunto de actividades y técnicas que se utilizan para comprobar o certificar que un software desarrollado cumple las especificaciones de funcionamiento establecidas. En otras palabras: testear un software es asegurarse de que hace lo que queremos o esperamos que haga.
Por supuesto, esta definición es bastante vaga y deberíamos matizar algunas cosas.
Cuando hablamos de especificaciones del software nos estamos refiriendo a cuál es el comportamiento que queremos que tenga o lo que necesitamos que haga. Podemos definir estas especificaciones de varias formas, más o menos precisas: esperando valores específicos, recurriendo a ejemplos, midiendo ciertos parámetros, analizando propiedades, etc.
Un aspecto del funcionamiento del software aparte de su utilidad y que nos puede interesar dentro del testing tiene que ver con su eficiencia, ya sea medida en velocidad, capacidad de carga, rendimiento, resistencia a fallos de servicios externos y otras métricas.
Además de que haga aquello que queremos, también esperamos que el software lleve a cabo su tarea sin introducir errores, por lo que también hay un aspecto del testing que tiene como objetivo la localización y prevención de estos fallos.
Es decir, el testing cubre toda una serie de aspectos del proceso para asegurarnos de que estamos desarrollando el software correcto.
El software testing debería ir más allá de la detección de errores y problemas, ocupándose de asegurar que el producto de software alcanza los niveles de calidad deseados.
Reckless, Claire: So, What Is Software Testing?
A mano o a máquina
Verificar el funcionamiento de un software es tan sencillo como ejecutarlo, observar el resultado que produce y compararlo con el que esperábamos obtener.
Podemos hacerlo de forma manual o automatizarlo.
Testing manual
El testeo manual consiste simplemente en preparar una serie de casos para probar y ejecutar a mano los elementos necesarios.
Sus limitaciones deberían ser evidentes:
- Examinamos un número limitado de posibles casos, pero puede haber decenas de ellos.
- No tenemos ni idea de qué otros factores podrían estar influyendo en el resultado, ni podemos tenerlos realmente bajo control.
- No podemos garantizar que el mismo test, realizado por otra persona y en otras condiciones, vaya a dar el mismo resultado.
La primera objeción es la más fácil de resolver: es necesario definir una serie de condiciones de partida y casos posibles que probar. Por ejemplo, datos válidos y datos no válidos, datos que disparen ciertas condiciones, etc.
La segunda y tercera objeciones van bastante relacionadas: tenemos que asegurarnos de que las condiciones en las que se realizan los tests sean las mismas siempre y estén controladas. Por ejemplo, algo tan obvio como que las bases de datos tengan los mismos datos.
El testeo manual es lento y propenso a errores, y en muchas ocasiones es inviable en la práctica.
Testing automático
El testeo manual tiene muchas limitaciones:
- El proceso es lento, por lo que obtenemos el feedback tarde, lo que puede retrasar la salida a producción o la corrección de errores.
- Los tests pueden ser difíciles de replicar y obtener los mismos resultados, ya que depende de cosas como las condiciones de la prueba o incluso la persona que lo lleve a cabo.
- Se cubren pocos casos, con lo cual es fácil que queden multitud de errores y problemas que no se detectan hasta que es demasiado tarde.
- La granularidad de los tests es baja, ya que suelen hacerse las pruebas de la feature como un todo, con lo cual en caso de fallo aún queda un largo camino hasta encontrar la causa y corregirla.
La respuesta es automatizarlo.
Los tests automáticos son, esencialmente, programas que escribimos para probar el software. Las ventajas son muchas:
- Al estar escritos en un lenguaje de programación, los tests constituyen una descripción formal de las especificaciones del software.
- Los tests se ejecutan rápidamente, por lo que devuelven feedback muy pronto.
- Podemos realizar gran cantidad de tests y cubrir muchos más casos, añadiendo nuevos tests si descubrimos nuevas casuísticas o en caso de errores.
- Los podemos repetir cuantas veces necesitemos.
- Los tests automáticos son más fácilmente replicables e independientes de la persona que los realice.
- Es posible testear las aplicaciones en distintos niveles de abstracción, lo que permite tener mucha granularidad y precisión para detectar qué falla y dónde se produce el fallo.
- Finalmente, es posible automatizar el proceso de lanzamiento de los tests para, por ejemplo, ejecutarlos con regularidad o antes de desplegar una nueva versión del software, etc.
Khan, Abdullah: Manual Vs Automation Testing: The Pros and Cons
Qué sometemos a test
Una aplicación o producto de software puede observarse y, por tanto, testearse a distintos niveles.
- Globalmente, podemos testear puntos de entrada a la aplicación desde “el mundo exterior” y controlar su respuesta. A estos los llamamos tests end to end. También se suelen conocer como tests de aceptación, ya que representan las demandas de los interesados en el software.
- Igualmente, podemos testear módulos o subsistemas, para ver cómo funcionan sus componentes en interacción. Es lo que llamamos tests de integración.
- Finalmente, podemos testear de forma aislada las unidades componentes del software, como clases y funciones, lo que conocemos como tests unitarios.
Para hacernos una idea más clara, vamos a aplicar lo anterior a la fabricación de un producto físico como puede ser un coche:
- Los tests de aceptación consistirían en probar el coche terminado en un circuito con diversos tipos de condiciones, verificando no sólo que funciona, sino también si alcanza las prestaciones para las que ha sido diseñado.
- Los tests de integración serían aquellos que realizamos al montar las piezas que corresponden a cada subsistema, como podría ser todo el sistema de dirección, comprobando que el movimiento del volante se corresponde con el de las ruedas, etc. Para probarlo no sólo no hace falta montar el coche entero, sino que es preferible montar el sistema aislado del resto y probarlo.
- Los tests unitarios serían las pruebas que hacemos a cada pieza individual, de forma aislada, asegurándonos que tienen las características y prestaciones requeridas.
Cada nivel de tests tiene unas características y requisitos particulares y persigue unos objetivos distintos.
Los tests end-to-end o de aceptación, buscan probar que el sistema hace lo que se ha pedido y, como objetivo secundario, detectar errores generales. Para ejecutarlos requieren un entorno que sea equivalente al de producción. Este tipo de tests debería cubrir los diferentes casos de uso del sistema. Por su naturaleza son costosos de ejecutar.
Los tests de integración buscan probar que los elementos que forman un módulo interactúan de forma correcta, manteniéndolos aislados del resto del sistema. Si asumimos que los elementos individuales funcionan correctamente, estos tests nos ayudan a diagnosticar los problemas de comunicación entre ellos.
Finalmente, los tests unitarios prueban en aislamiento las unidades de software que, como hemos dicho, son clases y funciones. De este modo, es posible localizar con precisión problemas en su funcionamiento. Estos tests se ejecutan rápidamente, por lo que nos devuelven feedback muy pronto y tienen especial valor cuando estamos desarrollando.
Todos estos tipos de tests se agrupan, junto con otros, en los denominados tests funcionales, los cuales tratan sobre qué hace la aplicación.
Por otro lado, existen tests que nos dicen cómo desempeña la aplicación su tarea. Son los tests no funcionales, que miden cuestiones como el rendimiento, la tolerancia a fallos, la velocidad de respuesta y otras métricas.
Qué es hacer un test
Hacer un test, como hemos dicho, es comprobar si una pieza de software hace aquello que esperamos que haga. En ese sentido, podemos clasificar las piezas de software en dos tipos básicos:
- Queries: son piezas de software que devuelven una respuesta al ser ejecutadas. Por lo tanto, podemos tomar esa respuesta y compararla con la respuesta esperada.
- Commands: son piezas de software que provocan un cambio en el sistema. En consecuencia, para probar que se ha producido el cambio deseado debemos examinar aquella parte del sistema que debería haber cambiado.
Un test, en resumen, no es más que un programa que ejecuta una pieza de software y comprueba si su resultado, en el caso de las queries, o su efecto, en el caso de los commands, es el que se espera.
Normalmente escribimos los tests con ayuda de un framework o librería especializada, que nos aporta herramientas con las que gestionar y escribir más fácilmente nuestros tests, así como para ejecutarlos y obtener información útil de cada uno de ellos y del conjunto.
Estructura básica de un test
Ahora que tenemos clara la idea de que un test es un pequeño programa que ejecuta una unidad o componente de software para comprobar si su resultado es el que esperamos, vamos a analizar sus elementos.
La estructura de un test se puede representar de esta manera:
- Preparar unas ciertas condiciones
- Ejecutar la unidad de software bajo test
- Observar el resultado obtenido y compararlo con el esperado
Fundamentalmente, los tests tienen tres partes principales:
- Given o Arrange: La primera parte consiste en poner el sistema en un estado conocido, lo cual supone ajustar todas las variables que pueden afectar al test en un valor arbitrario determinado. En esta fase se disponen datos en una base de datos, se preparan los parámetros que se pasarán a la unidad probada, etc.
- When o Act: La segunda es la ejecución de la unidad de software y la obtención del resultado.
- Then o Assert: La tercera fase consiste en comparar el resultado obtenido con el resultado esperado. Normalmente esta operación se realiza con asserts o matchers, según el entorno de tests con el que trabajemos. Asserts y Matchers son utilidades de los frameworks de test que nos permiten verificar que el resultado obtenido coincide con el deseado.
Por otro lado, los tests deben estar aislados entre sí, de modo que unos no dependan o se vean afectados por el resultado de los otros.
Maksimovic, Zoran: The anatomy of a Unit Test
La elección de los casos para testear
¿Qué casos y cuántos casos deberíamos testear? Cuando vamos a realizar un test se nos plantea el problema de la elección de los casos que vamos a probar. En algunos problemas, los casos serán limitados y sería posible y rentable probarlos todos. En otros, tenemos que probar diversos parámetros que varían de manera independiente en proporción geométrica. En otros problemas, el rango de casos posible es infinito.
Entonces, ¿cómo seleccionar los casos y asegurarnos de que cubrimos todos los necesarios?
Para ello podemos usar diferentes técnicas, más allá de nuestra intuición basada en la experiencia o en las especificaciones del dominio. Estas técnicas se agrupan en dos familias principales:
- Tests de caja negra (black box): se basan en considerar la pieza de software que vamos a testear como una caja negra de la que desconocemos su funcionamiento. Sólo sabemos con qué datos podemos alimentarla y la respuesta que podemos obtener.
- Tests de caja blanca (white box): en este caso tenemos acceso al código y, por tanto, podemos basarnos en su flujo para decidir los casos de tests.
Tests de caja negra
Valores únicos
Si el número de casos es manejable podemos probar todos esos casos, más uno extra que represente los casos no válidos.
Supongamos un sistema de códigos de promoción que tiene los siguientes tres códigos. Queremos una función que devuelva el valor del código de promoción.
Código | Valor |
---|---|
COOL | 10 |
SUPER | 30 |
GREAT | 20 |
¿Qué valores debemos probar? Pues los tres valores válidos o posibles del código y un valor que no sea válido. Además, en este caso, podríamos probar que no haya valor.
Código | Valor |
---|---|
COOL | 10 |
SUPER | 30 |
GREAT | 20 |
BUUUU | 0 |
0 |
Cuando el número de casos crece, podemos recurrir a diversas técnicas:
Equivalence Class Partitioning (Partición en clases de equivalencia)
Supongamos que tenemos una función que puede aceptar, potencialmente, infinitos valores. Por supuesto, es imposible probarlos todos.
Equivalence Class Partitioning es una estrategia de selección de casos que se basa en el hecho de que esos infinitos valores pueden agruparse según algún criterio. Todos los casos en un mismo grupo o clase son equivalentes entre sí a los efectos del test, de modo que nos basta escoger uno de cada clase para probar.
Algunos de estos criterios pueden venir definidos por las especificaciones o reglas de negocio. Veamos un ejemplo:
Supongamos una tienda online que haga descuentos en función de la cantidad de unidades adquirida. Para 10 ó más unidades, el descuento es del 10%; para 50 ó más unidades, el descuento es del 15%, y para 100 ó más unidades el descuento es del 20%.
Una función para calcular el descuento tendría que tomar valores de números de unidades y devolver un porcentaje. Esto se puede representar así gráficamente:
En el gráfico se puede ver fácilmente que todos los valores que sean menores que 10 no tendrán descuento (0%), los valores desde 10 a 49 tendrán un descuento del 10%, los valores del 50 al 99, del 15% y los valores del 100 en adelante, del 20%. Nos basta escoger un valor cualquier de esos intervalos para hacer el test:
Intervalo | Valor a probar | Resultado |
---|---|---|
0-9 | 5 | 0% |
10-49 | 20 | 10% |
50-99 | 75 | 15% |
100+ | 120 | 20% |
Boundary Value Analysis (Análisis de valor de límite)
Aunque la metodología anterior es perfectamente válida se nos plantea una duda: ¿cómo podemos tener la seguridad de que se devuelve el resultado correcto en los valores límite de los intervalos?
Usando Equivalence Class Partitioning seleccionamos un valor cualquiera dentro de cada intervalo. En Boundary Value Analysis vamos a escoger dos valores, correspondientes a los extremos de cada intervalo, excepto en los intervalos que no están limitados en uno de los lados:
Intervalo | Valor a probar | Resultado |
---|---|---|
0-9 | 9 | 0% |
10-49 | 10 | 10% |
10-49 | 49 | 10% |
50-99 | 50 | 15% |
50-99 | 99 | 15% |
100+ | 120 | 20% |
Los dos valores escogidos para la prueba son válidos dentro de la definición de Equivalence Class Partitioning, con la particularidad de que al ser los extremos de los intervalos nos permiten chequear condiciones del tipo “igual o mayor”.
Decision table (tabla de decisión)
En las estrategias anteriores partíamos de la base de trabajar con un único parámetro. Cuando son varios parámetros los casos para probar se generan combinando los posibles valores de cada uno de ellos en una tabla de decisiones.
Imaginemos una tienda online de impresión de camisetas en la que el precio depende del modelo de camiseta (Masculino o Femenino), la talla (Pequeña, Mediana y Grande) y el tamaño de la ilustración (Pequeña o Grande). En ese caso, el número de posibles casos es 2 * 3 * 2 = 12, suponiendo que no habrá casos inválidos.
Esta casuística se representa en una tabla, más o menos como ésta:
Casos -> | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
Condiciones | ||||||||||||
Modelo | M | M | M | M | M | M | W | W | W | W | W | W |
Talla | S | S | M | M | L | L | S | S | M | M | L | L |
Tamaño | B | S | B | S | B | S | B | S | B | S | B | S |
Acciones(precios) | 25 | 20 | 35 | 30 | 45 | 40 | 25 | 20 | 35 | 30 | 45 | 40 |
Esta tabla nos permite generar todas las combinaciones de tal modo que cada columna representa un caso que deberíamos probar.
Tests de caja blanca
Basic path
Basic path es un tipo de diseño de tests que presupone el conocimiento del algoritmo que estamos testeando, de tal modo que diseñamos los casos de test en función de los caminos que sigue el flujo de ejecución del código. Por lo tanto, la cantidad de casos estará directamente relacionada con la complejidad ciclomática del mismo.
Por ejemplo, un código en el que no haya ninguna decisión, necesitaría un único caso de test. Si hay una decisión que crea dos caminos de ejecución, se necesitarán dos casos.
Normalmente, lo mejor es representar el diagrama de flujo del código para identificar fácilmente los diversos recorridos, identificando qué valores forzarán el paso por uno u otro.
Code coverage o Line Coverage
El índice de Code Coverage indica qué líneas de un código han sido ejecutadas o no por los tests. Normalmente se indica en porcentaje de líneas ejecutadas sobre líneas totales. Obviamente esta medida no nos dice nada acerca de la funcionalidad del código (si lo que hace es correcto o no) pero sí nos ayuda a detectar casos no testeados porque ciertas partes del código no se han llegado a ejecutar con las pruebas que tenemos.
Branch coverage
Aunque está estrechamente relacionado con el anterior, el branch coverage es un poco diferente. Su función es indicarnos si los posibles cursos de acción (o branches) de un código se han ejecutado.
Por ejemplo, una cláusula if…then
que evalúa una condición tiene dos ramas, por tanto, ambas ramas deberían haberse ejecutado al menos una vez para asegurarnos de que han sido correctamente cubiertas. Si se evalúan dos condiciones, tendremos cuatro posibles combinaciones lógicas.
Frameworks para testing
Un framework para testing es un paquete de software que nos permite escribir tests de una manera sencilla, así como ejecutarlos y extraer información de interés.
Si no usamos un framework, podríamos escribir un test más o menos así, en pseudocódigo:
Usando un framework el test podría ser así:
Aunque el código es muy similar, usar un framework de test aporta varias ventajas:
- Ofrece un conjunto de aserciones que nos permiten verificar de forma expresiva diversos tipos de resultados de nuestras pruebas.
- Recopila información sobre la ejecución de los tests, mostrando estadísticas, tiempo de ejecución, etc.
- Facilita localizar los test que fallan.
Las aserciones (asserts o matchers) son funciones provistas por el framework de testing que encapsulan la comparación del resultado de la pieza de software probada con el criterio, junto con otras operaciones que permiten al sistema de testeo recopilar esa información. En último término una aserción no es otra cosa que una función que verifica que se cumple una condición. La variedad de aserciones nos permite que el código de nuestro tests sea más expresivo y conciso.
Existen varios tipos o familias de frameworks, según su orientación:
- xUnit: es el tipo más genérico. Los tests se estructura en TestCases y utiliza aserciones para verificar los resultados (JUnit, PHPUnit, etc).
- xSpec: los test de tipo Spec se usan en metodologías TDD o Behavior Driven Development y ponen el énfasis en la descripción del comportamiento esperado de las unidades bajo test (RSpec, phpspec, JSpec).
- xBehave: son frameworks para Behavior Driven Development, se orientan a la realización de tests de aceptación (JBehave, Cucumber, Behat).
Cuándo testear
Después de escribir el código
Testear después de desarrollar el código es una de las formas más habituales de trabajar, especialmente en la orientación que podríamos denominar de Quality Assurance (QA). La idea es probar que el código cumple las especificaciones y detectar posibles fallos o casos no cubiertos.
En resumen, consiste en escribir los tests una vez que hemos terminado de desarrollar el código de tal forma que podamos probar que cumple con las especificaciones y que no tiene fallos.
Esto presenta dos problemas principales:
El primero tiene que ver con la dificultad psicológica de poner a prueba nuestro código una vez lo consideramos terminado, ya que el test implica un trabajo extra que no siempre es fácil de realizar.
El segundo tiene que ver con la dificultad técnica de escribir buenos tests para un código que puede estar en un estado difícil de testear. Esto ocurre cuando en el desarrollo no se ha realizado una buena gestión de la dependencias y tenemos alto acoplamiento entre clases.
Antes de escribir el código
La idea de tener los tests antes que el código (test first development) es históricamente bastante antigua. Consiste en que los tests se escriben o definen antes de iniciar el desarrollo, de modo que su objetivo es conseguir que los tests se cumplan.
Obviamente al principio no se cumplirá ningún test puesto que no hay código que ejecutar. A la vez, esto es una guía que nos va indicando qué pasos debemos ir realizando.
Estrechamente ligada con esta idea está la metodología Test Driven Development (TDD). Las principales diferencias entre Test First Development y Test Driven Development son que, en TDD:
- Los tests se escriben uno a uno (no todos de una vez).
- Se escriben implementaciones lo más sencillas posible de código que consigan hacer pasar el test.
- Una vez que ha pasado el test, se revisa el código para eliminar duplicación y mejorar el diseño, asegurándonos de que el test se mantiene pasando.
tcagley: Test First and Test Driven Development: Is There a Difference?
Para describir un bug
Cuando detectamos un bug o un error en el código desplegado o en la fase de QA es buena idea escribir un test que, al fallar, ponga de manifiesto el problema observado.
A continuación, revisaremos el código para corregir el error y así hacer que el test que hemos escrito pase, manteniendo los otros tests pasando también. De este modo, nos aseguramos tanto de corregir el problema como de mantener el resto del sistema funcionando correctamente.
Para refactorizar un código
Cuando arrastramos deuda técnica, es decir código antiguo que es difícil de comprender y por tanto de mantener, es tentador tratar de reescribirlo para mejorar su inteligibilidad. Para esos casos es útil introducir los llamados tests de caracterización.
Se trata de tests que creamos a partir del funcionamiento actual de la pieza de software que estamos estudiando. Usando su resultado actual como criterio de comparación del test. La idea es no alterar ese resultado con los cambios que hagamos en el código.
Testing en contexto
Taxonomía de tests
Los tests de software se pueden agrupar en dos grandes categorías globales:
- Tests funcionales
- Tests no funcionales
Tests funcionales
Los tests funcionales se refieren a los tests que prueban que el software hace aquello para lo que ha sido diseñado. Esto es, por ejemplo, una aplicación para gestionar la venta de productos de segunda mano permite a sus usuarios comprar y vender artículos de segunda mano y todo lo que eso conlleva, como dar de alta productos con sus precios, descripciones y fotos, contactar con vendedores y compradores, gestionar pagos, etc.
Esto se puede probar a varios niveles, a saber:
- Tests unitarios: probando las unidades básicas en que está organizado el software funcionando de manera aislada.
- Tests de integración: probando conjuntos de unidades básicas que están relacionadas entre sí, para comprobar que sus relaciones funcionan correctamente.
- Tests de aceptación: probando los puntos de entrada y salida del software para comprobar que su comportamiento es el definido por los stakeholders (quienes están interesados en su uso)
Estos tres niveles componen la llamada “pirámide de tests” que comentaremos posteriormente.
Además de los anteriores, entre los tests funcionales también consideramos:
- Tests de regresión: son tests que pueden detectar las consecuencias de los cambios que hayamos podido realizar en el software que nos llevan a un comportamiento no deseado. Se puede decir que todos los tipos de tests son tests de regresión en tanto que una vez que hayan pasado correctamente fallarán si se realizan cambios en el software que alteren el comportamiento esperado.
- Tests de caracterización: son tests que se escriben cuando el software no tiene otros tests y normalmente se crean ejecutando el software bajo unas condiciones determinadas y observando el resultado que arroja. Ese test se utiliza como red de seguridad para realizar cambios y también mejores tests.
Test Driven Development puede practicarse también en los tres niveles. La idea es que se definen primero los tests y se escribe el código para que los tests pasen. Una vez que esto se logra, tras eliminar posibles redundancias, los tests escritos se convierten automáticamente en tests de regresión.
Tests no funcionales
Los tests no funcionales se refieren a cómo trabaja el software. Al margen de que aporte una funcionalidad, es necesario que el software ofrezca una fiabilidad, capacidad de respuesta, etc, algo que es transversal a todo tipo de aplicaciones y que se puede medir de diversas maneras. Estos tests prueban cosas como, entre otras:
- Velocidad: Nos dice si el software devuelve resultados en el tiempo deseado.
- Carga: Nos dice si puede soportar un volumen de trabajo determinado, lo cual puede tener diversas medidas: conexiones simultáneas, volumen de datos que puede procesar de una sola vez, etc.
- Recuperación: Nos dice si un sistema es capaz de recuperarse correctamente en caso de fallo.
- Tolerancia a fallos: Nos dice si el sistema reacciona correctamente si se producen fallos en otros sistemas de los que depende.
En algunos casos, los tests de aceptación nos pueden ayudar a controlar a grosso modo algunos aspectos que corresponden a tests no funcionales. Por ejemplo, cómo reacciona nuestro sistema si no puede acceder a información de otros sistemas.
Los tests no funcionales buscan controlar que el sistema se comporta dentro de ciertos parámetros y que reacciona correctamente a ciertas contingencias que se encuentran fuera de su alcance.
Esencialmente los tests no funcionales siguen el mismo esquema que los tests funcionales:
- Se define un escenario o estado inicial del sistema (Given).
- Se ejecuta una acción sobre el sistema (When).
- Se observa la respuesta del sistema para ver si coincide con la esperada (Then).
Por ejemplo, podríamos decidir que una página web debe esta lista en menos de un segundo con una velocidad de red determinada para que un usuario pueda interactuar con él (o bien un tabla de velocidades de red típicas y tiempos de respuesta). Por tanto:
- Ponemos el sistema en un estado conocido asumiendo ciertas condiciones.
- Medimos el tiempo que tarda en estar listo para aceptar una entrada.
- Comprobamos si ese tiempo es menor que el deseado.
La pirámide de tests funcionales
La pirámide de tests funcionales es un heurístico para decidir la cantidad de tests de cada nivel que realizamos.
La idea es algo más o menos así:
- Test unitarios: están en la base de la pirámide y deberíamos tener muchos
- Test de integración: están en la parte media de la pirámide y deberíamos tener menos
- Test de aceptación: están en lo alto de la pirámide y deberían ser pocos
O dicho de otra forma:
Dada una feature de nuestro software tendríamos:
- Unos pocos tests de aceptación que cubran los escenarios definidos por los stakeholders.
- Un número mayor de tests de integración que aseguren que los componentes del sistema funcionan correctamente en interacción y que saben reaccionar a los fallos de los demás.
- Un gran número de tests unitarios que nos aseguren que las unidades de software hacen lo que se espera de ellas y son capaces de manejar las distintas condiciones de entrada, así como reaccionar correctamente en caso de entradas no válidas.
Pero, ¿por qué?
Muchos tests unitarios
Los tests unitarios, al centrarse en una sola unidad de software en aislamiento, deberían ser:
- Fáciles de escribir: una unidad de software debería tener que manejar relativamente pocas casuísticas y reaccionar ante problemas de una forma sencilla, normalmente lanzando excepciones.
- Rápidos: en caso de haber dependencias estas estarían dobladas por objetos más sencillos y menos costosos, permitiendo que la ejecución de los tests sea muy rápida.
- Replicables: podemos repetir los tests cuantas veces sea necesario, obteniendo los mismos resultados si el comportamiento de la unidad de software no ha sido alterado.
Estas condiciones nos permiten hacer cosas como:
- Ejecutar los tests cada vez que hagamos un cambio en el software, lo que nos proporciona feedback inmediato en caso de cualquier alteración del comportamiento.
- Puesto que los tests prueban una unidad concreta de software en un escenario específico, si fallan obtenemos un diagnóstico inmediato del problema y dónde se ha producido.
Por estas razones, lo lógico es tener muchos tests unitarios que puedan ejecutarse rápidamente muchas veces al día: cuando realizamos un cambio, en el momento de enviar commits, en el momento de desplegar, etc.
¿Más es mejor? Como en tantos aspectos de la vida, más no es necesariamente mejor, pero nos interesa que entre todos los tests de una unidad de software se cubran todas las casuísticas relevantes, de modo que cuando uno de ellos falla podamos saber qué cambio concreto hemos realizado que ha provocado el problema.
En los tests unitarios, los comportamientos de otras unidades de software que pudiesen intervenir se simulan mediante test doubles, los cuales se limitan a devolver respuestas prefijadas de modo que el output de la unidad de software sólo pueda ser atribuido a su propio comportamiento, manteniendo controlado el de los colaboradores.
Un número medio de tests de integración
Los tests de integración ejercitan varias unidades de software que están interrelacionadas para un proceso dado, pero de forma aislada al resto de la aplicación.
En este caso no se simula ningún comportamiento puesto que nuestro objetivo es ver si las unidades de software interactúan de la forma prevista. Es posible que sí tengamos que simular el comportamiento de sistemas externos al nuestro (por ejemplo, una API que nos proporciona ciertos datos, etc.). Obviamente utilizamos datos específicamente creados para la situación de test.
La casuística aumenta en proporción geométrica al número de unidades implicadas pues es el producto del número de casos que tiene que manejar cada unidad.
El problema es que además de aumentar el número de casos, los tests de integración son más lentos que los unitarios, por lo que debemos tomar un enfoque diferente.
El test de integración no tiene que verificar que cada una de las unidades realiza bien su trabajo, eso es algo que ya habrá probado nuestro test unitario, sino que probará el comportamiento del sistema de unidades, particularmente los casos en que alguna de las unidades falla por un motivo u otro, para asegurarnos de que las demás reaccionan de una forma manejable.
Un número pequeño de tests de aceptación
Los tests de aceptación prueban el sistema desde el punto de visto de sus usuarios o stakeholders, por tanto, ejercitan todos los componentes del sistema implicados en una acción concreta. En los tests de aceptación no se simulan comportamientos de nuestro sistema, aunque es posible que tengamos que simular otras sistemas externos o condiciones de ejecución.
Nuestras pruebas de aceptación se ejecutan en un entorno específico de tests que sería idéntico al de producción.
Por lo general, en los tests de aceptación nos interesa probar ciertos escenarios que son significativos para los stakeholders. Por ejemplo:
- Un usuario que quiere contratar un servicio y aporta los datos necesarios debe recibir una confirmación de que ha contratado el servicio.
- Un usuario que quiere contratar un servicio y no aporta los datos necesarios (o son incorrectos) debe recibir una información de qué datos debería corregir y que no se ha contratado el servicio.
- Un usuario que quiere contratar un servicio debe recibir una información adecuada en caso de que haya algún fallo del sistema que impida completar el proceso.
Muchos tests de aceptación pueden realizarse con este simple modelo:
- Input correcto del usuario + sistema correcto -> output correcto del sistema
- Input incorrecto del usuario + sistema correcto -> output informativo del sistema
- Input correcto del usuario + sistema incorrecto -> output informativo del sistema
Obviamente muchos procesos tienen una diversidad de escenarios para considerar que aumentan el número de tests necesarios. Sin embargo, su número será menor que el de todas las combinaciones de casos de los tests unitarios que ejercitan las unidades de software implicadas.
Los tests de aceptación se pueden escribir con lenguaje Gherkin, que es una forma estructurada de definir features mediante escenarios usando lenguaje natural. De este modo, los stakeholders o los product owners pueden contribuir a definirlos conjuntamente con los desarrolladores. Posteriormente se traducen a un lenguaje de programación usando herramientas como Cucumber o Behat.
Utilidad de la pirámide de tests
La primera utilidad de la pirámide de tests es ayudarnos a definir cuántos tests necesitamos en cada uno de los niveles. De todos modos es sólo un heurístico ya que la proporción entre los tres niveles también es significativa y no es fácil definir una correcta.
La pirámide de tests nos proporciona tres niveles de resolución a la hora de analizar el comportamiento de nuestra aplicación.
- El nivel de aceptación nos permite observar los procesos de la aplicación como una unidad.
- El nivel de integración nos permite observar los procesos en los sub-sistemas que están implicados y los fallos en estos tests nos indican, sobre todo, fallos de comunicación entre unidades.
- El nivel unitario nos permite observar las unidades de software y los fallos en este nivel nos permiten diagnosticar nuestros algoritmos.
Idealmente, con una buena proporción de tests nos encontraríamos que un test que no pasa en el nivel de aceptación se reflejaría en tests que no pasan en alguno de los otros niveles:
- Si fallan tests en el nivel de integración (pero no en unitarios) nos estaría indicando que algunas unidades de software no se comunican bien entre sí, por ejemplo, porque una está entregando datos en formatos inadecuados.
- Si fallan tests en el nivel unitario (seguramente también estará fallando el nivel de integración) indica que alguna unidad de software está funcionando mal.
A veces es más informativa la ausencia de fallos:
- Los fallos en los tests de aceptación que no tienen reflejo en fallos en los tests de integración o unitarios nos dicen que nos hemos dejado casos de test en el tintero. Los tests de aceptación que fallen deberían llevarnos a crear nuevos tests en los demás niveles.
- Si los tests unitarios fallan y no fallan los tests de nivel superior, nos está diciendo que nos faltan tests en esos niveles. Un test unitario que falla debería reflejarse en un test de integración y un test de aceptación fallando igualmente.
La pirámide, por otra parte, nos ayuda a controlar que la ejecución de los tests se mantenga en un nivel que la haga práctico:
- Los tests de aceptación son muy lentos. Si tenemos relativamente pocos (siempre que sean suficientes, claro) lograremos que se ejecuten en el menor tiempo posible y podríamos lanzarlos automáticamente antes de cada deploy.
- Los tests de integración son medianamente rápidos, si los mantenemos en un nivel adecuado podríamos ejecutarlos automáticamente en cada pull request.
- Los tests unitarios son muy rápidos, por lo que podríamos ejecutarlos en cada commit.
Para que esta estructura sea realmente eficaz, tendríamos que asegurarnos de que un fallo en un nivel se refleja en los otros dos.
Obviamente podemos optimizar la ejecución de tests mediante herramientas que nos ayuden a ejecutar sólo aquellos afectados por los últimos cambios.
Smells en la pirámide de los tests funcionales
Observando la proporción entre los tests en los tres niveles podemos diagnosticar si nuestra batería de tests está bien proporcionada. En caso de que no sea así, el objetivo debería ser incrementar la cantidad de tests en los niveles que lo necesitan así como revisar aquellos niveles que podrían tener tests redundantes.
En general, un excesivo número de tests del nivel de aceptación con respecto al unitario nos permite detectar fallos, pero no diagnosticarlos con precisión.
Por otra parte, demasiados tests unitarios con pocos tests de aceptación probablemente pase por alto muchos errores que se revelarán en producción.
Pirámide invertida
La pirámide invertida indica que hay pocos tests unitarios y muchos tests de aceptación.
Muchos tests de aceptación podrían indicar que se prueban escenarios innecesarios o que se intenta comprobar el funcionamiento de unidades concretas del sistema desde fuera intentando suplir con tests de aceptación la necesidad de tests unitarios.
Pocos tests unitarios hacen difícil o imposible determinar con facilidad dónde están los problemas cuando los tests de nivel superior fallan.
Pirámide aplastada
La pirámide aplastada indicaría que hay demasiados pocos tests de aceptación respecto a los tests unitarios. Si suponemos una situación en que la cobertura de tests unitarios es adecuada, lo que nos está diciendo este smell es que tenemos que realizar más pruebas de integración y de aceptación.
En esta situación los tests no nos dicen mucho acerca de cómo se comporta la aplicación como un todo y probablemente estamos confiando demasiado en tests manuales. En consecuencia no podremos identificar casos problemáticos que estarán relacionados con mala comunicación entre las diversas unidades de software.
Forma de diábolo
Nos indicaría que tenemos pocos tests de integración y que son los tests de aceptación los que están haciendo su trabajo. Tendríamos que analizar los tests de aceptación y mover pruebas al nivel de integración.
En caso de fallo en el nivel de aceptación nos encontraríamos que si los tests unitarios no fallan no podemos saber qué interacción de nuestras unidades está funcionando mal.
Forma de rombo
La forma de rombo nos indica que los tests de integración están haciendo el trabajo de los tests unitarios. Un fallo en este nivel no nos aclara si es debido a un problema de integración o a un problema en una unidad concreta de software.
La solución es crear más tests unitarios que nos ayuden a discriminar mejor.
Forma de cuadrado
Si en lugar de una pirámide tenemos una forma parecida a un cuadrado es que tenemos un número similar de tests en cada nivel. Esto indica que o bien tenemos pocos tests unitarios o bien tenemos demasiados tests de integración y aceptación que, probablemente, estén haciendo el trabajo de niveles inferiores.
Psicología del testing
Mi primer contacto con los tests, con el propio concepto de test para ser precisos, fue de todo menos una epifanía.
Al principio conseguí desarrollar una noción bastante vaga de la idea y necesidad de testear software, la cual, afortunadamente, fui elaborando y perfeccionando con el tiempo. Aún hoy sigo trabajando en refinarla.
Asimismo me costó entrar en la técnica del testing. En aquel momento había pocas referencias en el mundo PHP y tampoco es que hubiese mucho interés en hacer pedagogía sobre cómo escribir tests, no digamos ya buenos tests. Toda mi documentación era la que proveía SimpleTest, un framework de la familia JUnit del que no sé si se acordará alguien todavía.
Ni te cuento el shock mental que supuso encontrarme con las metodologías test-first y test driven development. Por entonces, no me cabía en la cabeza la idea de no tener que preparar un montón de cosas antes de plantearme siquiera poder empezar a escribir el test más simple. En aquella época, un ‘Class Not Found’ era un error, no una indicación de mi siguiente tarea.
Hoy por hoy, después de varios años, ha llegado un punto en el que me cuesta escribir software sin empezar por los tests. Con ellos defino mis objetivos al escribir código o tiendo redes de seguridad para realizar modificaciones y rediseños. Como programador, mi vida es ciertamente mejor con tests.
¿Por qué nos cuesta el testing?
Técnicamente hablando, hacer tests es algo bastante simple. Un test no es otra cosa que un pequeño programa que ejecuta una unidad de software y nos dice si el valor devuelto coincide o no con uno predeterminado, o bien, si produce un efecto que esperamos.
En último término, un test es esto:
Claro que para trabajar profesionalmente necesitamos herramientas y frameworks algo más potentes, y tenemos el problema de definir qué es una unidad de software, así como delimitar lo que entendemos como resultado esperado.
Pero no vamos a tratar aquí de esas cuestiones.
Nuestro objetivo es llamar la atención sobre una serie de aspectos que podríamos definir como psicológicos y que contribuyen a explicar por qué la práctica del testing no está tan extendida como cabría esperar.
El concepto de test y la necesidad de testear
¿Cómo sabemos que un software funciona? Pues sencillamente viéndolo funcionar. Un primer problema en el acercamiento al testing es que vemos que el software que acabamos de escribir funciona. De hecho, hablamos muchas veces de testeo manual en se mismo sentido: observamos si el código que hemos escrito se comporta como deseábamos y, si es así, lo consideramos terminado. En caso contrario, intentamos comprender por qué ha fallado y procuramos corregirlo, comenzando el ciclo de nuevo.
Esta es una de las primeras barreras: si vemos que el código funciona: ¿por qué debería dedicar tiempo y esfuerzo a crear un test para decir lo mismo?
He aquí algunas razones:
- Un test es una definición formal de lo que entendemos por funcionamiento correcto del software en la que las distintas personas interesadas podremos estar de acuerdo.
- Es replicable: el test dará el mismo resultado ejecutado en diversos entornos, no depende de si estamos prestando atención o si recordamos cuál era el resultado que tenía que dar.
- Es repetible: podemos repetir el test cuantas veces queramos, de modo que podemos hacer cambios en el código y asegurarnos de que sigue dando el mismo resultado.
- Es automatizable: podemos programar la ejecución del test en cualquier momento que necesitemos, junto con todos los demás test que tengamos.
Es decir, la afirmación que yo pueda hacer sobre el funcionamiento de mi código no tiene el mismo peso cuando no está corroborada por tests que cuando sí lo está. El test es una medida del funcionamiento adecuado del software.
Otra cuestión sería la discusión de si estamos midiendo de la forma correcta con un test dado. Pero precisamente, el hecho de que exista un test nos permite evaluar si ese test mide lo que queremos que mida.
Sentimos apego por nuestro código
Tendemos a sentir apego por nuestro código. Puede ser feo, pero es el nuestro. En realidad nunca lo vemos feo, nos parece un unicornio blanco y hermoso.
Para decirlo de forma más técnica y menos dramática: todos tenemos un cierto prejuicio a favor de nuestro propio código, así que puede costarnos esfuerzo ponerlo a prueba. No por las dificultades técnicas que supone, de lo que trataremos más adelante, sino por la disonancia que nos genera ser críticos con nuestra propia obra.
En particular, señalaría estos factores que influyen en la dificultad psicológica de poner a prueba el código creado por nosotros:
- Tarea terminada: cuando conseguimos que un código funcione tenemos la sensación de haber cumplido con la tarea, por lo que la fase de testing se convierte en un extra difícil de asumir. Nuestra mente se ha puesto en modo de “buscar la siguiente tarea”.
- La solución única: la mayoría de nosotros hemos pasado por un sistema educativo que ha inculcado en nuestros cerebros la idea de que sólo hay una solución correcta para los problemas. Una cultura maniquea, en la que las cosas o están bien o están mal y en la que la evaluación se percibe como un peligro más que como una forma de diagnostico (a ver si va a fallar el test en algún caso raro).
Pero, ¿cómo librarse de esta visión subjetiva del propio código? Probablemente la mejor forma sea utilizando un enfoque test first, como TDD y BDD.
Al tener los tests antes que el código conseguimos:
- Que sean los tests los que nos digan cuál es nuestro siguiente paso: la tarea no termina hasta que no pasan todos los tests.
- Al no tener código previo no tenemos un prejuicio hacia un diseño u otro, sino que lo vamos definiendo en consonancia con lo que los tests nos piden.
- En todo momento podemos decir cuál es el estado del código, pues hemos formulado las especificaciones como tests y sabemos cuales se cumplen y cuales todavía no.
La dificultad técnica del testing
Como ya sabemos hay muchos diseños que hacen especialmente difícil testear un código. En particular, cuando se tiene un alto acoplamiento o dependencias globales.
Esta dificultad puede llevarnos a evitar la etapa de testeo o reducirla solo a la comprobación del happy path.
Aunque hay técnicas específicas para lidiar con estas situaciones, lo ideal es el enfoque test first. Para poder cumplir con los tests, nuestro diseño lo tiene en cuenta desde el inicio y las situaciones problemáticas (acoplamiento, dependencias de estado global, etc) se manifiestan de manera inmediata, obligándonos a adoptar soluciones que reducen o evitan esos problemas.
La presión para no testear
La necesidad o la prisa de sacar productos o features al mercado puede generar una presión de la organización para entregar cuanto antes. En consecuencia, todo lo que no contribuye a ese objetivo tiende a dejarse de lado, y el testeo suele ser una de las primeras cosas que se abandona cuando se pretende ir ligero.
Volviendo al punto anterior, la sensación de tarea terminada es determinante aquí: “si el producto funciona, ¿para qué debería testearlo?”
La respuesta es otra pregunta: ¿cómo puedes decir que el producto funciona si no lo has testeado?
Es habitual tener la siguiente experiencia: dedicar un tiempo a desarrollar un código, entregarlo y tener que emplear el mismo tiempo o más para corregir detectar y corregir errores que se han manifestado tras entregar el producto. De tal modo que la definición de producto terminado queda completamente cuestionada: ¿podemos decir que un producto está terminado si tenemos que corregir errores que impiden su funcionamiento en muchos de los casos de uso habituales?
Ese tiempo que hemos tenido que dedicar a corregir errores hubiera podido dedicarse a prevenirlos mediante un testeo adecuado que guiase el desarrollo. No sólo eso, los errores pueden tener consecuencias en forma de pérdida de ingresos, publicidad negativa, etc, que son medibles y, muy probablemente, supongan un mayor coste que haber tratado de garantizar una buena cobertura de tests.
Testear no es igual a confirmar que funciona
En la visión popular de la ciencia es habitual pensar que un experimento exitoso demuestra el acierto de una teoría. Pero esto no es así, el objetivo de los experimentos es justamente lo contrario. Es lo que se denomina falsabilidad.
Siguiendo la idea de la falsabilidad, un experimento exitoso no demuestra que la teoría en la que se basa es verdadera, sino que no es falsa hasta donde ha sido probada. Y ese cambio de visión es fundamental: el experimento se diseña con la idea de refutar la teoría, de modo que si no funciona nos aporte información, señalando cuáles son sus límites, y podamos aceptarla o rechazarla provisionalmente.
Con los tests de software pasa algo parecido. Un test determinado puede pasar, pero eso no garantiza que el software funcione bien, ya que puede haber casos en los que no. Los test deberían buscar el fallo del software, es decir, probar aquellos casos que pondrían en cuestión su funcionamiento. Cuantos más de estos casos podamos expresar en forma de test, con más solidez podremos afirmar que el software funciona.
Un test de happy path, es decir, un test que sólo ejercita el flujo perfecto de nuestro código no nos garantiza el funcionamiento del mismo, aunque ciertamente nos ayuda proporcionando una representación formal de cuál debería ser su comportamiento.
Responsabilidad ética y testeo
Nuestro mundo está cada vez más basado en el software. ¿Te has parado a pensar alguna vez en las consecuencias de que tu código funcione como se espera o no?
Si una arquitecta o un ingeniero diseñan una estructura que resulta estar mal calculada y se derrumba tienen una responsabilidad civil. Piensa en el perjuicio económico que ello causaría. Pero, ¿y si ese derrumbe provoca lesiones o muertes?
¿En qué producto de software estás trabajando? Hoy en día, el software controla todo tipo de dispositivos y todo tipo de servicios. Por poner unos pocos ejemplos:
- Un error podría provocar que un usuario pierda una cantidad de dinero, al pagar por un servicio que nunca recibirá: ¿cómo de grande es la pérdida para esa persona?.
- Otro error podría dar lugar a un diagnóstico erróneo de un paciente en un hospital y, en consecuencia, un tratamiento inapropiado.
- Otro error podría impedir a una persona viajar para presentarse a una entrevista de trabajo y perder una oportunidad de empleo que tal vez no vuelva a darse.
Las consecuencias del mal funcionamiento del software van más allá de una molestia o de un perjuicio económico para una persona o empresa y son imposibles de predecir para nosotros.
El contexto es fundamental y es cierto que no es lo mismo escribir software para un supermercado online, que hacerlo para un sistema de navegación aeronáutica o un software de ayuda al diagnóstico médico. En base a ese contexto podemos prever una serie de posibles consecuencias y tomar medidas adecuadas. Cada uno de estos contextos tiene distintas exigencias en cuanto a la fiabilidad y exactitud del funcionamiento de nuestro código. Pero ello no nos libera de la responsabilidad de hacerlo lo mejor posible y el testing es un medio para lograrlo.
El testing no es garantía de un software sin defectos, es cierto, pero es la demostración de que estamos tomando medidas para crearlo de la mejor manera posible.
Primer test
Un test no es más que una pieza de código que comprueba el resultado generado por otra pieza de código, comparándola con un criterio:
En este ejemplo, la función test
llama a la función triple
con un valor y compara el resultado, convenientemente guardado en $result
, con un valor esperado. En caso de que coincida imprime ‘Pass’ y, en caso de que no, imprime ‘Fail’.
Este sistema funciona para un sólo test, pero es poco eficaz cuando queremos hacer más de uno. No digamos los cientos o miles que debería tener una aplicación mediana.
Veamos sus limitaciones más importantes:
- Tener que definir la estructura condicional, que es el meollo del test, para cada test que creamos.
- Tener que montar algún tipo de runner para ejecutar todos los tests de una aplicación de una sola vez, o tener que ejecutarlos uno por uno.
- No recoger información sobre la ejecución de los tests: los que han pasado, o los que han fallado.
- No recoger información acerca de cuáles son las diferencias encontradas entre el resultado obtenido y el esperado.
Frameworks al rescate
Para superar estas limitaciones, se han creado frameworks de testing que nos aportan:
Aserciones o matchers que encapsulan la comparación del resultado esperado y el obtenido, aportándoles significado. Por ejemplo: assertEquals
comprueba que sean iguales, assertGreaterThan
verifica que el resultado obtenido sea mayor que un criterio dado, assertTrue
verifica que una condición se cumple, y así muchas más.
Runner: los frameworks de tests utilizan un runner que recopila todos nuestros tests, o el subconjunto que indiquemos mediante algún tipo de criterio, y los ejecuta con una sola orden, optimizando el tiempo.
Estadísticas: los runners recogen estadísticas acerca de la ejecución de los tests. Pueden seguir corriendo, o no, cuando alguno de los tests falla y nos muestran información de qué tests se han ejecutado, cuáles han pasado, cuáles han fallado, o pueden analizar la cantidad de código cubierto por los mismos.
Análisis de diferencias: las aserciones que no se cumplen nos muestran comparaciones de los valores esperados con los obtenidos, lo que nos puede ayudar a analizar qué errores podemos haber cometido o dónde debemos intervenir para corregir el código.
Por tanto, la mejor manera de escribir tests es utilizar un framework que nos proporcionará todas las herramientas que podamos necesitar.
En los ejemplos de este libro utilizamos phpunit que es prácticamente el estándar, aunque ciertamente hay otros frameworks. Algunos de ellos parten de ciertos planteamientos metodológicos, como puede ser el Behavior Driven Development, una variante de TDD. Otros se especializan en tipos de tests específicos o complementan de algún modo el trabajo que podemos hacer con phpunit, o incluso se basan en él.
Sobre la instalación y puesta en marcha de phpunit, además de la documentación propia del framework, puedes recurrir a los apéndices del libro, donde lo explicamos.
El primer test
Vamos a suponer el siguiente código en el que se modela un carro de la compra para una tienda online.
La implementación es bastante mala y esto es a propósito, así que no te fijes mucho en ella. En este capítulo no vamos a discutir los detalles de su desarrollo, sino que vamos a ver cómo podríamos poner bajo test un código ya escrito y cómo eso nos puede servir para detectar problemas en un código ya existente.
Aquí tienes: src/Shop/Cart.php
¿Qué es lo que podríamos testear primero? Lo mejor es empezar un poco antes, tratando de tener claro cuál es el comportamiento que esperamos de nuestro carro. Podemos escribir una lista de especificaciones que sería más o menos como esta:
- Un carro nuevo se instancia autoasignándose un id y no contiene productos.
- Un carro nuevo puede instanciarse cuando un usuario selecciona un producto y lo añade a un carrito que todavía no existe.
- Podemos añadir productos al carro, indicando cuántos se añaden.
- Si se añade un producto que ya existe, se incrementa su cantidad.
- Se pueden quitar productos del carro, decrementando su cantidad.
- El carro nos puede decir si tiene productos o está vacío.
- Podemos obtener un recuento del total de productos que contiene.
- Podemos obtener un recuento del total de productos diferentes que contiene.
- Podemos obtener el importe total de los productos que contiene.
- Podemos vaciar por completo el carro.
Testeando la instanciación
Tiene bastante sentido comenzar testeando que el carrito se instancia correctamente tal y cómo esperamos. Para plantear el test debemos pensar en tres cosas:
- En qué escenario o precondiciones se va a ejecutar el test.
- De qué modo tenemos que accionar el objeto bajo test.
- Cómo vamos a obtener el resultado o consecuencias observables de esa acción.
En nuestro ejemplo, tenemos dos escenarios posibles para la instanciación. En uno de ellos el carrito se crea vacío, mientras que en el otro, se crea añadiendo un producto.
Observando el código, podemos ver que un carro nuevo se obtiene mediante un named constructor:
Y lo que queremos comprobar es que se le ha asignado un identificador y que no contiene ningún producto.
Con estas piezas podemos montar nuestro primer test.
En phpunit solemos agrupar los tests relativos a una misma clase en un Test Case. Un Test Case extiende de TestCase
, una clase básica para test que te proporciona todas las herramientas que necesitas para trabajar.
La convención dice que el nombre del Test Case es el mismo que el de la clase con el sufijo Test
. Este sufijo es necesario para que la configuración por defecto de phpunit pueda encontrar los tests que debe ejecutar. Puedes definir otro sufijo si lo deseas, pero no vamos a entrar en eso ahora.
Ahora bien, esto es una convención. No implica ningún tipo de obligación o requisito técnico. Puede que te interese tener varios Test Case para probar la misma clase, pero agrupando casos por diferentes criterios que a ti o a tu equipo le resulten significativos.
Aquí está tests/Dojo/Shop/CartTest.php
Ahora vamos a escribir un test que pruebe lo que hemos dicho sobre la instanciación de un carrito de la compra nuevo. Lo primero es escribir un método, que contiene el código del test:
El nombre del método comienza con el prefijo test
y debe ser público. De este modo, phpunit sabe que es un método de test y lo recolecta cuando decide qué tests tiene que ejecutar.
Otra forma de indicarlo es mediante anotaciones, lo que deja más limpio el nombre del método:
¿Es mejor uno que otro de los dos sistemas? No. Es una cuestión de preferencia personal o de equipo. Así que haz lo que más te guste o lo que te parezca más legible.
En cuanto al nombre del test, debería ser descriptivo de lo que se va a probar en él. Últimamente, mi forma de hacerlo es empezar todos los tests escribiendo “should” (debería) lo que te va enfocando hacia definir un comportamiento observable y concreto. En el ejemplo que acabamos de poner, el test dice que debería instanciarse un carro vacío con un id.
No hay problema en cambiar el nombre del test si creemos que se puede describir mejor el comportamiento probado, normalmente no va a afectar a nada a nivel técnico. Considéralo un refactor básico del mismo.
Si no tienes claro cómo expresar lo que estás testeando es posible que eso refleje que no sabes con precisión qué es lo que quieres testear, por lo que tal vez debas darle unas vueltas a tu objetivo y definirlo mejor.
Por otro lado, podrías estar en una fase exploratoria mientras tratas de comprender cómo funciona el software, por lo que puedes ponerle al test un nombre provisional como shouldDoSomething
y cambiarlo cuando tengas más claras las cosas.
En nuestro ejemplo, vamos a hacer un poco más preciso el nombre del test:
Bien, es hora de empezar a poner código. Lo que va a hacer este test es instanciar un carro y comprobar que cumple las condiciones expresadas. Recuerda, de nuevo, que un test tiene tres partes principales:
El escenario (la parte Given o Arrange del test) es que el usuario va a tomar un carro nuevo, por lo tanto, no tiene productos preseleccionados.
La acción (When o Action del test) es crear un carro nuevo.
El resultado (Then o Assert del test) es que el carro tiene un identificador y que no contiene productos:
En este caso no necesitamos preparar nada previamente a obtener el carro.
La acción es el hecho de inicializar el carro. Puesto que tenemos un named constructor estático no tenemos más que asignar una variable $cart
con Cart::pickUp()
.
Ahora $cart
contiene un carrito nuevo. Lo que hacemos a continuación es verificar ciertas condiciones mediante el uso de Aserciones.
Las aserciones encapsulan una comparación entre lo que esperamos y lo que obtenemos, realizando otras operaciones internas de recogida de información que serán útiles para la batería de tests. phpunit ofrece una buena cantidad de aserciones. Las que hemos usado aquí son:
assertNotEmpty
verifica si el parámetro que le pasamos, que resulta ser el id del carrito recién creado, no está vacío.
assertEquals
que verifica si el valor esperado (primer parámetro) es igual al obtenido en la acción (segundo parámetro).
En este caso, si ambas aserciones se cumplen, el test pasa e indicaría que, efectivamente, nuestro carrito se instancia con un identificador y vacío de productos.
Y eso es lo que ocurre al ejecutar:
Testeando la instanciación alternativa
La segunda forma de instanciar el carrito nos permite hacerlo añadiendo un producto previamente seleccionado por el usuario, para lo cual existe otro named constructor que admite parámetros que especifican el producto y la cantidad inicial del mismo.
Para probar esta segunda forma de instanciación lo que necesitamos es tener un producto, pasarlo al constructor y comprobar que el carrito contiene ese producto.
Nuestro escenario require que exista algún producto que podamos adquirir.
Nuestra acción será instanciar el carro con el método que nos permite pasarle el producto inicial.
Nuestra aserción será ver si se ha creado el id y si el producto ha sido añadido efectivamente al carro.
Algo así:
De momento, hemos encapsulado la creación del producto en un método porque precisamente queremos discutir de dónde vamos a sacarlo para el test.
Ahora mismo estamos escribiendo un test unitario, lo que implica que lo que estamos observando es el comportamiento de una unidad concreta de software (Cart
) y queremos evitar la influencia de otras unidades que puedan intervenir en alguna de sus acciones.
En el test que estamos tratando ahora necesitaremos un objeto producto para pasarle inicialmente al carro. Tenemos varias formas de usar ese objeto:
- Utilizar una instancia cualquiera de la clase Product.
- Utilizar un doble de la clase Product. A su vez, el doble podemos obtenerlo creando una subclase de Product apta para test, o bien usar un doble creado con una alguna utilidad.
En nuestro ejemplo, tenemos una interfaz ProductInterface que implementan todas las clases Product de nuestra tienda online:
ProductInterface
nos garantiza que los objetos que le pasamos a Cart
tengan un método id
y un método price
, que necesitamos para las operaciones propias de Cart.
Usando objetos reales
Podría ser que tuviésemos una clase Product
que implementa esta interface:
En consecuencia, simplemente podríamos crear un producto instanciando esa clase. Nuestro método getProduct
, quedaría así;
La gran ventaja es que es fácil y obvio. Esto es aplicable a objetos sencillos que tienen poco o ningún comportamiento, al menos en lo que respecta a la acción que estamos testeando, lo que nos permite garantizar que ese comportamiento no influye en la acción que probamos.
El test, de hecho, pasará sin problemas.
Normalmente esta técnica es adecuada cuando el objeto que necesitamos es una entidad, un value object, un DTO o cualquier otro tipo de objeto que nos interesa fundamentalmente por sus datos y que no tiene un comportamiento que afecte realmente al del objeto que estamos testando.
Usando dobles
Sin embargo, podría ocurrir que el objeto que necesitamos para testear otro no sólo tenga comportamiento, sino que el comportamiento de nuestro objeto bajo test sea dependiente de él. En otras palabras, que sea un “colaborador”. En ese caso normalmente necesitaremos un doble en el que podamos definir con total precisión el comportamiento que va a exhibir en ese test concreto.
Además de controlar con exactitud el comportamiento del colaborador, puede ser que ese colaborador por su implementación sea lento o dependa de cuestiones fuera del ámbito del test, como puede ser acceso a servicios remotos que hacen no sólo incontrolable su respuesta, sino que no garantizan su disponibilidad en la situación de test o su uso podría interferir en el funcionamiento de un sistema en producción.
En ese caso, debemos crear “dobles” que representen a esos objetos necesarios con comportamientos que nosotros predefinimos. Para crear dobles podemos usar dos estrategias:
- Definir una clase que implemente la misma interfaz del colaborador o que extienda la clase, escribiendo métodos con un comportamiento nulo o prefijado para el test.
- Utilizar una utilidad para generar dobles como la que ofrece el propio phpunit, con la cual simularemos comportamientos específicos del colaborador.
La segunda opción es la más práctica ya que no tenemos que escribir una clase a propósito para el test, sino que simplemente simulamos sus comportamientos indicando que debería responder cada método al ser invocado.
En el test que nos ocupa no es necesario utilizar esta estrategia, pero podemos hacerlo. El método getProduct
podría quedar así:
Brevemente:
$this->createMock(ProductInterface::class)
devuelve un objeto que implementa la interfaz ProductInterface
, aunque también podría generarse a partir de Product
o de cualquier objeto que quisiésemos simular.
El objeto $product
devuelto es tanto un ProductInterface
como un MockObject
, gracias a lo cual podemos simular el comportamiento realizado por sus métodos.
Con method
le indicamos a $product
que vamos a especificar el comportamiento de uno de sus métodos, forzándolo a devolver una respuesta prefijada mediante willReturn
.
Por ejemplo, esto sería como tener un Product cuyo id
es el especificado en $id
.
En otro capítulo profundizaremos en la creación y uso de dobles de test.
Finalmente, si ejecutamos el test, comprobaremos que también pasa.
Testeando que podemos añadir productos al carro
Con lo anterior hemos verificado que podemos instanciar el carro y podemos tachar dos requisitos de nuestra lista. Nuestro objetivo ahora es probar que podemos añadir productos. En cierto modo ya sabemos que esto se cumple pues tenemos un test que muestra que es posible instanciar el carro añadiendo un producto. Además, si vemos el código podemos comprobar que internamente se llama al método que añade productos, lo que implica que el test existente prueba, indirectamente, el comportamiento que vamos a examinar ahora.
Sin embargo, cuando hacemos tests unitarios, deberíamos considerar la clase bajo test como una caja negra y no pensar en su implementación, sino en su comportamiento observable. Podría ocurrir que la forma de añadir productos al carro fuese distinta en la creación que durante el resto de su ciclo de vida, por lo que testear explícitamente el método para añadir productos es mucho más seguro.
En este caso tenemos varios escenarios ya que necesitamos probar varias cosas:
- Que podamos añadir una unidad de un producto.
- Que podamos añadir varias unidades del mismo producto.
- Que podamos añadir una unidad de más de un producto.
- Que podamos añadir varias unidades de más de un producto.
En realidad, por lo que podemos ver de la interfaz pública, podemos añadir de una sola vez un producto, variando la cantidad.
Así que realmente podríamos testear estas situaciones:
- Añadir una unidad de producto.
- Añadir más de una unidad de producto.
- Añadir una unidad de dos productos distintos.
Existen varias formas de testear que hemos añadido productos al carro. La más obvia sería obtener los productos que tiene el carro y comprobar que están los que hemos introducido y que veremos luego.
La otra forma es mucho más sencilla. Consiste simplemente en ver si la cantidad de productos en el carro es la que esperamos según los que hemos ido añadiendo. Si sólo añadimos un producto, debería haber sólo un producto o una sola línea de producto, que es lo que realmente nos dice el método count
.
Si queremos testear que podemos añadir una cantidad de productos mayor a uno, nos encontramos que no nos vale el mismo test. count
nos dice cuántos productos distintos hay, mientras que totalProducts
nos dice cuántas unidades de productos hay en total. Primero veremos el test y luego analizaremos algunas cosas interesantes:
Triangulación. En este test hacemos dos aserciones que verifican dos aspectos similares del mismo problema. Decimos que hacemos triangulación en un test precisamente cuando hacemos varias aserciones que por sí solas podrían tener una interpretación ambigua, mientras que si las hacemos juntas reflejan con precisión lo que queremos comprobar.
En este caso, añadir un producto en una cantidad distinta de uno implica que se añade una única línea de producto en el carrito, pero que éste contiene diez unidades en total. Si sólo comprobásemos que hay diez unidades, no sabríamos si se corresponden con una única línea o con cinco o diez, lo que sería incorrecto según cómo debería funcionar nuestro carrito de la compra.
Detección de problemas. Cuando estamos haciendo un test y lo que queremos testear nos hace dudar, habitualmente es señal de que hay algún punto mejorable en el código. De hecho, cualquier dificultad al testear nos podría estar dando pistas de un problema de diseño.
Por ejemplo, el significado de count
como número de líneas o productos distintos en el carro no es muy intuitivo. Si count
devolviese el número de productos en total dentro del carro seguramente nos parecería más natural. La interfaz de Cart
podría quedar así:
-
Cart::count()
: número de productos -
Cart::totalLines()
: número de líneas
El tercer test relacionado con añadir productos al carro sería uno en el que añadimos varios productos en distintas cantidades. Eso generará una línea por cada producto distinto y hará que el total de productos sea la suma de las cantidades.
De nuevo hemos aplicado triangulación, ya que queremos que no haya ambigüedad al interpretar lo que ocurre aquí.
Esto nos lleva a otra cuestión: ¿qué ocurre si añado productos de un tipo, luego de otro y luego del primer tipo? Deberían generarse dos líneas de productos, no tres. Y debería acumularse el total de unidades. Probémoslo:
Y este test falla. De nuevo, la triangulación resulta importante. Si sólo hiciésemos una aserción sobre el número de líneas, el test pasaría:
Y esto nos daría una visión engañosa de lo que hace el código. Por eso, es importante definir bien lo que estamos testeando y cómo lo vamos a observar o medir.
Detección de errores. Por otro lado, este ejercicio nos revela el poder del testing para detectar bugs que no son aparentes o que se manifiestan sólo en ciertos casos de uso. Un vistazo rápido al código podría no revelar ningún problema y, por otro lado, el caso de uso de que un usuario añade un producto y, más tarde, añade más unidades de ese producto puede no ser evidente a primera vista.
En cualquier caso, lo que nos dice el resultado del test es que debemos modificar el código para hacerlo pasar. En este ejemplo, nos dice que al añadir un producto tendríamos que comprobar si ya estaba en el carrito y añadir las unidades extra.
Con este código el test pasa y la prestación de añadir productos al carro está ahora correctamente implementada.
Redundancia de tests
Con los tests que hemos escrito hasta ahora hemos cubierto dos de los comportamientos más importantes del carrito:
- Que el usuario pueda tomar un carrito e iniciar las compras
- Que pueda añadir objetos al carrito
Para poder verificar esos comportamientos hemos tenido que recurrir a algunos métodos que nos proporcionan recuentos del contenido del carrito. Estos métodos, por su parte, estaban en la lista de requisitos con la que habíamos empezado a trabajar.
La cuestión es que esos métodos no han sido testeados explícitamente, sino que los hemos verificado de forma implícita en los tests que hemos escrito para comprobar el comportamiento de la clase.
La pregunta que surge aquí es si deberíamos tener tests que los prueben explícitamente o no.
La respuesta es una cuestión de enfoque:
En un enfoque orientado al comportamiento tendríamos suficiente con los tests que ya hemos realizado. Los métodos de recuento ya están cubiertos implícitamente y la información que pueda aportar tests específicos sería redundantes. Al fin y al cabo estos métodos simplemente recogen datos del estado del objeto al realizar sus comportamientos.
En el enfoque alternativo, los tests específicos de estos métodos serían necesarios para eliminar cualquier ambigüedad y poder diagnosticar de forma precisa en caso de problemas. Puede resultar difícil aislar estos métodos para que los tests no resulten redundantes y se limiten a probar lo mismo que ya está probado en otros.
La redundancia en los tests no suele merecer la pena per se ya que no aporta nueva información. Es lo que ocurre en el ejemplo que tenemos entre manos, los tests de los métodos de recuento no nos van a proporcionar una información diferente de la que ya tenemos.
Otros tests
Uno de los comportamientos que se espera de Cart
es darnos información sobre el importe total de los productos en el carrito, que obtenemos mediante el método amount
. Una buena idea es empezar con un caso extremo y asegurarnos de que un carro vacío cuesta cero, lo que garantizará que no se han introducido importes inesperados al crear el carro.
Obviamente, a continuación deberíamos testear que se contabilizan los precios de los productos que añadimos.
Así como que tiene en cuenta las cantidades.
Y que podemos combinar productos y cantidades:
Descubriendo implementaciones incorrectas
En la lista de requisitos se nos dice que debemos poder retirar productos del carro, decrementando su cantidad. Si pensamos en los posibles escenarios veremos que son tres:
- El carro no tiene el producto previamente, por lo que no hay nada que decrementar.
- El carro tiene una unidad del producto, así que debería retirarse sin problema, quedando el carro con ninguna unidad de ese producto.
- El carro tiene más de unidad del producto (n), por lo que debería retirarse una sin problemas, lo que se reflejaría en que quedan n-1 en el carro.
Así que planteamos los tests correspondientes:
El método removeProduct
lanzará una excepción si no tenemos el producto indicado. Podemos testear fácilmente que se lanzan excepciones mediante el método expectException
del TestCase
de phpunit.
Este test pasa, lo que indica que este requisito está bien implementado.
Veamos el segundo. Primero ponemos un producto en el carro y luego lo retiramos, comprobando si queda alguno:
Este test también pasa. Si dejásemos de hacer tests aquí podríamos decir que el requisito se cumple y que la feature está implementada. Es por esa razón que hemos definido los tres escenarios: carro vacío, carro con un único producto y carro con más de una unidad del producto.
En nuestro ejemplo se puede prever que el tercer test no pasará tal y como está implementado el método. Sin embargo, es muy posible que el código real que tienes que testear no sea tan fácil de leer y la única manera de asegurarte de que funciona como es debido es precisamente haciendo un test que lo verifique.
Lo probaremos añadiendo dos productos y quitando uno de ellos. El carro debería contener todavía un producto:
Pero no ocurre eso, si no que se eliminan todas las unidades del mismo producto, lo que demuestra que la feature no está implementada correctamente.
He aquí una implementación sencilla que hace pasar el test, corrigiendo el problema:
En resumen. Es muy importante definir bien los escenarios que debemos probar en los tests, para lo que podemos utilizar las distintas técnicas de análisis que comentamos en un capítulo anterior, a fin de asegurarnos de que implementamos las features que se nos ha pedido de forma correcta.
Últimos tests
Tenemos pendiente el test de que podemos vaciar por completo el carro, el cual podría quedar así:
Nos queda por crear un test sobre si podemos preguntarle al carro explícitamente si está vacío, lo que supone la existencia de un método isEmpty
o similar. Este test implica que debemos probar tanto que el caso positivo (está vacío):
Como el negativo (contiene algún producto):
Hemos terminado… de momento
Este es el TestCase
con el que hemos probado que Cart
funciona, o en algunos casos no lo hace como se desea:
¿Cómo seguir a partir de ahora? Al tener el código de Cart
bajo test ganamos varias cosas:
Podemos hablar con propiedad de lo que hace Cart
. El primer beneficio de tener el software cubierto con tests es justamente que ahora tenemos una definición concreta y reproducible de lo que Cart
hace y cuáles son sus límites. Cualquier afirmación que podamos hacer sobre su comportamiento o bien está demostrada por un test, o bien podemos hacer un test para demostrarla.
Por ejemplo, si ocurre algún tipo de bug, podemos crear un nuevo test que al poner en evidencia el error nos indique dónde tenemos que intervenir y cuál es el resultado que debemos lograr. Por otro lado, el test podría demostrar que el error no es causado por Cart
, sino por otro elemento del código.
Refactor: podemos cambiar Cart
sin riesgo. Otro beneficio es que podemos modificar la implementación de Cart con la seguridad de que no romperemos su comportamiento. Mientras los tests sigan pasando podemos hacer todos los cambios que nos lleven a una mejor arquitectura.
Si lo que necesitamos es una reescritura los tests nos servirán para hacerlo con seguridad, aunque quizá tengamos que retocarlos para responder a las nuevas decisiones de diseño.
Un ejercicio para aprender TDD
Una vez que comprendemos el concepto, no es difícil hacer TDD. Pero ese primer paso necesario para arrancar suele necesitar ayuda. Lo mejor es encontrar un ejercicio de programación que sea sencillo sin ser trivial y que ayude a poner de manifiesto los elementos más importantes de la metodología TDD.
TDD es más una disciplina que una técnica específica. Para aprender y mejorar en ella lo recomendable es practicar mucho. Los ejercicios de TDD suelen denominarse katas, como las de las artes marciales. Se trata de automatizar el proceso de crear un test, escribir código para que el test pase y refactorizar. Por eso, conviene hacer y repetir ejercicios, ya sea en solitario, ya sea en pairing con otra persona, o en grupo, o incluso presenciar cómo lo hacen otras personas.
Recientemente, encontré un ejercicio que me ha ido muy bien para empezar a introducir a otras personas en TDD. Aunque no es una kata reconocida, he descubierto que funciona muy bien como primera aproximación a la metodología. Se trata de escribir un Value Object para representar el DNI (el documento de identificación individual en España). Ese documento también se utiliza como Número de Identificación Fiscal (NIF) por lo que usaré los dos nombres indistintamente.
Repasando conceptos
Qué es eso del DNI (si no eres de España)
Un DNI (Documento Nacional de Identidad) es un identificador que consta de ocho cifras numéricas y una letra que actúa como dígito de control. Existen algunos casos particulares en los que el primer número se sustituye por una letra y ésta, a su vez, por un número para el cómputo de validez que viene a continuación. Este último es el caso del NIE o Número de Identificación para Extranjeros residentes.
El algoritmo para validar un DNI es muy sencillo: se toma la parte del numero del documento y se divide entre 23 y se obtiene el resto. Ese resto es un índice que se consulta en una tabla de letras. La letra correspondiente al índice es la que debería tener un DNI válido. Por tanto, si la letra del DNI concuerda con la que hemos obtenido, es que es válido.
La tabla en cuestión es esta:
Resto | Letra |
---|---|
0 | T |
1 | R |
2 | W |
3 | A |
4 | G |
5 | M |
6 | Y |
7 | F |
8 | P |
9 | D |
10 | X |
11 | B |
12 | N |
13 | J |
14 | Z |
15 | S |
16 | Q |
17 | V |
18 | H |
19 | L |
20 | C |
21 | K |
22 | E |
Qué es un Value Object
Value Object es un tipo de objeto que representa un concepto importante de un dominio el cual nos interesa por su valor, y no por su identidad. Esto quiere decir que dos Value Object del mismo tipo se consideran iguales e intercambiables si representan el mismo valor.
En el mundo físico tenemos un gran ejemplo de Value Object: el dinero. Los billetes de 10 euros, por ejemplo, representan todos la misma cantidad, y da igual el ejemplar concreto que tengamos, siempre representará 10 euros y lo podremos cambiar por otro del mismo valor, o por una combinación de billetes y monedas que sumen el mismo valor. Los billetes, de hecho, tienen una identidad (se les asigna un número de serie) pero no se tiene en cuenta para su utilización como medio de pago.
El valor representado por un billete no cambia. En el ámbito de la programación, los Value Objects tampoco pueden cambiar de valor a lo largo de su ciclo de vida: son inmutables. Se instancian con un valor determinado que no puede cambiarse. Para tener un valor nuevo se debe instanciar un objeto nuevo de ese tipo con el nuevo valor.
Para instanciar un Value Object debemos asegurarnos de que los valores que le pasamos nos permiten hacerlo de forma consistente, por lo que serán importantes las validaciones. Lo bueno, es que una vez creado, siempre podemos confiar en que ese Value Object será válido y lo podemos usar sin ningún problema.
Las leyes de TDD
Repasemos las leyes de TDD. Son tres, en la formulación de Robert C. Martin:
- No escribirás ningún código de producción sin antes tener un test que falle.
- No escribirás nada más que un test unitario que sea suficiente para fallar.
- No escribirás nada más que el código de producción necesario para hacer pasar el test.
La primera regla nos dice que siempre hemos de empezar con un test. El test especifica lo que queremos conseguir que haga el código de producción que escribiremos posteriormente. Nos indica un objetivo en el que nos vamos a centrar durante los minutos siguientes, sin preocuparnos de nada más.
La segunda regla nos pide que sólo escribamos un único test cada vez y que sea lo bastante concreto como para fallar por un motivo específico, y lo hará inicialmente orque todavía no hemos escrito código que resuelva esa situación que estamos definiendo con el test.
Una vez que tenemos el test tenemos que ejecutarlo y verlo fallar. Literalmente: “verlo fallar”. No basta con “saber” que va a fallar. Tenemos que verlo fallar y que, así, nos diga cosas.
La tercera regla nos pide que al escribir el código de producción nos limitemos al estrictamente necesario para hacer pasar el test, ni más, ni menos, de la manera más inmediata y obvia posible en las condiciones actuales del código.
Si la manera más obvia es devolver la respuesta esperada por el test, eso es lo que debemos hacer.
Si la manera más obvia es tratar un caso con una estructura if… else
, y devolver algo distinto en cada rama, eso es lo que debemos hacer.
Ya vendrán después otros tests que nos forzarán a cambiar esa implementación obvia por una más general.
Estas tres leyes son las fuerzas motoras del desarrollo dirigido por tests o TDD y, a pesar de su aparente sencillez, tienen una gran potencia para ayudarnos a escribir un código eficiente y bien diseñado.
Y espero que en este ejercicio las puedas ver en acción.
La kata del DNI
Nuestro ejercicio consistirá en crear un Value Object que nos sirva para representar un DNI o NIF. Por tanto, queremos que se pueda instanciar un objeto sólo si tenemos un DNI válido. Así que vamos a ello.
Lo que queremos es algo así:
¿Qué vamos a testear?
Esencialmente, un DNI no es más que una cadena de caracteres con un formato específico. De todas las cadenas de caracteres que se podrían generar sólo un subconjunto de ellas cumplen todas las condiciones exigidas para ser un DNI. Estas condiciones se pueden resumir en:
- Son cadenas de 9 caracteres.
- Los primeros 8 caracteres son números, y el último es una letra.
- La letra puede ser cualquiera, excepto U, I, O y Ñ.
- La última letra se obtiene a partir de un algoritmo que la consulta de una tabla a partir de obtener el resto de dividir la suma de los dígitos numéricos entre 23. Si la letra suministrada no se corresponde con la calculada, el DNI no es válido.
- El primer carácter puede ser X, Y o Z, lo que indica un NIE (Número de identificación para personas extranjeras).
- Para la validación, las letras XYZ se reemplazan por 0, 1 ó 2, respectivamente.
En caso de que alguna de las condiciones no se cumpla, el DNI no es válido.
Si nos fijamos en las condiciones recogidas en la lista anterior, vemos que cada una de ellas reduce el número de cadenas de caracteres candidatas a ser un DNI.
El primer test
Una de las grandes ventajas de trabajar con TDD es que nos permite posponer la toma de decisiones sobre lo que programamos. Es una ventaja muy valiosa, aunque poco conocida. Precisamente esta kata del DNI lo refleja muy bien.
La capacidad de posponer decisiones es muy importante para escribir código de calidad. Nos permite ganar tiempo y conocimiento para tomar una decisión mejor informada. Así, en lugar de intentar decidir de entrada cómo vamos a implementar el algoritmo que valida los DNI, lo que vamos a hacer es posponerlo hasta estar en mejores condiciones de afrontarlo.
En primer lugar, vamos a buscar un problema lo más sencillo posible y lo vamos a resolver de la manera más obvia que podamos. Con lo que aprendamos, buscaremos un nuevo problema sencillo que nos acerque, poco a poco, al meollo del ejercicio: implementar la validación.
Un buen enfoque es tratar de empezar con un aspecto muy general de lo que vamos a desarrollar, para ir enfocándonos en detalles más concretos a medida que progresamos.
El primer problema sencillo que podemos resolver es asegurarnos de que vamos a rechazar cadenas de caracteres que de ningún modo pueden ser un DNI: aquellas que tienen más o menos de 9 caracteres.
Así que es hora de abrir el editor y empezar a escribir nuestro primer test.
El primer impulso podría ser el hacer un test con el que se compruebe que nuestro DNI sólo acepta cadenas que contengan exactamente nueve caracteres. Pero, si lo piensas, es mucho más fácil comprobar que rechaza cadenas que contengan más o menos de ese número caracteres.
Ten en cuenta lo siguiente: una vez que has hecho un test, tiene que seguir pasando a medida que añades más tests y más código de producción. Ahora mismo, podríamos escribir un test que prueba que una cadena de nueve caracteres sea aceptada, pero para que ese test no falle en el futuro, la cadena ya tendría que ser un DNI válido y nuestro código todavía no sabe nada sobre eso. Si ahora ponemos una cadena de nueve caracteres en el test que no sea un DNI válido, el test fallará en el futuro cuando implementemos el algoritmo completo, obligándonos a cambiar esos tests que han fallado.
Por eso, vamos a escoger un problema mucho más sencillo y general: rechazar cadenas de caracteres que no tengan la longitud adecuada y que, por tanto, nunca podrían ser DNI válidos, con lo que esos tests no fallarán al implementar el algoritmo completo. De hecho, sólo podrían fallar si realizamos algún cambio que introduzca un cambio en el comportamiento o un error, lo que los convierte en tests de regresión.
Por tanto, testearemos que al intentar instanciar un objeto Dni
se lanza una excepción si la cadena de caracteres tiene una longitud inadecuada. Pero la vamos a hacer en dos pasos: primero probaremos cadenas con más de nueve caracteres.
y aquí tenemos el primer test, en tests/DniTest.php
Si lanzamos el test este es el resultado:
Este es el fallo que cabría esperar ya que no tenemos la clase Dni
definida. Es la primera ley de TDD: no escribir código de producción sin antes tener un test que falle.
Pero esto ya nos dice lo que tenemos que hacer. Nuestro objetivo inmediato es crear la clase, simplemente para que el test pueda usarla.
Y la creamos rápidamente con ayuda del IDE (en src/Dni.php):
Ahora que hemos creado lo que el test nos pedía, podemos volver a lanzarlo y ver qué pasa. Y lo que pasa es esto:
Hemos resuelto el primer error y el test ya se ejecuta y falla. Ahora ya estamos cumpliendo la segunda ley: tenemos el código de test mínimo para que falle. Y esto nos comunica, de nuevo, qué es lo que tenemos que hacer.
Y, en aplicación de la tercera ley, vamos a escribir el código de producción mínimo para hacer que el test pase.
Y lo mínimo, y más obvio, es hacer que la excepción se lance incondicionalmente:
Este código de producción hace que el test pase y nosotros ya estamos listos para avanzar un paso más. Pero vamos a observar un par de detalles:
- No estamos pasando nada al constructor. De hecho, no lo necesitamos todavía. Estamos posponiendo la decisión de qué vamos a hacer con ese parámetro.
- El código sólo hace lo que pide el único test que tenemos, porque realmente no estamos resolviendo aún ese problema.
Y está bien que sea así.
El siguiente test
Ahora vamos a asegurarnos de que no podemos instanciar un objeto Dni
con una cadena de longitud más corta que nueve caracteres. Por tanto, lo vamos a expresar mediante un nuevo test.
Si ahora lanzamos el test veremos que falla. Hemos decidido que se lanza el mismo tipo de excepción, pero con distinto mensaje.
Por tanto, nuestro objetivo ahora es hacer que el nuevo test pase, a la vez que mantenemos en verde el test anterior.
Si ahora escribiésemos el siguiente código de producción:
Lo que ocurrirá será que el último test pasará, pero el anterior fallará. Ejecutando phpunit
con la opción --testdox
para verlo mejor: bin/phpunit tests/Dojo/DniTest.php --testdox
obtenemos este informe:
Es decir, que tenemos que resolver el problema planteado por el test anterior primero y luego aplicar la implementación obvia.
Y ahora pasan los dos tests.
Este segundo test nos ha forzado a encontrar una solución al problema planteado en el test anterior. Es decir, al implementar el código obvio para pasar el test previo, hemos pospuesto la toma de decisiones sobre esa condición. Y es ahora cuando resolvemos el problema.
De hecho, estamos posponiendo el problema planteado para este segundo test, así que tenemos que avanzar y crear un nuevo test.
Tercer test
Ahora ya garantizamos que sólo serán candidatas a ser un Dni las cadenas de nueve caracteres y tenemos dos tests que lo demuestran.
Con eso hemos reducido el ámbito del problema. Ahora tenemos que ver qué secuencias de caracteres tienen aspecto de ser un DNI.
En realidad, sabemos que un DNI es una serie de números con una letra al final, excepto aquellos casos en los que se permiten ciertas letras como primer carácter. Esto nos dice que una cadena formada por números que tenga una letra al final puede ser un Dni. Pero, aún mejor, también nos dice que una cadena cuyo símbolo final sea un número no puede serlo, como una cadena formada sólo por números.
Por lo tanto, vamos a testear precisamente eso:
El mensaje que obtenemos al ejecutar el test nos dice lo que necesitamos saber:
Y lo que nos está diciendo es que espera una excepción DomainException
pero el código lanza una LengthException
, indicándonos que tenemos un problema pendiente de resolver. Para ello, escribimos este código de producción:
Los tres test ahora pasan y es hora de analizar lo que tenemos.
El ciclo red-green-refactor
Hasta ahora, hemos estado siguiendo las leyes de TDD para guiar nuestros pasos, pero en el proceso TDD también se genera el ciclo red-green-refactor.
Este ciclo es consecuencia de las tres leyes:
- Fase red: una vez que tenemos un test que falla decimos que estamos en “rojo”, esto es: el test falla y tenemos que implementar código de producción para que pase.
- Fase green: nuestro objetivo es que el test pase y ponernos en “verde”.
- Fase refactor: una vez que hemos conseguido hacer pasar un test y antes de empezar a escribir el siguiente, examinamos nuestro código para ver si podemos aplicar alguna mejora mientras mantenemos los tests pasando.
Esto es: podemos mejorar la estructura y organización interna de nuestro código siempre que mantengamos su comportamiento, cosa que garantizamos mediante los tests. Si en este punto introducimos un cambio de comportamiento alguno de los tests fallará.
¿Qué cambios podríamos querer hacer? Lo más evidente suele ser evitar o reducir la duplicación innecesaria de código, lo que nos lleva poco a poco a mejores estructuras y diseños.
Vamos a ver qué encontramos en nuestro código de producción:
Para empezar, vemos el número nueve dos veces. No sólo hay una repetición del mismo valor, sino que lo podemos considerar un número mágico. Un número o valor mágico no es más que un valor arbitrario que tiene un significado no expresado en el código. En este caso, el nueve representa la longitud válida de un DNI, por lo que podríamos convertirlo en una constante, lo que le da nombre y significado:
Aplicamos este cambio y ejecutamos los tests para comprobar que siguen pasando.
Otra duplicación la podemos ver en las dos condicionales que controlan la longitud de la cadena. Lo cierto es que nos bastaría con lanzar la excepción si la longitud es distinta de nueve. Por ejemplo, así:
De nuevo, con este cambio, los tests siguen pasando. Sin embargo, la expresividad ha salido un poco perjudicada, por lo que que podríamos extraer la condición y el lanzamiento de la excepción a su propio método, dejando más limpio el constructor.
Ahora lo que tenemos es una cláusula de guarda que, a la vez que oculta la complejidad, es mucho más explícita acerca de lo que ocurre.
Refactor de los tests
En este punto me gustaría plantear una cuestión interesante. El refactor también puede aplicarse a los tests. En cualquier momento podemos darnos cuenta de que tenemos tests que son redundantes o que, si bien fueron necesarios para generar el código, se han vuelto innecesarios en su estado actual.
Por eso, en la fase de refactor, podemos modificarlos siempre y cuando los mantengamos en verde.
Por ejemplo, podríamos decidir que no necesitamos chequear el mensaje de la excepción LengthException
ya que para este proyecto no nos aporta nada significativo saber que la cadena sea demasiado corta o demasiado larga. Simplemente tiene el tamaño inadecuado. Si quitamos esa línea en los tests, éstos siguen pasando, que es como decir que siguen testeando lo mismo.
De hecho, no es buena práctica hacer tests basados en los mensajes de las excepciones, pero nos están siendo útiles temporalmente para poder lanzar y esperar el mismo tipo de excepción producida por causas diferentes.
Adicionalmente, ganamos la ventaja de poder simplificar un poquito más el código de producción porque no tenemos que personalizar el mensaje de error:
Volviendo al rojo: hagamos un nuevo test
Después de detenernos un rato en mejorar la calidad de la implementación, con la red de seguridad que supone mantener los test existentes pasando para garantizar que no alteramos el comportamiento, llega el momento de seguir avanzando en la implementación.
Nuestro último test planteaba el problema de que el último carácter de la cadena candidata a ser un DNI no puede ser un número.
Ahora vamos a profundizar en esa condición para testear que tampoco puede ser una letra del conjunto [I, O, U, Ñ], las cuales han sido eliminadas para evitar confundirlas con otros símbolos. Una cadena de caracteres terminada en uno de éstos símbolos no puede ser un DNI y esto es lo que refleja el test:
Test que, al ejecutarlo, falla:
Que un test falle es una gran noticia. Nos dice lo que necesitamos saber y lo que tenemos que hacer: resolver el problema que hemos pospuesto antes, o sea, comprobar que el último carácter no es un número, cosa que aquí he decidido hacer con una expresión regular:
Podríamos haber escogido otra implementación con tal de hacer pasar el test, por tosca o ingenua que nos pudiese parecer. Lo importante es que consigas que funcione y, cuando sepas que funciona porque los tests pasan, es cuando intentas mejorar esa implementación que has hecho. Pero el objetivo ya está cumplido.
Poco más podemos hacer con este código, así que podemos avanzar a la siguiente condición.
La siguiente condición que voy a probar es que el DNI sólo puede estar formado por números, excepto la letra final y, en ciertos casos, la inicial. Por tanto, no puede haber caracteres que no sean números fuera de las posiciones extremas. Lo expresamos en forma de test:
El test falla:
Como estamos en rojo, vamos a implementar algo que nos permita pasar el test:
De nuevo: no tenemos que preocuparnos mucho por la calidad de la implementación. Simplemente escribimos código de producción que haga pasar el test y mantenga los test anteriores pasando, de manera que seguimos teniendo el comportamiento deseado en todo momento.
En cualquier caso, con esta implementación, el test está pasando y es ahora cuando podríamos pararnos a mejorarla. Pero eso lo vamos a dejar para dentro de un rato. No tenemos que hacerlo a cada paso si no nos convence o no vemos claro cómo hacer ese refactor. Tenemos un código que no sólo funciona, sino que su funcionamiento está completamente respaldado por tests.
Ahora vamos a probar otra condición. Esta vez, trata sobre cómo debería ser el principio de la cadena. O mejor dicho: cómo no debería ser. Y la cuestión es que no debería empezar por nada que no sea un número o las letras [X, Y, Z].
Describimos eso con un test que falle:
El código de producción que hace pasar este test es el siguiente:
De nuevo, posponemos la solución de ese problema a la siguiente iteración. El caso es que, con el último test, hemos definido ya todas las condiciones que debería cumplir una cadena de caracteres para poder ser un DNI aunque, recordemos, en realidad todavía no hemos implementado todo ese comportamiento ya que necesitamos un nuevo test que nos obligue a ello.
Ahora mos toca entrar en el terreno del algoritmo del validación en sí.
Este algoritmo se basa en obtener el resto de la división de la parte numérica del DNI entre 23. Con este resto buscamos la letra de control en la tabla de correspondencias y la comparamos con la que finaliza la cadena. Si coinciden, el DNI es válido. Si no coinciden, lanzaremos una excepción.
A partir de ahora, la validez de la cadena candidata como DNI vendrá determinada por el resultado de aplicar el algoritmo. Además, a partir de ahora vamos a seguir un modelo de validación pesimista en el que, por defecto, asumiremos que la cadena de caracteres es inválida salvo que se demuestre lo contrario al aplicar el algoritmo.
Por tanto en nuestro siguiente test vamos a probar que se lanza excepción cuando la cadena candidata no es válida.
Encontrar ejemplos para generar tests es muy fácil, ya que nos basta con utilizar las cadenas desde 00000000 a 00000022. En la siguiente tabla de correspondencia tenemos los ejemplos válidos:
Parte numérica | Resto | Letra | DNI |
---|---|---|---|
00000000 | 0 | T | 00000000T |
00000001 | 1 | R | 00000001R |
00000002 | 2 | W | 00000002W |
00000003 | 3 | A | 00000003A |
00000004 | 4 | G | 00000004G |
00000005 | 5 | M | 00000005M |
00000006 | 6 | Y | 00000006Y |
00000007 | 7 | F | 00000007F |
00000008 | 8 | P | 00000008P |
00000009 | 9 | D | 00000009D |
00000010 | 10 | X | 00000010X |
00000011 | 11 | B | 00000011B |
00000012 | 12 | N | 00000012N |
00000013 | 13 | J | 00000013J |
00000014 | 14 | Z | 00000014Z |
00000015 | 15 | S | 00000015S |
00000016 | 16 | Q | 00000016Q |
00000017 | 17 | V | 00000017V |
00000018 | 18 | H | 00000018H |
00000019 | 19 | L | 00000019L |
00000020 | 20 | C | 00000020C |
00000021 | 21 | K | 00000021K |
00000022 | 22 | E | 00000022E |
Para generar un caso no válido, nos basta con tomar cualquiera de las secuencias numéricas y asociarla con cualquier letra excepto la propia. Por ejemplo: 00000000S (o 00000000 con cualquier letra que no sea la T).
Y el test sería más o menos así:
El cual falla porque no se lanza la excepción esperada:
De nuevo, para pasar el test debemos resolver primero el problema que dejamos pendiente en el anterior:
Refactor
El caso es que si ahora observamos el código de producción que tenemos es fácil pensar que podría hacerse más conciso. Tenemos cuatro estructuras condicionales que comprueban el match de una expresión regular y, aunque son diferentes patrones, se puede ver que estamos ante una forma de duplicación innecesaria.
Pero para hacerlo tengo que modificar un poco los tests, ya que no quiero depender de los mensajes de las excepciones1. En principio, eliminar la comprobación de los mensajes no afectará al resultado del test.
El TestCase ahora mismo es así:
Con el test pasando, podemos emprender el refactor. Vamos a ver si podemos unir las expresiones regulares. Lo primero que vamos a intentar es unir las condiciones afirmativas entre sí:
Y luego las negativas, aprovechando para hacerla un poco más concisa:
Ya sólo tenemos dos estructuras if
y hemos hecho el cambio sin romper la funcionalidad que ya existía gracias a los tests existentes. Ahora vamos a ver si podemos unificarlas, invirtiendo el patrón de una de ellas:
Y aquí tenemos el resultado. Es muy interesante que hemos desarrollado paso a paso una expresión regular para identificar secuencias de caracteres que podrían ser DNI válidos mediante tests. Pero ahí no queda la cosa, podemos ir un paso más lejos.
Si nos fijamos en la expresión regular podemos ver que fuerza una longitud precisa de caracteres en la cadena, haciendo innecesario el control de longitud que encapsulamos en el método checkDniHasValidLength
. Como tenemos tests, podemos probar que pasa si comentamos la línea donde se llama para que no se ejecute al relanzar los tests.
Falla:
Pero falla porque se lanza una excepción distinta a la esperada, no porque ahora acepte como válidas cadenas que no lo son. Recuperamos la línea comentada y vamos a cambiar el test para reflejar el nuevo comportamiento que queremos: que falle con la excepción DomainException
:
Lanzamos de nuevo los tests para ver fallar los que se refieren a la longitud de la cadena. Entonces, cambiamos el código de producción para no volver a controlar explícitamente la longitud:
Los tests pasan y nuestra clase Dni es ahora más compacta, podemos mejorar un poquito su legibilidad:
Retomando el desarrollo
Ahora que hemos refactorizado el código hasta dejarlo en la mejor forma posible, estamos en condiciones de seguir desarrollando. En esta ocasión, vamos a empezar con cadenas que sean válidas, las cuales podemos tomar de la tabla que mostramos anteriormente. Podemos empezar por 00000000T.
El test no pasará porque no hay nada implementado:
Pero podemos observar que se lanza la excepción InvalidArgumentException, lo que quiere decir que la cadena que hemos pasado para construir el objeto ha superado la validación de formato inicial, señal de que vamos bien.
Lo mínimo para pasar el test podría ser:
Y podemos seguir con otros ejemplos:
Resuelto con:
En este caso es bastante obvio como seguiría esta vía, así que vamos a empezar a implementar el algoritmo que, por otra parte, es bastante sencillo. Pero para ello, primero añadiremos otro test:
Y ahora empezamos a tratar la cadena recibida para separarla en partes, manteniendo los tests en verde.
Con los tres ejemplos que tenemos podemos ver una estructura: es posible mapear el valor de la variable $mod
con la letra con la que debería acabar el DNI, así que lo reflejamos en una nueva versión del código.
Como los tests siguen pasando, podemos hacer un par de experimentos para que el código sea más manejable. Por ejemplo, en lugar de un array podemos guardar el mapa como un string:
Y convertirlo en una constante, a la vez que añadimos el resto de letras que nos permitirá validar cualquier posible DNI.
Los tests siguen pasando y con esto tenemos casi terminado nuestro Value Object.
El curioso problema de los tests que pasan a la primera
Aún nos quedan unos casos que tratar: los DNI especiales que empiezan con los caracteres X, Y, Z. Hagamos un test para tratarlos.
El test no falla. Y esto es malo porque no nos aporta información ni nos dice qué debemos implementar. Resulta un poco paradójico porque queremos que ese DNI sea reconocido como válido.
En TDD un test puede pasar a la primera por alguna estas razones:
- Nuestra implementación del algoritmo es más general de lo que esperábamos.
- El caso probado puede tener algún tipo de ambigüedad que no es captada por el código.
- La implementación tiene algún tipo de problema.
- No hemos escrito el test correcto.
Seguramente nuestro problema está en la línea:
Que convierte la parte numérica de la cadena en un entero, con lo cual la X es ignorada y se obtiene el número 0 que, por otra parte, es lo que queríamos conseguir.
Pero lo que necesitamos para hacer cambios es un test que falle, así que probamos con un ejemplo que sí debería fallar por la razón correcta que es el no tener implementado nada que maneje esa situación:
El algortimo de validación dice que debemos sustituir la Y por un 1 y proceder de la manera habitual:
La verdad es que no es necesario hacer un nuevo test para implementar lo que queda, que es añadir las dos transformaciones que nos faltan. Hacemos eso y, manteniendo los tests en verde, refactorizamos un poco, extrayendo el método para el cálculo del resto, así como nos deshacemos de todos los números mágicos convirtiéndolos en constantes:
El resultado es este Value Object, cuyo código está completamente cubierto por tests y responde a todos los requisitos que teníamos inicialmente.
Nuestro siguiente paso sería terminar de testearlo usando, por ejemplo, data providers para verificar todos los casos de la tabla de correspondencias que mostramos antes, así como otros casos no válidos. Pero eso ya no sería una cuestión de TDD, sino de tests de QA.
Resolución de bugs mediante TDD
Posiblemente no se te ha escapado un detalle importante: ¿qué ocurre si le pasamos un dni con la letra en minúscula a nuestra clase? Aprovechemos este detalle para ver cómo se trataría un bug usando TDD.
Aunque TDD y el testing, en general, nos ayudan a desarrollar software muy sólido, no siempre podemos evitar que se introduzcan errores debido a especificaciones incompletas o defectuosas. Cualquier situación o caso no cubierto por un test es susceptible de fallar al ejecutar el programa.
En un proyecto real, posiblemente nos encontraremos con infinidad de situaciones en las que una definición incompleta o imprecisa de una tarea nos pueda llevar a desplegar código que puede incluir defectos y generar resultados erróneos.
En nuestro ejemplo, las especificaciones no incluían criterios para actuar en el caso de que las cadenas candidatas a ser un DNI se presentasen en mayúsculas o minúsculas. Por tanto, el comportamiento de nuestro software en este caso no está determinado. Aunque en este caso pueda parecer evidente que hay un problema, la mayor parte de las veces no se puede predecir y el error se descubre cuando algo falla en producción.
Lo primero, un test que ponga en evidencia el problema
Una vez que se ha identificado el problema, o un área de comportamiento del software que está indeterminada, el primer paso es escribir un test que, fallando, ponga en evidencia que es necesario añadir código para lograr el comportamiento que se desea y solucionar el bug. Este test fallará si hemos definido bien la situación problemática.
En nuestro ejemplo haremos un test que pruebe que un DNI escrito con letras minúsculas es válido. Además, queremos asegurarnos de que se normaliza a mayúsculas, por lo que comprobamos que la cadena contenga la letra en el caso adecuado.
El test falla, con el siguiente mensaje:
Esto es, el Value Object identifica la cadena como un DNI no válido si está en minúscula, lo que nos dice que hemos identificado correctamente el problema y el test prueba la existencia del error.
El código para arreglar esto es bastante sencillo, simplemente pasamos la cadena a mayúsculas antes de nada:
Esta línea que hemos añadido soluciona el problema y también lo hace para los NIE, que empiezan con las letras X, Y, Z, por lo que no nos servirá de mucho hacer nuevos tests.
El test, por su parte, demuestra que esta circunstancia está contemplada por nuestro software.
Así que TDD también nos sirve como forma de afrontar la corrección de errores y bugs al permitirnos expresar el comportamiento correcto en forma de test y poner en evidencia la necesidad de escribir el código necesario para que ese comportamiento sea realizado por el software, manteniendo el ya descrito por los tests existentes. Además, el propio test nos certifica que el problema ha sido solucionado.
Desarrollar un algoritmo paso a paso con TDD: Luhn Test kata
Originalmente, hice esta kata con python en el blog, pero voy a trasponerla a PHP para este capítulo. Los principios en que se basa son exactamente los mismos, pero he tratado de eliminar todas las referencias a python y a como organizar el entorno de trabajo en ese lenguaje.
Sobre la Luhn Test kata
La idea es desarrollar una función que compruebe números de tarjetas de crédito para ver si son válidos o simplemente cifras escogidas al azar.
Puedes ver el ejercicio y sus detalles en este Gist de Manuel Rivero, quien presentó la kata en uno de los meetups de la Software Crafters de Barcelona, con una complejidad añadida: crear una única función o método para hacer la validación, testeando únicamente la interfaz pública y hacerlo en baby-steps.
Esto significa no recurrir a objetos colaboradores, que podrían testearse aisladamente, o a trampear los tests probando métodos privados o similar. También significa no generar todo el algoritmo en una primera iteración, sino forzarse a ir paso a paso.
El reto tiene su miga y eso es lo que voy a intentar mostrar en este capítulo.
Empecemos con un test
Lo primero es empezar con un test, así que voy a crear un primer archivo que llamaré LuhnValidatorTest.php.
Voy a explicar este test y cómo lo he hecho.
Analizar el problema y escoger el primer test
Escoger el primer test en TDD es siempre un pequeño reto y resolverlo es algo que se perfecciona con la práctica y examinando el problema que tenemos entre manos. También es cierto que el propio proceso de TDD suele confirmarte si lo has escogido bien o tienes que replantearlo.
Una aclaración: no voy a considerar toda la problemática de validar la longitud de la cadena que se pasa como argumento, ni otras consideraciones que haría en un caso “real”, así nos centramos en el meollo de este ejercicio.
En cuanto al test en sí, en este ejercicio es importante pensar en cómo funciona el algoritmo de validación. Los puntos importantes son:
- El dato de entrada es una cadena de 11 dígitos.
- Primero, se invierte la cadena de números.
- Después se toman los que ocupan las posiciones pares e impares y se tratan por separado.
- El grupo de los impares simplemente se suma.
- El grupo de los pares, se multiplica por dos, se suman los dígitos de los resultados entre sí, si son mayores que 10, y lo que sale se acumula.
- Luego se suma lo obtenido de los números impares y pares.
- Si el resultado es múltiplo de 10 (esto es: acaba en cero) entonces el número de tarjeta ingresado es válido.
Al analizar este flujo podemos ver que las operaciones son sumas y productos y, siendo los productos una suma de sumandos iguales, tenemos una cifra interesante para empezar a trabajar que es el 0, elemento neutro de la suma de enteros. Un número de tarjeta compuesto sólo por ceros será válido porque la serie de cálculos realizados dará cero como resultado. Por tanto es ideal como primer test, que nos forzará a crear la clase y el método.
Empezando con el código de producción
El test que he mostrado arriba no pasará obviamente. Primero nos reclama crear la clase LuhnValidator
.
Código mínimo para pasar el test
Helo aquí, en LuhnValidator.php:
Claro que esto no pasará el test pero, de momento, nos garantiza que hemos implementado lo mínimo necesario. Simplemente nos falta hacer que devuelva algo:
Y ahora el test pasa. Como habíamos visto en el capítulo anterior, nos basta con hacer pasar el test, de modo que posponemos la decisión sobre cómo debería pasar.
Buscando un nuevo ejemplo
Tenemos que volver al enunciado del problema. Lo primero que tenemos que hacer con la cadena entrante es invertirla, así que sería buena idea disponer de un test que de alguna manera pruebe que la hemos invertido.
Eso descarta números de tarjeta con todos los dígitos iguales o formando palíndromos: queremos algo que cambie al invertir su orden y que sea lo más sencillo posible.
Nuestro primer test nos forzó a implementar algo muy básico: el validador siempre devuelve true
, así que nos conviene que el siguiente ejemplo no sea válido, lo que nos forzará a implementar un mínimo del algoritmo. En realidad dos cosas:
- La inversión de los dígitos
- Una pequeña parte del algoritmo
Y esto es lo que tenemos:
- La inversión de los dígitos se puede forzar si el número de la tarjeta de crédito es asimétrico, por ejemplo, todos los dígitos son iguales menos uno en un extremo.
- Lo más sencillo es operar con los dígitos que tras la inversión queden en las posiciones impares, ya que sólo habría que sumarlos.
- Los dígitos que caigan en lugar par tendré que multiplicarlos por dos, y lo ideal, de momento, es que sean menores a cinco para no tener que hacer la suma de los dígitos resultantes.
- Los dígitos que sean cero no van a influir en el cálculo, por lo que puedo dejar en cero todos los dígitos que no necesite.
- Si a esto le añado que los dígitos finales del original acabarán en las primeras posiciones de la secuencia invertida…
Mi siguiente ejemplo será: “00000000001”.
Pero, un momento, ¿sería un número válido o no? Hemos quedado en que no puede serlo.
Lo cierto es que si aplicamos el algoritmo vemos que nuestro ejemplo no sería un número de tarjeta válido, por lo tanto, nuestro un test en el estado actual del código no pasaría, que es lo que queremos.
¿Y por qué este ejemplo en concreto y no otro?
Bien, mi interés es probar que invertimos el número de tarjeta introducido, así que voy a implementar un test que asegure que si sólo tomo en consideración el primer dígito tras la inversión éste no es cero y, por tanto, la cadena ha sido invertida. Si sólo tomo el primer dígito en consideración y los demás son ceros, el resultado de la suma total que necesitamos para valorar si la tarjeta es válida será igual a ese primer dígito.
El test falla, por lo que vamos a implementar.
Lo primero es invertir la cadena, cosa que en PHP se puede hacer así:
Pero esto no hace pasar el test, hay que implementar una mínima parte del algoritmo, que simplemente es ver el valor del primer dígito de la cadena invertida. Si no es 0
devolvemos false
porque sería inválido:
Hemos resuelto un primer problema, que es asegurarnos de que la cadena es invertida, y puede que no sea suficiente para demostrar todo lo que queremos. Pero lo interesante es que estamos avanzando en pequeños pasos, que es la intención del ejercicio.
Tal cual está el código en este momento no tendríamos por qué hacer mucho más. A primera vista tendríamos la opción de realizar un pequeño refactor:
Los tests deben forzar implementaciones
Una premisa de TDD es que cualquier código de producción sólo puede crearse como respuesta a un test que falle, que es como decir “a una característica no implementada todavía”. Si ahora escribimos un nuevo test que pase no podríamos modificar la implementación. No es que esté “prohibido”, es simplemente que en este momento si escribimos un nuevo test que pasa, no nos dice nada acerca de qué deberíamos implementar. Únicamente nos confirma lo que ya sabemos. En todo caso, este tipo de tests, que recién escrito ya pasa, nos puede servir como test de regresión: si algún día falla nos está indicando que algo ha alterado el algoritmo.
Lo que nos interesa ahora mismo es introducir alguna variación en los ejemplos que fuerce un cambio en la implementación a fin de darle cobertura. En concreto queremos sumar los dígitos en las posiciones impares.
Un enfoque sería añadir un nuevo dígito que caiga en posición impar al invertir la cadena, pero que cambie el resultado de la validación, de modo que el número de tarjeta sea válido. Eso quiere decir que los dígitos que introduzcamos deben sumar diez.
Un ejemplo es “00000000901”, pero también valdrían otros como “00000000604”, o “00000000208”.
Sin embargo, hay un test mucho más sencillo. Bastaría con poner 1 en la siguiente posición que nos interesa: “00000000100”. Si el algoritmo no tiene en cuenta esa posición el test fallará y para hacerlo pasar habrá que introducirla en el cálculo:
He aquí el test:
Después de asegurarme de que el test no pasa, vamos al código de producción.
El problema es que tengo dos opciones: por un lado, de momento me basta con controlar que cualquiera las dos posiciones tiene un dígito distinto de cero para pasar este test concreto.
En otras palabras: el test anterior no me ha forzado a implementar la suma, ya que podría cubrirlo con otro algoritmo. Por eso, ahora es cuando hago un test que pruebe que dos dígitos que sumen 10 hacen un número de tarjeta válido:
Y el test falla, porque mi algoritmo no suma los dígitos que han caído en posiciones impares. Lo que me interesa es empezar a sumarlos, y lo más fácil es acumular directamente las dos posiciones que me interesan. Como puedo ignorar el resto de dígitos al ser cero, no tengo más que ver si el resultado es divisible por 10.
En este ejemplo, que hace pasar el test, también vemos una oportunidad de refactor:
Buscando un algoritmo más general
Todavía no tenemos mucho como para forzarnos a escribir un algoritmo más general. Para eso necesitamos otro test, el cual debe fallar si queremos que nos sirva. El código nos estará indicado que necesitamos un algoritmo más general cuando podamos observar repeticiones o algún tipo de regularidad que podamos expresar de otra manera.
Nosotros vamos a proponer el siguiente test, que nos fuerza a considerar un tercer dígito en la validación. Ahora que ya he establecido que el algoritmo se basa en la suma, puedo volver a mi estrategia minimalista anterior. He aquí el ejemplo (a partir de ahora sólo voy a poner el test específico). Aprovecho para cambiar un poco la forma de denominar los tests para que sean más claros sobre lo que ocurre
Este test no pasa porque el algoritmo no tiene en cuenta todavía la quinta posición. Así que toca implementar algo:
Con este cambio, el test pasa. Y lo bueno es que ya empezamos a vislumbrar una posibilidad de refactor: el cálculo de la suma de los dígitos impares (que en la representación index0 de los arrays de PHP van en los lugares pares, para liarla más) empieza a ser difícil de leer, además de repetitivo. Podríamos empezar extrayéndolo a un método, para explicitarlo:
En principio, podríamos seguir haciendo baby steps hasta cubrir todas las posiciones impares una a una hasta el total de seis, añadiendo un test para cada posición, pero a estas alturas ya parece obvio que podemos generalizar el método de cálculo con un bucle. En este ejercicio mostraré los tests uno a uno y el código de producción final. En un caso de trabajo real el tamaño de los baby steps puede modularse en la medida en que tengamos seguridad de cuál es la implementación más obvia en cada momento.
He aquí los tests con la nueva nomenclatura. La clave es que cada uno debe hacerse teniendo en cuenta lo que tenemos hasta ese momento:
Y este es el código de producción, que es bastante feo pero pasa.
Es hora de hacer un refactor, manteniendo los tests en verde:
Y con esto, ya tenemos dos partes del algoritmo cubiertas. Tendremos que trabajar ahora con las posiciones pares.
Añadiendo prestaciones
Las posiciones pares nos plantean varios problemas:
- Primero tenemos que multiplicar cada dígito por dos.
- En caso de que el resultado sea mayor que diez debemos sumar los dígitos resultantes.
- Por ultimo, debemos sumar todo lo obtenido.
Y, para finalizar, sumaremos el resultado a la suma de los dígitos en posición impar, para decidir si el número de tarjeta es válido.
Sin embargo, podríamos desmenuzar el problema de una forma parecida a la anterior. Por una parte, necesitamos ignorar las posiciones impares, por lo que las pondremos a cero en nuestros casos de ejemplo. Utilizar como dígito para las pruebas el 1
parece una buena idea, ya que no nos obliga a implementar, de momento, la fase de reducir el resultado si es mayor que diez.
Así que haremos un test para empezar:
El test falla, implementemos algo para pasar el test:
Podemos seguir usando el mismo patrón que antes, moviendo el dígito a la siguiente posición par, un test cada vez. Para abreviar el ejemplo, voy a poner sólo los tests que he ido haciendo (te doy mi palabra de que he llegado hasta aquí haciendo baby steps):
Y este es el código que estos tests me han permitido escribir, una vez refactorizado:
Nos queda un requisito por implementar. Si el doble de los dígitos pares es mayor o igual que diez tenemos que sumar los dígitos de este producto y sumar éste en su lugar. Por tanto tendríamos que hacer test con algún ejemplo que fuerce esa situación y nos obligue a implementar.
Por ejemplo, el caso “00000000050” debería darnos un número no válido, según prueba este test:
Y falla porque el doble de cinco es 10, lo que fuerza al método a devolver true. Si sumamos los dígitos, el resultado sería 1, lo que hace fallar la validación y, por tanto, hace pasar nuestro test.
Es un código bastante feo, pero hace su trabajo.
Podríamos mejorarlo un poco, aprovechando que nos cubren los tests:
Tenemos 14 tests que prueban pequeñas partes de nuestro algoritmo. Es hora de asegurarnos de que todo funciona correctamente. Podríamos probar con el ejemplo propuesto en la kata. Actuaría como test de aceptación. De hecho, podría haberlo escrito desde el primer momento. Aquí está en ** LuhnValidatorAcceptanceTest.php**
¿Y sabes qué? El test pasa.
Y es interesante reflexionar sobre cómo el hecho de desarrollar con TDD y buenos tests de aceptación mejora el tiempo de desarrollo disminuyendo la necesidad de tratar con defectos del código que se nos podrían haber escapado de no disponer de tests.
Clean testing
Los tests también son código. así que igualmente tenemos que mantenerlos legibles y, por tanto, más capaces de comunicar lo que hace el software que respaldan.
Escribo bastantes tests en mi trabajo y tiendo a hacerlo en modo TDD siempre que puedo. Aún así, no estoy especialmente orgulloso de mis tests, pienso que tengo un gran espacio de mejora en esa área. Hay dos aspectos que me preocupan en particular:
- El primero es la constatación de que todavía se me escapan bastantes casos, sobre todo en la integración, que acaban dando lugar a defectos en el software. Sobre esto tengo que pensar más a fondo y adoptar mejores estrategias de diseño.
- El segundo tiene que ver con la calidad del código de los tests. Al fin y al cabo los tests siguen siendo código y debería aplicar los principios de diseño y las buenas prácticas para que sean sostenibles, legibles y útiles. Es más, en mi opinión, deberían ser legibles incluso para alguien que no conozca el lenguaje de programación.
De este segundo aspecto es de lo que trata este capítulo, en el que intento recoger algunas ideas con las que estoy trabajando últimamente.
Buena parte de esto se apoya en la charla Be solid my tests de mi compañera y manager en HolaLuz, Mavi Jiménez, que explica muy bien por qué y cómo nuestros tests tienen que ser SOLID, además de sólidos, y propone algunas técnicas prácticas para lograrlo.
Después de ver la charla, puedes seguir leyendo.
Naming de los test
Cada vez más, intento escribir tests cuyo nombre pueda leerse incluso sin saber programación, de modo que pueda entender qué es lo que se prueba y facilitar así la vida de mi yo del futuro y de compañeras y compañeros que tengan que enfrentarse a ese código.
Puede parecer que el nombre de los tests no tenga importancia. Sin embargo, es un aspecto clave del test en el que declaramos qué es lo que estamos probando en lenguaje de negocio. Así que, aunque comencemos con nombres tentativos, debemos crearlo con mucho cuidado y asegurarnos de que realmente dice lo que queremos demostrar con él.
Usar abstracciones
Por ejemplo, el siguiente test dice que la clase o método probados no debería aceptar cadenas de más de un cierto número de caracteres.
La razón por la que no pongo un número concreto es que ese número es un detalle que podría cambiar. En la formulación better hablo de un concepto en lugar de una concreción por lo que el enunciado del test se seguiría cumpliendo incluso si el máximo cambiase o fuese configurable.
En lo posible evito introducir detalles técnicos, aunque a veces es complicado, como en el caso anterior en el que menciono string
, pero prefiero escribir tests que no revelen la implementación.
Supongamos un repositorio de estudiantes en una aplicación de gestión educativa:
El caso Not so good tiene dos defectos principales:
- En primer lugar dice que se prueba el método
findAllByClass
, lo que asume que existe ese método. Si en algún momento cambiamos su nombre, el test empezará a mentir. - En segundo lugar, no dice qué se espera que suceda, lo cual debería ser la razón de ser del test. Tan sólo dice qué es lo que se prueba, tanto da que devuelva estudiantes, como chorizos o billetes de metro.
El caso Better ataca ambos problemas:
- No se ata a ninguna implementación particular del método.
- Dice lo que debería estar ocurriendo si el test pasa.
Los tests dicen lo que debería ocurrir
El uso de Should
como elemento del nombre del test merece su mención. De hecho me gustaría poder eliminar el prefijo test
, que es lo que hace que los test frameworks phpunit identifique los métodos que se tienen que ejecutar. Hay un par de soluciones:
Usar anotaciones:
Configurar un prefijo alternativo mediante un add-on para PHPUnit, que tampoco me convence porque la convención está muy establecida, de modo que pudiese escribir lo anterior sin anotaciones:
En este mismo sentido, me gusta la sintaxis de phpspec, que usa it
como prefijo, lo que es más natural que test
:
En cualquier caso, me gusta Should
como prefijo porque significa “debería”, indicando que la unidad probada debería hacer algo y, por tanto, si no lo hace es que está mal.
He visto alguna propuesta de utilizar Should
como sufijo en el nombre del TestCase
, cosa que es posible configurar:
En este sentido el principal obstáculo para adoptar estar últimas prácticas está en que las convenciones ya están muy establecidas y son difíciles de cambiar.
Los tests certifican que cumplimos las reglas de negocio
Otra estrategia de naming que me gusta tiene que ver con el cumplimiento de las reglas de negocio y de las invariantes.
Es decir, deberíamos tener un test que verifique que se cumplen las reglas de negocio que definen lo que nuestros objetos de dominio pueden y no pueden hacer, o los criterios que los hacen válidos.
Por ejemplo, este test para una factoría de Commercial
es bastante claro:
O este:
Por tanto, una buena forma de afrontar esto es redactar una checklist
de reglas de dominio e invariantes que nos guíe para decidir qué tenemos que testear en un momento dado 2
Eliminar los números mágicos
Uno de los defectos que me gustaría evitar en los tests es el de la aparente arbitrariedad de los valores de los ejemplos y su falta de significatividad. Quiero decir que, para alguien que lea el test puede resultar difícil comprender en una primera lectura por qué hemos elegido probar unos valores y no otros y qué significado tienen.
Existen técnicas para seleccionar los valores que usamos para nuestros tests, como pueden ser Equivalence Class Partitioning o Boundary Value Analysis, de las que hemos hablado en los primeros capítulos, que se utilizan para asegurarnos de que los tests cubren todos los escenarios posibles con el mínimo de pruebas, lo que resuelve el primer problema estableciendo una metodología.
Por ejemplo, Equivalence Class Partitioning es una técnica muy simple con la que agrupamos todos los casos posibles en clases de tal modo que todos los valores de una clase serán equivalentes entre sí, por lo que cualquiera de ellos es representativo de la clase en la que está categorizado. En consecuencia, podemos hacer un test para probar cada una de esas clases en lugar de intentar comprobar todos los valores posibles.
Imaginemos un sistema de tarificación basado en la edad, bastante habitual en museos y otras instituciones culturales, tal que el precio de una entrada cuesta:
- Hasta 7 años: 0€
- De 8 a 15: 6€
- De 16 a 25: 9€
- De 26 a 64: 12€
- A partir de 65: 10€
Esto es: el rango de todas las posibles edades se organiza en cinco clases o categorías y no tenemos más que escoger un ejemplo de cada una de ellas. Obviamente no hace falta probar todos los casos de 0 a 110 años uno por uno.
Así que podríamos hacer una tabla de casos como esta:
Clase | Valor | Precio |
---|---|---|
7 o menos | 5 | 0 |
8 - 15 | 10 | 6 |
16 - 25 | 18 | 9 |
26 - 64 | 30 | 12 |
65 o más | 70 | 10 |
Y, consecuentemente, podríamos hacer un test como este:
Vale, el test es correcto pero, ¿a que te deja mal sabor de boca?
Si pensamos un momento sobre los datos, es fácil ver que podríamos darle un nombre significativo a las clases:
Clase | Intervalo | Valor | Precio |
---|---|---|---|
CHILD | 7 o menos | 5 | 0 |
TEEN | 8 - 15 | 10 | 6 |
YOUNG | 16 - 25 | 18 | 9 |
ADULT | 26 - 64 | 30 | 12 |
ELDER | 65 o más | 70 | 10 |
Y utilizarlos en todos los elementos del test, aplicando lo que se denomina Lenguaje Ubicuo:
Bueno, esto ha mejorado bastante. Ahora el test es mucho más comunicativo. Pero creo que aún quedaría mejor si usamos constantes;
Obviamente podríamos utilizar un Data Provider, aunque eso no elimina lo anterior:
Extrae métodos, también en tests
Otra cosa que me molesta mucho en los tests es todo el código necesario para preparar el escenario que no comunica nada acerca del test mismo. Hace que el test sea difícil de leer e incluso saber qué está pasando realmente.
Al fin y al cabo, la estructura básica de un test es bien sencilla:
- Given: dado un escenario y unos datos iniciales
- When: cuando se ejercita el subject under test
- Then: entonces tenemos unos efectos
Esta estructura debería estar bien visible siempre, aunque hay situaciones (como el ejemplo anterior) en que no es muy explícita. De hecho, podríamos escribir el ejemplo así, para que se revele de una forma un poco más evidente.
Ahora bien, hay situaciones en las que los escenarios no son tan simples y requieren una preparación más elaborada, como cuando construimos test doubles (y eso que en este ejemplo ya hemos sacado la construcción del Test Double a una clase externa):
Aunque el test tampoco es complicado, la preparación del escenario incluyendo la carga de fixtures es un detalle de implementación que no ayuda necesariamente a la comprensión de lo que ocurre.
¿Por qué no hacerlo del siguiente modo?
Lo que hemos hecho ha sido extraer la preparación de los dobles de los repositorios a sus propios métodos, lo que nos permite escribir el test de una forma más concisa y clara.
Obviamente, en un proyecto real, es posible que pudiésemos extraer gran parte de la preparación a métodos setUp
, incluyendo la instanciación del servicio, o incluso parametrizar de algún modo los métodos prepare*
, pero creo que la idea queda clara en cuanto a que el cuerpo del test tenga líneas con un mismo nivel de abstracción 3.
Esperar excepciones
Hace tiempo decidí reducir en lo posible el uso de annotations en el código porque me genera cierta inseguridad. Por esa razón, en vez de marcar un test como que espera excepciones, hago la expectativa de forma explícita en el código:
El punto en contra, sobre el que no tengo una opinión del todo consolidada, es dónde poner esa expectativa. Con las anotaciones se sitúa al principio del test, pero la estructura Given->When->Then nos dice que debería estar al final:
En parte, me inclino más por la primera opción, precisamente por el carácter de excepcionalidad.
Métodos assert*
De vez en cuando, si necesito hacer una aserción que tiene alguna complejidad o necesita alguna preparación escribo un método con nombre assert*
para encapsularla. Por ejemplo, este método para comparar dos arrays independientemente del orden:
Eso me lleva a pensar que algunas triangulaciones en los tests podrían, igualmente, encapsularse en un único método:
Test doubles (1)
Estaba pensando en comenzar el capítulo con la manida metáfora de los test doubles como especialistas de cine, los que doblan a los actores en ciertas escenas, no necesariamente peligrosas. Pero cuando más vueltas le doy, menos claro tengo que sea un buen símil.
Al fin y al cabo, los test doubles son más bien figurantes que, a veces, tienen una o dos líneas de diálogo en la escena, mientras que nuestra unidad bajo test es la protagonista y la que tiene que llevar el peso de la actuación: no la podemos sustituir por otra. En cambio, de los test doubles preferimos que no hagan nada especial y que, si lo hacen, no se salgan del guión ni un milímetro.
Así pues, ¿qué son los test doubles?
El concepto de test double
Comencemos con la idea de test unitario. Un test unitario busca probar que una unidad de software se comporta de la manera deseada.
¿Qué es una unidad de software? Pues normalmente se trata de una función o bien de una clase en OOP a través del ejercicio de sus métodos públicos que son los que definen su comportamiento observable.
Sin embargo, muchas clases usarán otras como colaboradoras y esto introduce problemas: ¿qué parte del comportamiento observable de una clase corresponde a su propio código y qué parte corresponde al de sus colaboradores?
Para discriminar esto tenemos que mantener bajo control el comportamiento de esos colaboradores.
Es muy similar a cuando hacemos un experimento científico: para poder afirmar que cierto cambio se produce como consecuencia de un factor que estamos estudiando tenemos que controlar las demás variables que podrían estar afectando.
En algunos casos podríamos eliminarlas. Por ejemplo, hacer un experimento en una cámara de vacío para evitar el efecto de rozamiento del aire.
En otros casos no podemos hacer eso y tenemos que recurrir a otras técnicas, como puede ser aleatorizarlas, lo que nos dará un margen de error previsible en la medida del cambio que estamos observando, o controlarlas: saber exactamente en qué condiciones hacemos el experimento, al respecto de esas variables y repetirlo bajo distintos conjuntos de condiciones.
Pues bien, en el tema de los tests doubles la estrategia va por ahí. El objetivo es que los tengan un efecto nulo sobre el comportamiento de nuestra unidad bajo test o que podamos tenerlo controlado.
Por qué y para qué de los tests doubles
Utilizamos test doubles para evitar efectos no deseados en nuestros tests y asegurarnos que el comportamiento que estamos probando corresponde a la unidad de software:
- Controlar el comportamiento de un colaborador o dependencia y poder generar diversos escenarios en los que probar nuestra unidad de software.
- Que el test no se vea afectado por la disponibilidad o no de ciertos recursos. Por ejemplo, podemos suplantar un servicio externo con un doble que nos devuelva respuestas determinadas, e incluso simular que no está disponible para asegurarnos de que nuestro código sabe reaccionar a esa situación.
- Que los tests se ejecuten con mayor rapidez al simular componentes del sistema que, de otro modo, podrían tener bajo rendimiento, consumir muchos recursos, etc, como puede ser una base de datos, etc.
- Evitar trabajar con datos reales o de producción.
Test doubles y dónde encontrarlos
Hay varios tipos de test doubles aunque tendemos a llamarlos a todos mocks. Pero, siendo estrictos, los mocks son un tipo específico de test double, como demuestra este artículo:
Martin, Robert C.: The little mocker
Los vamos a agrupar en función de si acoplan, o no, el test a la implementación. Esto es: hay test doubles que esperan ser usados con un cierto patrón, lo cual se refleja en el test. Si ese patrón de uso cambia, el test fallará. Por eso decimos que provocan un acoplamiento del test a la implementación del SUT (subject under test) y eso hace que el test se vuelva frágil. Sobre esto volveremos más adelante.
Test doubles que no acoplan el test a la implementación
Dummies y Stubs no tienen expectativas sobre su uso y se limitan a participar en el comportamiento del SUT.
Dummy
Los dummies son dobles que creamos porque nos interesa su interfaz, no su comportamiento. Obviamente, nuestro SUT los llama pero no espera ninguna respuesta o su comportamiento no depende de ella. Por tanto, el dummy no debe implementar comportamiento. En pocas palabras, un dummy:
- Implementa una interfaz
El caso típico es poder instanciar nuestro objeto bajo test cuando necesita inyección de colaboradores en construcción.
En el ejemplo anterior, nuestro SUT (SomeService
) utiliza un Logger
, pero nosotros no vamos a mirar qué ha registrado.
Stub
En la mayor parte de los casos, un test double “dummy” no es suficiente: normalmente querremos que los colaboradores proporcionen respuestas a nuestro SUT. Por ejemplo, podríamos necesitar un servicio al que consultar la fecha y hora actuales, tal vez otro que nos diga si un usuario es válido o cualquier ejemplo que se te ocurra.
Para eso necesitamos otro tipo de test double que se denomina stub. Un stub es un objeto que:
- Implementa una interfaz
- Tiene un comportamiento programado: al llamar a uno de sus métodos devuelve una respuesta conocida
A continuación podemos ver un ejemplo de Stub. La clase ClockServiceStub
nos dará siempre la misma hora que le hayamos programado al instanciar un ejemplar de la misma. De este modo, siempre sabremos qué fecha u hora nos va a devolver, cosa que no ocurre con la clase real.
Test doubles acoplados
Mocks y Spies mantienen expectativas sobre cómo son usados por el SUT, lo que quiere decir que hacen aserciones sobre si son llamados de una manera específica.
El problema es que en caso de que cambie la implementación del SUT, el resultado del test podría cambiar aunque el comportamiento se mantenga, por el hecho de que no se cumplen las expectativas sobre el uso de los colaboradores.
Pongamos un ejemplo sencillo. Supongamos que el método bajo test hace dos llamadas a un Servicio que envía emails porque queremos notificar a dos destinatarios una determinada situación, así que hacemos un Double del servicio de Email que será llamado dos veces. Por tanto, el test espera dos llamadas y pasará siempre y cuando la implementación realice ambas llamadas.
Pero ahora, imaginemos que nuestro Servicio de email puede enviar a una lista de direcciones con una sola llamada. Si cambiamos la implementación para hacerlo así, nuestro test fallará, puesto que espera dos llamadas y sólo se realiza una. Sin embargo, el comportamiento del SUT sigue siendo correcto porque se envían dos emails.
Entonces, si el comportamiento es correcto, ¿por qué falla el test? Pues porque estos test doubles pueden generar un acoplamiento del test a la implementación del SUT a través de las expectativas que les programamos.
Spy
Un Spy es un Stub que, además, guarda la información sobre cómo ha sido llamado, de modo que podemos hacer aserciones acerca de esa información. Esto implica que el test se acopla a la implementación del SUT, introduciendo un factor de fragilidad que hay que tener en cuenta.
En resumen, un Spy:
- Implementa una interfaz
- Tiene un comportamiento programado
- Nos permite verificar en el test si ha sido usado de cierta manera
- Introduce fragilidad en el test
Lo que sigue es un ejemplo muy esquemático de lo que sería un Spy de una hipotética clase Mailer
. El Spy se limita a contar las veces que se llama al método send
, lo que nos permite hacer aserciones en un test.
Mock
El Mock es un Spy que espera ser usado por el SUT de una manera específica, como por ejemplo que se llame a un método con ciertos argumentos. Si esta expectativa no se cumple el test no pasa.
Al igual que un Stub, tiene una respuesta programada, o incluso varias. La diferencia es que al hacer que esperen una forma de uso concreta se genera una aserción implícita que reside en el Mock, no en el test.
En resumen, un mock:
- Implementa una interfaz
- Tiene un comportamiento programado
- Espera ser usado de una cierta manera
- Introduce fragilidad en el test
Los Mocks necesitan de una programación más compleja que los Spies, por lo que veremos en otra ocasión cómo generarlos. De momento, aquí tenemos un ejemplo usando Prophecy
Test doubles que son implementaciones alternativas
Fake
Un Fake es una implementación de la interfaz de una clase que se crea específicamente para ser utilizada en situaciones de test. Como tal tiene comportamiento de negocio y, en realidad, necesita sus propios tests para asegurarnos de que este es correcto.
Las razones para crear Fakes
son varias. Quizá la principal pueda ser la de realizar pruebas de integración sin las limitaciones de las implementaciones de producción, como puede ser el acceso a bases de datos y otros recursos remotos, que son lentos y pueden fallar, por ejemplo un repositorio implementado en memoria.
Alternativas para generar test doubles
Usar las clases reales
Hay muchas ocasiones en las que no tiene sentido utilizar test doubles. En su lugar utilizaremos las clases reales en los tests:
- Value Objects: los VO, por definición, no pueden tener side effects ni dependencias, así que al utilizarlos en los tests podemos tener la seguridad de que su efecto sobre el SUT es el esperado.
- DTO: no dejan de se objetos sin comportamiento, por lo que podemos usarlos sin problema.
- Requests, Commands, Events: los objetos que son mensajes y no contienen lógica no necesitan ser doblados.
- Cualquier otra clase que que no tenga side effects ni dependencias.
Un motivo razonable para usar doubles de este tipo de objetos es cuando resulta costoso instanciarlos.
Implementación directa
Fundamentalmente se trata de crear objetos implementando la interfaz deseada y con un comportamiento nulo o limitado a lo que necesitemos para usarlo como test double en cualquiera de sus tipos.
Si esta implementación incluye lógica de negocio estaríamos hablando de un Fake.
Self-shunt
El self-shunt es una técnica bastante curiosa que consiste en que el propio TestCase sea el test double haciendo que implemente la interfaz que necesitamos reproducir, lo que nos permite recoger información al estilo de un Spy.
Obviamente no es una técnica para usar de forma habitual, pero puede ser práctica en los primeros estadios de desarrollo, cuando no hemos creado todavía el colaborador y queremos ir haciéndonos una idea de su interfaz, o cuando ésta es muy simple y tiene sólo uno ó dos métodos.
He aquí la versión self-shunt del MailerSpy
.
A la larga, los self-shunts los iremos eliminando a medida que desarrollamos y que, consecuentemente, vamos refactorizando los tests.
Michael Feathers describe el self-shunt en este artículo. También es interesante echar un vistazo a este artículo que compara los tres métodos básicos de mocking, escrito por Paul Pagel.
Clases anónimas
Desde PHP 7 podemos utilizar clases anónimas. Esto es útil en los tests cuando necesitamos objetos sencillos que no se van a reutilizar fuera de ese test.
He aquí el anterior ejemplo de Mailer
con esta técnica:
Test doubles (2) Principios de diseño
Los principios de diseño están muy relacionados con el testing de tal forma que son tanto objetivo de diseño como herramienta para lograrlo.
En el capítulo anterior sobre test doubles, hicimos un repaso de los mismos y sus tipos. En este, reflexionaremos sobre la aplicación de principios de diseño y el uso de test doubles.
Las consecuencias sobre la escritura de tests se podrían sintetizar en dos beneficios principales:
- El código que sigue principios de diseño es más fácil de testear.
- La creación de test doubles se simplifica si las clases que se doblan siguen los principios de diseño.
Por otro lado, los buenos test tienden a llevarnos a mejores diseños, precisamente porque los mejores diseños nos facilitan la escritura de buenos tests. Es un círculo virtuoso.
En otras palabras: cuando es difícil poner bajo test un código es que tiene problemas de diseño.
Principios de diseño y doubles
Esta relación entre principio de diseño y test doubles se manifiesta de muchas maneras.
Como hemos visto, si el código bajo test sigue principios de diseño, será más fácil escribir los tests y, por el contrario, si vemos que montar la prueba resulta complicado nos está indicando que deberíamos cambiar el diseño del código bajo test.
Incluso si vemos que generar el test double es complicado, eso nos estaría indicando tanto que la clase colaboradora tiene también problemas o que la interacción entre unidad bajo test y colaborador no está bien planteada.
Única responsabilidad
Una clase que sólo tiene una razón para cambiar será más fácil de testear que una clase que tiene múltiples responsabilidades.
Al desarrollar los test doubles, este principio nos beneficia en el sentido de que las clases dobladas serán más sencillas de definir al tener que reproducir un sólo tipo de comportamiento.
Abierto para extensión, cerrado para modificación
El principio abierto/cerrado se refiere a que las clases estarán abiertas para que su comportamiento pueda ser modificado extendiéndolas sin cambiar su código.
Cuando creamos un test double lo que queremos conseguir es un objeto equivalente al real, pero con un comportamiento distinto. Por ello, en muchos casos extendemos por herencia la clase original para crear el test double o, mejor, implementamos su interfaz.
El principio entonces se aplica en el sentido de que un objeto o una clase no debería modificarse ya sea para testearla, ya sea para poder utilizarla como doble en un test.
Sustitución de Liskov
El principio de sustitución de Liskov está en la base de nuestra posibilidad de realizar test doubles.
Este principio dice que en una jerarquía de clases, las clases base y las derivadas deben ser intercambiables, sin tener que cambiar el código que las usa.
Cuando creamos test doubles extendemos clases o bien implementamos interfaces. El hecho de que se cumpla este principio es lo que permite que podamos introducir doubles sin tener que tocar el código de la clase bajo test.
Los test doubles, por tanto, deben cumplir este principio en lo que se refiere a la interfaz que interesa a nuestra unidad bajo test.
Segregación de interfaces
El principio de segregación de interfaces nos dice que una clase no debe depender de funcionalidad que no necesita. En otras palabras: las interfaces deben exponer sólo los métodos necesarios. Si fuese necesario, una clase implementará varias interfaces.
Cuanto mejor aplicado esté este principio más fácil será crear doubles y los tests serán menos complejos y más precisos ya que no tendremos que simular un montón de comportamientos.
Si una clase colaboradora tuviese veinte métodos, al crear su double necesitaríamos implementar los veinte, aunque estemos interesados tan sólo en dos. En ese caso, es preferible extraer esos dos métodos a una interfaz, a partir de la cual crear el double. Y eso nos podría llevar, como beneficio extra, a la posibilidad de extraer funcionalidad de la clase, repartiendo mejor las responsabilidades.
Inversión de dependencias
La inversión de dependencias nos dice que siempre debemos depender de abstracciones. De este modo cambiar la implementación que usamos se convierte en algo tan trivial como inyectar una diferente.
Si dependemos de una abstracción, y no hay nada más abstracto que una interfaz, es fácil generar implementaciones específicas para situaciones de test.
Ley de Demeter o del mínimo conocimiento
La ley de Demeter nos dice que los objetos no deben tener conocimiento de cómo funcionan otros objetos internamente.
Si un objeto A usa otro objeto B que, a su vez, utiliza internamente un tercer objeto C para responder a esa petición, entonces A no debe conocer nada de C.
En el caso de los test esto significa que por muchas dependencias que pueda tener una clase doblada, no las necesitamos para crear el double ya que sólo nos interesa su interfaz o su comportamiento, no su estructura. Y si las necesitásemos entonces es que tenemos un problema con el diseño.
DRY: Evita las repeticiones
Cualquier repetición en el código debería llevarnos a un refactor con el objetivo de reducirla o eliminarla.
Este principio no se aplica sólo al código probado, sino también a los propios tests y, por supuesto, a los test doubles.
El principio DRY no tiene que buscarse necesariamente por diseño previo, sino que podemos aplicarlo a medida que detectamos repeticiones en nuestro código. Por ejemplo, si observamos que estamos usando el mismo test double por tercera vez, puede ser el momento de moverlo a una propiedad del Test Case e inicializarlo en el setUp.
YAGNI: No lo vas a necesitar
El principio Yagni nos recuerda que no deberíamos desarrollar aquello que no necesitamos ahora.
Por lo tanto, nuestros tests doubles tienen que responder a la necesidad específica que tengamos en el momento de crearlo. Un test double puede comenzar siendo un simple dummy en un test para pasar a ser un mock
en otro.
Un ejemplo
A continuación veremos un pequeño ejemplo de código de test en el que se pueden observar varios principios de diseño en funcionamiento.
Suponemos un servicio SendNotificationService
que utiliza un Mailer
para enviar mensajes.
Veamos un par de posibles tests:
Tanto Mailer
como Message
son interfaces, de modo que el servicio no depende de ninguna implementación concreta. En el proyecto podríamos estar usando SwiftMailer
, por poner un ejemplo, pero nada nos impediría utilizar una implementación que ponga mensaje en Twitter, Slack, Telegram…, con tal de escribir un Adapter que cumpla la interfaz de Mailer
.
En el test la implementación concreta nos da igual. Nosotros sólo queremos que nuestro servicio intente enviar el mensaje y devuelva una excepción si no puede hacerlo.
Al aplicar la Inversión de Dependencias, es decir, al depender de interfaces, podemos preparar Stubs que reproduzcan el comportamiento que necesitamos sin mucho esfuerzo. En nuestro caso, que el mensaje se ha enviado correctamente (el Mailer devolvería true).
Por otra parte, está claro que Mailer
sólo tiene una responsabilidad, que es enviar mensajes, por lo que su comportamiento es sencillo de simular. Y también debería verse que se cumplen los principios Abierto/Cerrado y Liskov. El hecho de que sólo nos interese un método en la interfaz de Mailer
, nos dice que también aplicamos Segregación de Interfaces: nuestras implementaciones concretas podrían tener otros métodos, pero para esta situación sólo queremos uno.
Message
aquí actúa como dummy, no queremos que haga nada en particular, pero lo necesitamos para cumplir la interfaz.
He dejado las variables para que el test sea más fácil de leer, pero podrían eliminarse haciendo un inline variable dado que no tenemos que hacer nada con ellas. Los tests quedarían así:
Este pequeño refactor que acabamos de hacer contribuye a ejemplificar DRY dentro de lo limitado del ejemplo. Otra opción sería, sacar $message
a una propiedad del TestCase para poder reusarlo:
En resumidas cuentas, unos principios nos ayudan a cumplir otros.
En particular, al invertir las dependencias y depender sólo de una interfaz sencilla, nuestra clase bajo test no puede atarse a una implementación concreta, lo que facilita cumplir la Ley de Demeter, pues puede que en este momento de desarrollo ni siquiera hayamos decidido cuál va a ser el mecanismo de distribución de esas notificaciones.
Lo mismo ocurre respecto al principio YAGNI. Nuestro servicio no tiene que estar preparado para implementaciones que podrían usarse en un futuro, sólo tiene que saber usar un Mailer
. Aunque inicialmente estuviésemos pensando en usar el correo electrónico, dentro de un tiempo podríamos lanzarlas por Slack.
En resumen
Los principio de diseño no rigen sólo para el código de producción, sino que deberían impregnar todo el desarrollo, incluyendo los test y los test doubles cuando los necesitemos.
Test doubles 3: un proyecto desde cero
Veamos un caso más o menos típico que nos podríamos encontrar en cualquier empresa: un cliente existente contrata un producto dado.
A nosotros nos toca desarrollar el UseCase que encapsula esta feature y que luego será usado para exponer un endpoint o un frontal.
En una arquitectura mínimamente organizada el UseCase utilizará una serie de repositorios y servicios como colaboradores para llevar a cabo su lógica. Por ejemplo, nuestro UseCase podría realizar el siguiente proceso:
- Obtener los datos del Cliente que quiere contratar el Producto.
- Obtener los datos del Producto que quiere contratar.
- Verificar que se cumplen las condiciones en las que el Cliente puede contratar ese Producto.
- Obtener el precio personalizado.
- Generar el Contrato con todos los datos obtenidos.
- Notifica al resto de la aplicación que un Contrato ha sido creado.
Para desacoplar otras tareas que podrían ser necesarias, como enviar un email de confirmación, generar y almacenar un contrato en PDF que el Cliente pueda firmar, enviarlo, etc.
La pregunta es, ¿cómo se testea esto?
Y otra más, ¿es posible desarrollar todo esto mediante TDD?
Por supuesto, vamos a verlo.
Las piezas del puzzle
En un primer análisis podemos ver que vamos a necesitar unas cuantas piezas para hacer funcionar esta feature:
Al menos, tres entidades:
- Customer
- Product
- Contract
Y sus correspondientes repositorios:
- CustomerRepository
- ProductRepository
- ContractRepository
Al menos, un par de servicios:
- CalculatePrice
- EventBus
Al menos, un evento
- ContractWasCreated
Posiblemente algún Value Object:
- Price
El propio UseCase:
- CustomerContractsProduct
Algunas excepciones para describir problemas:
- ContractCouldNotBeCreatedException
- CustomerNotEligibleForProductException
- PriceNotFoundException
- CustomerNotFoundException
- ProductNotFoundException
Lo cierto es que nosotros no tenemos ninguna de esas piezas, así que, ¿por dónde empezar? Pero es que, aunque las tuviésemos, seguimos con el mismo problema: ser capaces de demostrar mediante tests que nuestro UseCase hace lo que debe hacer.
Test doubles en acción
Como ya sabemos, podemos testear a varios niveles:
- Aceptación, para demostrar que la feature funciona como se espera.
- Integración, para demostrar que los elementos del subsistema trabajan juntos correctamente.
- Unitario, para demostrar que cada pieza de software tiene el comportamiento esperado.
En el nivel Unitario, que es el que vamos a tratar aquí, queremos demostrar que nuestro UseCase es capaz de generar un Contrato correcto dados un Cliente y un Producto. Y, para ello, necesitamos mantener bajo control el comportamiento de los colaboradores que necesita usar. Es decir, necesitaremos usar doubles.
En este punto, una de las primeras preocupaciones de mucha gente es intentar preparar todos los colaboradores y, si es necesario, mockear sus comportamientos primero, antes de empezar con el UseCase.
Sin embargo, aquí vamos a plantearlo de otra manera.
Cómo hacer Test Driven Development de un Use Case
Seguro que ya sabes lo que voy a decir a continuación: empezamos con un test que falla.
El use case App\Application\CustomerContractsProduct
estará en la capa de Aplicación. Más exactamente en src/Application/CustomerContractsProduct.php. Ya que vamos a escribir un test unitario, pondremos éste en tests/Unit/Application/CustomerContractsProductTest.php. Y es ahí donde queremos empezar.
No empieces por el happy path
Posiblemente la forma más sencilla de empezar es la menos deseable a nivel de negocio: que el contrato no haya podido ser creado. Hay varios puntos en los que el proceso podría detenerse y basta con que falle uno para que sea así.
Si quisiésemos empezar por testear el happy path tendríamos que tener todo el UseCase montado, e inyectarle todos sus colaboradores doblados. Sin embargo, si comenzamos testeando por alguno de los sad paths previsibles podemos ir introduciendo esos doubles de forma controlada, a medida que son necesarios.
El primer sad path que vamos a testear es que el contrato no se puede generar porque no existe el cliente. En fin, por no existir, no existe el UseCase. Este sería mi primer test, y tiene unas cuantas cosas que necesitan ser explicadas:
Ante todo, este test refleja cosas que aún no sabemos y cosas que sí sabemos.
Por ejemplo, sabemos que para contratar un producto necesitamos pasar al UseCase información de un cliente y del producto que desea contratar. Dado que están representados como Entities en nuestro sistema, tienen una identidad que nosotros representaremos mediante un value object que implemente IdentityInterface
, la cual aún no hemos definido.
Vamos a usar dummies ya que, de momento, no necesitamos que hagan nada en especial. Como son value object podríamos no doblarlos, pero en este caso, aún no tenemos clases ProductId
o CustomerId
, y puede que nunca las lleguemos a tener, por lo que decidimos doblarlas a partir de su Interface. Sencillamente, posponemos decisiones.
Para ello, usamos el método provisto por phpunit, createMock()
, que nos devuelve un doble que implementa la interfaz deseada. Tal como están son dummies y cualquier mensaje que les enviemos nos daría null
como respuesta.
La anotación que hemos añadido es simplemente para facilitar la vida al IDE cuando sea necesario y ahorrarnos un poco de trabajo manual.
Como puedes ver, no estamos pasando ningún colaborador al UseCase, eso es porque, por el momento, no lo vamos a necesitar para hacer pasar este primer test. En realidad, este test nos va a permitir dos cosas:
- Generar todos los elementos que necesitamos para que pase.
- Aplazar la resolución del problema de qué debe pasar si no existe el Cliente.
Así que vamos a ejecutar el test y comprobar que falla, fundamentalmente debido a que no hay nada implementado. Así que vamos añadiendo, paso a paso, los elementos que nos pide, hasta que falle porque no se cumple la expectativa de obtener una excepción:
Para llegar hasta aquí, habremos creado lo siguiente:
src/Application/CustomerContractsProduct.php
src/Domain/Exception/ContractCouldNotBeCreatedException.php
src/Domain/IdentityInterface.php
Así que llega el momento de hacer pasar el test, devolviendo la respuesta más obvia: lanzar la excepción:
Y, con esto, el test pasa.
Llamando al primer colaborador
Para obtener un Customer
, necesitaremos un CustomerRepository
del cual extraerlo. Y antes de correr a montar una tabla en un sistema de base de datos, o en el ORM de turno, simplemente queremos una interfaz CustomerRepositoryInterface
. Se trata de posponer la decisión de la implementación concreta que vayamos a utilizar. Para los efectos de desarrollo del UseCase sólo necesitamos que el colaborador entregue una entidad Customer
cuando se le pida.
Volveremos sobre eso. Antes, tenemos que pensar un poco en cómo plantear el test.
Hemos dicho más arriba que íbamos a empezar por los sad paths del UseCase, de modo que nuestro siguiente test debería esperar una excepción, pero esta vez por un motivo distinto, como es que no dispongamos del Producto solicitado por su identidad.
Si ahora creásemos un nuevo test, nos encontraríamos que es exactamente igual al que ya tenemos pasando y no nos aporta ninguna información útil. ¿Qué podemos hacer?
Necesitamos algo más de granularidad para pode identificar lo que está pasando, al menos temporalmente. Una forma sencilla de hacerlo es esperar un mensaje especifico para cada tipo de problema dentro de la misma excepción. Como hemos visto en otros ejemplos a lo largo del libro, hacer expectativas sobre los mensajes de las excepciones no es una buena práctica, pero podemos recurrir a este truco temporalmente.
La otra alternativa es crear excepciones más precisas, pero el beneficio no siempre compensa el esfuerzo de poblar el espacio de nombres de la excepción con subclases tan específicas.
Por lo tanto, vamos a cambiar el test temporalmente, añadiendo esa expectativa y refactorizando al final:
Y, a continuación, implementamos el nuevo comportamiento demandado:
Ahora ya tenemos podemos crear un nuevo test y progresar en el desarrollo:
El nuevo test falla, así que para hacerlo pasar tendremos que hacer un poco más inteligente la implementación inflexible que tenemos actualmente. Eso supone resolver el problema planteado por el test anterior y así permitir que los dos tests pasen.
El segundo test, asume que hemos podido obtener un Customer del repositorio, así que no nos hace falta comprobar eso explícitamente. Para hacer pasar el primer teste necesitamos que el CustomerRepository no pueda entregarnos el Customer que le pedimos y lance una excepción.
En realidad, tendremos que cambiar un poco el escenario del test.
En una arquitectura orientada a dominio, las entidades se guardan y se obtienen de los repositorios. Como ya hemos señalado al principio de esta sección, esperamos disponer un CustomerRepository
al cual pedirle que nos proporcione una entidad Customer
que tenga la identidad almacenada en el parámetro $customerId
. Este será el primer colaborador que le pasaremos a nuestro UseCase.
Actualmente no tenemos ninguna implementación concreta de un CustomerRepository
, pero sí que podemos tener una idea clara de su interfaz. Por lo tanto, para el test usaremos un stub: un doble que devuelva una entidad Customer
o que lance una excepción y que será creado a partir de la interfaz CustomerRepositoryInterface
.
Así que redefinamos un poco el escenario del test:
Podemos ver muchos cambios aquí.
En primer lugar introducimos unos dobles. El primero es $customer
un dummy de la entidad Customer
. De momento, sólo necesitamos un objeto que cumpla su interfaz, no tenemos interés en que tenga comportamiento. Ni siquiera nos preocupan sus propiedades, ya que va a ser manejado como un todo, por lo que un doble ya nos va bien.
Por otro lado tenemos el stub $customerRepository
. Este es más interesante porque vamos a necesitar que tenga un comportamiento determinado: entregarnos un Customer
. No necesitamos nada más, simplemente queremos que al llamar al método getById
, nos devuelva ese objeto.
Finalmente, construimos el UseCase bajo test pasándole este primer colaborador.
Lanzamos el test para que falle y nos diga lo que tenemos que hacer. Básicamente:
Crear la entidad Customer
en src/Domain/Customer.php
Crear la interfaz CustomerRepositoryInterface en ****
Hasta que el test falla porque el mensaje recibido no es el esperado:
Lo primero que podemos observar ahora es que no hay nada en los mensajes del test que nos fuerce a introducir el colaborador, salvo el hecho de que el IDE nos indica que algo no cuadra al construir el UseCase.
Así que vamos a utilizarlo para que nos fuerce. Obviamente, en este punto podríamos empezar a implementar el constructor. Yo lo haré paso a paso en esta ocasión:
Al ejecutar el test, ya me dice que la propiedad customerRepository
no existe.
Y ahora fallan los dos tests, porque en el primero no estoy pasando el repositorio. Toca repensar el escenario de test.
Reorganizando el test
En el testing de este tipo de clases que se instancian pasándoles sus colaboradores mediante Inyección de Dependencias en el constructor y que no tienen estado, lo más cómodo es crearlos de una sola vez en el método setUp
del TestCase, o llamar desde ahí a un método privado que haga la construcción.
De este modo, los tests quedarán más limpios y tenemos un sólo lugar en el que tocar la instanciación, sobre todo mientras estamos en una fase de desarrollo aún muy inestable. Así que movemos los elementos comunes al método setUp
.
La nueva disposición es un poco más clara y al ejecutar el test volvemos al momento en que falla porque no se cumple la expectativa del mensaje.
Por otro lado, la configuración del stub $this->customerRepository
la hacemos en el test que la necesita y no en el constructor.
De momento, necesitamos hacer pasar este test antes de hacer más cambios, ya que aún no estamos en verde. Esta es una posible forma de hacerlo:
Sin embargo, tenemos un problema. El segundo test pasa, pero el primero deja de pasar.
Nuestro problema es que, tal y como hemos construido el double mediante la utilidad de phpunit, se va a generar automáticamente un objeto Customer
en cada invocación. En nuestro segundo test, no habríamos necesitado definirlo explícitamente, aunque creo que es preferible definirlo ahí.
Sin embargo, esto nos viene bien por lo siguiente. Que no podamos obtener una entidad de un repositorio porque no existe ninguna con el identificador que le pasamos suele ser una circunstancia digna de ser señalada con una Exception
. Así que vamos a hacer que el stub simule que lanza una excepción para indicar que no se encuentra el Cliente.
Así que reescribimos el test de la siguiente forma:
Y para conseguir que pase, modificamos así el código de producción:
Hemos dado unas pocas vueltas para llegar hasta aquí, pero creo que el viaje ha merecido la pena ya que prácticamente nos ha forzado a implementar un rediseño bastante elegante del UseCase.
Asegurando que el Cliente puede contratar el Producto
Si se han superado los tests existentes, toca verificar que el Cliente pueda contratar el Producto. Esta lógica no estará en el UseCase mismo, sino que debería estar en alguna Entidad o Servicio de Dominio. Por ejemplo, podría estar en la misma entidad Product
, que recibiría una entidad Customer
, evaluando alguno de sus datos para ver si lo puede contratar. Otra opción sería en un Servicio de Dominio, a la que recurriríamos si necesitamos más información procedente de otras fuentes.
Imagina, por ejemplo, que nuestros Productos estuviesen dirigidos a distintas edades, o disponibles sólo para residentes en ciertas provincias. Si esa información se puede obtener de la entidad Cliente con facilidad, posiblemente no necesitemos un servicio específico. Si se hace más complejo, podemos trasladarlo en un futuro.
En cualquier caso, lo único que necesitamos es que haya alguien que nos diga si el Cliente puede contratar el Producto. Nosotros vamos a poner esa lógica en Product
. O más bien, vamos a asumir que esta lógica está en Product
.
Pero antes de eso, necesitamos algo más básico. Necesitamos obtener el Producto, para lo cual necesitaremos el repositorio ProductRepository
, que aún no tenemos. Vamos a ir un poco más rápido que antes, ya que ahora entendemos el proceso.
Lo primero es cambiar un poco el test existente para aplicar la misma estrategia basada en excepciones.
El test fallará indicándonos que tenemos que implementar varias cosas, como hicimos anteriormente en relación con CustomerRepository
. Cuando lo arreglemos, debería fallar porque no se lanza la excepción con el mensaje esperado:
Cada vez que ejecutes el test, el sistema te señalará algo concreto que deberías hacer, por lo que no necesitas más que ir reaccionando a cada una de esas peticiones. No hay necesidad de llenar tu cabeza con tareas para hacer, sino concentrarte en esa necesidad concreta.
Esto nos lleva a crear lo siguiente:
La entidad Product
, en src/Domain/Product.php. Fíjate que no le voy a implementar ningún comportamiento todavía.
La interfaz de ProductRepositoryInterface
en src/Domain/ProductRepositoryInterface.php.
Y la excepción ProductNotFoundException
en src/Domain/Exception/ProductNotFoundException.php
Y, es ahora, cuando introducimos el nuevo test, que fallará:
Para implementar lo que pide el test, resolvemos el problema que controla el test anterior:
Ahora que el test pasa, es hora de testear el último sad path, que podría producirse si, por algún motivo, no se puede establecer un precio para el Producto que el Cliente quiere contratar.
Nosotros vamos a tener esa lógica en un servicio llamado CalculatePrice y su comportamiento va a ser devolver un objeto Money si lo puede calcular o lanzar una Excepción si no es así.
Tal y como hicimos antes, tenemos que preparar el escenario para inyectar el doble del servicio en el UseCase y definir su interfaz, así como la excepción asociada y un value object Money
.
Iremos añadiendo lo necesario según nos vayan indicando los fallos del test, hasta que el propio test falle por la razón adecuada:
Al llegar este punto, el test nos habrá forzado a crear lo siguiente:
El servicio lo hemos declarado como interfaz en src/Domain/CalculatePriceInterface.php
Aquí tenemos el value object Money, en src/Domain/Money.php
Y la excepción que puede ser lanzada por el servicio src/Domain/Exception/CanNotCalculatePriceException.php:
Con esto, estamos listos para implementar algo:
Con esto, tenemos testeados todos los* sad paths, por lo que podemos empezar a comprobar que se cumple el happy path. En realidad, nos falta un posible punto de fallo, pero lo veremos en un momento.
Cuando necesitamos hacer mocks
Un UseCase como el que estamos desarrollando puede ser modelado como un Command o una Query. En el primer caso, podemos testear a través de los cambios que produce en el estado del sistema, mientras que la Query puede ser testeada por la respuesta que devuelve.
Por desgracia, a veces no es sencillo acceder al estado del sistema y ese es nuestro caso. Al doblar las dependencias no se va a guardar “físicamente” el contrato generado para poder recuperarlo y ver si se ha creado correctamente. En los tests de integración y de aceptación esto sí ocurre, por lo que podemos comprobar el resultado de las acciones consultando directamente el estado del sistema tras ejecutar el test.
Para hacer este tipo de tests podemos recurrir a varias estrategias. Nosotros vamos a hacerlo mediante mocks, es decir, dobles que tienen expectativas cobre como son usados. Pero además, nos vamos a aprovechar de hacer uso del tipado, lo que nos ayudará a garantizar que ocurre exactamente lo que necesitamos que ocurra.
El happy path de nuestro UseCase implica que ocurra lo siguiente:
- Se crea un objeto contrato y se guarda en su repositorio
- Se publica mediante un evento determinado
El primer punto lo podemos testear de la siguiente manera:
Partimos de la base de que el objeto Contract
require tres elementos: Customer
, Product
y Money
(indica su precio) para poder ser instanciado. Si se puede crear consistentemente, y a estas alturas tenemos todo lo necesario, podemos tener la seguridad de que tenemos un Contract
correcto.
Creamos un mock de un ContractRepository
que espere una llamada a su método save
o store
, con un objeto Contract
. Esto es como decir que haremos una aserción que verifica que se guarda un objeto en el repositorio, aunque no esté expresado así en el test. Si el objeto está bien construido, y sabemos que lo está, podemos asumir que será guardado correctamente.
Así que vamos a hacer un test para probar que esto ocurre. Como todavía no tenemos ni Contract
y ContractRepository
tenemos que crearlos a medida que falla el test. Pero esta vez veremos que surgen problemas nuevos. Todavía no hemos implementado realmente nada sobre el uso del colaborador CalculatePrice
, de hecho, ni siquiera lo estábamos pasando en el test.
Así que, tenemos que ir resolviendo los distintos problemas, implementando la lógica paso a paso a medida que los errores nos lo indican.
Este es el test:
Lo primero que nos pedirá es crear la interfaz de ContractRepository
src/Domain/ContractRepositoryInterface.php
El siguiente error puede desconcertarnos un poco:
Pero tiene una explicación obvia. La implementación actual es inflexible en este punto y tenemos que cambiarla para poder seguir adelante:
A continuación, el test fallará porque no se cumple la expectativa de ContractRepository, nadie lo llama:
Por lo que tenemos que usarlo en la implementación:
Ahora bien, el type hinting me exige pasarle un objeto Contract
, que aún no hemos definido, por lo que tendremos que crearlo en src/Domain/Contract.php. De momento, nos bastará con esto:
Fíjate que me estoy “inventado” sobre la marcha las interfaces que necesito. Es decir, estoy escribiendo una implementación que no va a funcionar porque todavía no existen algunas de las cosas que necesito.
Por ejemplo, la forma de obtener una nueva identidad para el contrato:
Otra alternativa sería crear un IdentityService
, pero para simplificar lo he dejado en el propio repositorio.
En cualquier caso, podemos ejecutar el test para ver que falla y veremos que nos pide implementar el método nextId
. Pero como sólo tenemos la interfaz haremos dos cosas: añadirlo al mock, para que devuelva un resultado y el contrato se pueda crear:
Y eso nos obligará a añadirlo a la propia interfaz:
Y, con esto, tenemos el test pasando y el UseCase ya hace lo que dice.
Usar un spy hecho a mano
Bueno, aún no. Nos queda demostrar que se lanza el evento. Y necesitamos un test, por supuesto. Tenemos el mismo problema que antes, el EventBus que usemos no será el real, porque queremos controlar lo que pasa.
Podríamos usar la herramienta de generación de doubles que hemos estado usando hasta ahora, pero me gustaría mostrar que hay otras maneras de hacer las cosas. Lo que vamos a usar en un EventBus espía, pero creado a mano. Esto tiene algunas ventajas e inconvenientes.
El principal inconveniente es que tendremos que crear una implementación. En este ejemplo, lo haré de forma directa para ir más rápido, pero lo normal sería hacerlo también con TDD.
La ventaja es que podemos tener un mejor control de lo que necesitemos.
Pero primero, vamos a ver cómo sería el test:
Ejecutaremos el test para que falle y nos indique cosas:
Así que creamos la clase EventBusSpy
, en src/Infrastructure/EventBus/EventBusSpy.php.
Luego nos pedirá:
Con lo que crearemos esta primera iteración:
El siguiente fallo del test, ya requiere implementar algo:
Así que vamos a ello:
Lo primero que debería llamarte la atención es que el constructor tipamos la dependencia como EventBusInterface, para no acoplarnos a una concreta, pero no la tenemos definida. La crearemos en src/Application/EventBusInterface.php:
Y hacemos que EventBusSpy
la implemente:
Al lanzar los tests nos pedirá que creemos el método publish
, que hemos usado, pero no definido:
Y lo implementamos en el Spy:
La próxima ejecución del test nos dirá algo que ya sabemos: el evento ContractWasCreated no está definido, como tampoco lo está la interfaz EventInterface.
Así que vamos a ello:
src/Domain/EventInterface.php
src/Domain/Event/ContractWasCreated.php
Y, finalmente, pasan todos los tests y nuestro UseCase está listo.
Qué ha pasado aquí
Hemos empezado con nada más que unos requisitos y hemos acabado con un montón de interfaces y clases. La lógica del UseCase está implementada conforme a las especificaciones recibidas y, además, está testeada.
Obviamente, queda mucho que desarrollar, porque de los colaboradores sólo tenemos interfaces, no implementaciones.
Pero, y esto es importante, justamente hemos definido las interfaces de esos colaboradores desde las necesidades del UseCase. Hemos ido descubriendo lo que necesitábamos a partir de un diseño básico de los componentes que esperábamos usar. No tendremos que suponer qué métodos necesitará este repositorio, ni cómo será llamado. Ya lo hemos especificado.
El UseCase está preparado para trabajar con cualquier implementación que cumpla las interfaces definidas, siguiendo una arquitectura ports & adapters.
Qué hacer a continuación
Podemos trabajar en varios frentes:
Refactorizar los test: los tests con doubles suelen ser abigarrados y requieren un esfuerzo para que queden más inteligibles, extrayendo a métodos la preparación de los escenarios, etc.
En este ejemplo, lo que haría para empezar sería retirar las expectativas sobre mensajes de las excepciones, lo que daría como resultado un test más resistente y me permitiría más flexibilidad para hacer el refactor del Use Case.
En un siguiente paso, intentaría extraer la preparación de escenarios a métodos privados para quitarlos del cuerpo principal de cada test.
El resultado sería algo parecido a este:
Refactorizar el UseCase: la implementación es sencilla, pero podemos notar que es posible hacer algunas mejoras mientras mantenemos los tests en verde.
Un ejemplo:
Reorganizar el código: aunque hemos partido de la habitual estructura Domain, Application, Infrastructure, podríamos organizar mejor las cosas dentro de ellas. Parece claro que hay tres conceptos relacionados entre sí, pero que podemos separar: Customer, Contract, Product, más algunos que son comunes.
Con los test en verde y la ayuda del IDE, podemos mover sin riesgos todas las clases e interfaces creadas.
Resolver problemas con baby-steps
Una de las características de trabajar con metodología TDD es que nos forzamos a avanzar en pasos muy pequeños hacia la solución del problema. Estos pequeños pasos se conocen como baby-steps. De hecho, es una de la cosas que distinguen TDD de otras formas de escribir tests antes que el código.
A muchas personas que comienzan a utilizar TDD les preocupa la dimensión de estos pasos. Dicho de otra forma: ¿cómo de pequeños deberían ser?
No hay una respuesta definitiva. Kent Beck, en su libro Test Driven Development by Example explica que los baby-steps se han de ir adaptando a la experiencia y a la dificultad del problema. En resumen:
- Con la experiencia en TDD aprendemos a juzgar qué tamaño de paso nos viene bien y la seguridad adquirida nos permite avanzar en saltos más largos.
- Pero cuando un problema resulta complicado, una buena solución es tratar de avanzar en pasos más pequeños.
En este capítulo vamos a presentar un caso en el que el problema parece apuntar a una solución “todo de una vez” en un solo paso, cuando en realidad podemos avanzar con más seguridad con pasos más cortos.
Un problema sencillo, pero con intríngulis
El problema concreto era desarrollar un pequeño servicio capaz de clasificar documentos. Recibe el path de un archivo y unos metadatos y, a partir de esa información, decide dónde debe guardarse el documento, devolviendo una ruta al lugar en donde se almacenará de forma definitiva.
La verdad es que parece más difícil de lo que es. Como veremos, el servicio simplemente entrega un string
compuesto a partir de elementos extraídos de la información aportada. Inmediatamente nos damos cuenta de que tan solo hay que obtener los fragmentos necesarios, concatenarlos y devolver el resultado.
Supongamos un centro de enseñanza en el que queremos desarrollar una aplicación que permita al alumnado enviar documentos subiéndolos en una web preparada al efecto. La cuestión es que esos documentos se guarden automáticamente en un sistema de archivos con una estructura determinada. Aunque este sistema podría servir para muchas tareas, voy a simplificar el problema a un único caso:
- Los documentos relacionados con trabajos escolares se guardan en una ubicación específica por curso escolar, etapa, nivel educativo, tutoría, asignatura y alumno.
- Además, el nombre de archivo se cambiará para refleje un identificador de la tarea y una marca de tiempo.
Es decir, que si un alumno con número de matrícula 5433, matriculado en 5º C de Primaria sube el próximo lunes (12-03-2018) el archivo deberes-de-mates.pdf, éste deberá situarse en la ruta:
Aunque no forma parte de la tarea concreta que vamos a realizar, se supone que la información necesaria se obtiene a través del formulario de la web, de la identificación del usuario conectado, de datos almacenados en el repositorio de alumnos, y otros se obtienen en el momento.
Así, por ejemplo, el número de matrícula (que sería el ID del alumno) se obtiene de su login. Este dato nos permite obtener la entidad Student del repositorio correspondiente, a la cual podemos interrogar sobre su curso, tutoría y etapa.
Por supuesto, la fecha se obtiene del sistema y, con ella, es posible elaborar el fragmento de curso escolar.
La solución es bastante obvia, pero no adelantemos acontecimientos…
Un problema de TDD
La pregunta es: ¿cómo resolvemos este desarrollo utilizando TDD y que los tests sean útiles?
Me explico.
La interfaz de este tipo de servicios contiene un único método que devuelve el string
que necesitamos.
En una metodología de tests a posteriori podríamos simplemente testear el happy path y santas pascuas, aparte de algunas situaciones problemáticas como que no se encuentre el estudiante con ese ID o similares, en las que podríamos testear que se lance excepción.
Incluso con una metodología test antes que el código podríamos plantear lo mismo, y pensar que estamos haciendo TDD.
Y eso no sería TDD, o al menos no sería una forma muy útil de TDD.
Veámoslo en forma de código, el cual voy a simplificar evitando usar objetos de dominio para centrarme en el meollo de este caso.
Para llamar al servicio usaremos este objeto ClassifyDocumentRequest, con el que pasamos la información obtenida en el controlador al servicio:
El servicio se utilizaría más o menos así:
Al ejecutarlo, debería devolvernos una cadena de este estilo:
El enfoque de tests antes que el código pero que no es TDD
Veamos: ¿cuál sería un primer test para este problema?. La solución rápida sería algo más o menos como esto:
– No sé, Rick… Parece bueno.
– ¡Pues no lo es!
Veamos. Este test tiene algunos problemas aunque aparentemente es correcto. El principal de ellos es que nos obliga a implementar toda la funcionalidad de una sola tacada y resulta que tenemos que extraer ni más ni menos que nueve fragmentos de información para componer la ruta a partir de cinco datos: ¿no nos convendría ir por partes?
¿Qué pasa si en el futuro un cambio provoca que el test no pase? Pues que no tenemos forma de saber a través del test qué parte concreta está fallando. Este caso es bastante simple, pero imagínatelo en desarrollos con algoritmos más complejos.
Podríamos considerar éste como un test de aceptación: dada una petición válida, devuelve una ruta válida. Así que no vamos a tirar este test, sino que lo utilizaremos como lo que es: un test de aceptación que nos diga si hemos terminado de desarrollar la funcionalidad. Así que, mientras tanto, lo pongo en un archivo aparte y ya volveré a él más adelante.
Como test unitario, en un enfoque TDD, este test no nos sirve de mucho pues no nos dice por dónde empezar o qué hacer a continuación. Cada fragmento de la ruta tiene su propia lógica en tanto que se obtiene de una manera diferente y ocupa una posición específica.
¿Y cómo lo reflejamos en el proceso de TDD?
El enfoque TDD
En otro capítulo del libro mostramos una versión de la Luhn Code Kata, que nos viene muy bien precisamente para practicar cómo abordar estos problemas. Se trata de analizar la situación para entender cómo podemos dividir el problema en partes manejables, testeando cada una por separado.
TDD del curso escolar
El primer elemento que tenemos que generar es el curso escolar, el cual es una cadena formada por el año natural en que comienza y el año en que termina, separados por un guión. Por ejemplo:
Calcularlo es relativamente sencillo: dada una fecha, si el mes es mayor o igual que septiembre, el curso escolar comienza ese año. Si el mes es menor que septiembre el curso escolar ha comenzado el año anterior.
Así que empiezo creando un test que falle:
Este test fallará, en primer lugar porque no se encuentra la clase ClassifyDocument que aún no hemos creado. Sin embargo y de momento, me interesa resaltar cómo voy a probar la generación de cada fragmento.
Para empezar a trabajar, mi ruta sólo va a tener un elemento, por lo que no me preocupo de otra cosa que generarlo.
Para ello, voy resolviendo las cosas que me pide el resultado de pasar cada test. En primer lugar, crear la clase y, luego, el método.
Y, después, una implementación obvia para hacer que el test pase:
Bien. Nuestro siguiente paso será probar que generamos la ruta correcta para la fecha de subida del archivo y obligarnos a implementar algo para ello. Así que introducimos un cambio de fechas, de modo que podamos tener un nuevo test que falle. Ese test será el siguiente:
En este test he cambiado la fecha de entrega para el último trimestre del año, de modo que el curso escolar sea 2018-2019. Obviamente falla porque nuestra primera implementación es inflexible.
Sin embargo, el cálculo del curso escolar no es una responsabilidad que incumba a nuestra clase, ya que se ocupa únicamente de generar rutas. Lo ideal sería tener un servicio al que dándole una fecha nos devuelva el curso escolar. Esta funcionalidad la necesitaremos seguramente en un montón de sitios, así que vamos a suponer que lo tenemos aunque no esté todavía implementado, por lo que introduciremos un stub que nos haga el trabajo.
Primero creamos una interfaz:
Solo para que conste: personalmente no soy partidario de añadir el sufijo Interface
. La interfaz representa el concepto, y las implementaciones serían formas concretas, cuyo nombre podría indicarnos su tipo concreto. Podría ocurrir que sólo tiene sentido una implementación concreta de ese servicio, con lo cual desaparecería la interfaz, la implementación sería genérica y, como bonus, no tendría que cambiar nada más.
Un stub es un test double que tiene una respuesta programada a ciertos mensajes que le enviamos, así que lo introducimos en nuestro test y veremos a qué nos lleva:
Como este test falla, podemos empezar a implementar lo necesario:
Una vez implementado esto vemos que pasan dos cosas:
- El nuevo test pasa.
- El test que ya existía no pasa porque no contempla el hecho de haber introducido el servicio CalculateSchoolYear.
Así que arreglamos eso para que pase, cuidando de ajustar los nuevos valores del stub.
Estupendo. Ahora podemos observar varias cosas.
- La construcción del servicio bajo test ClassifyDocument estaría mejor en un único lugar.
- Hay varios valores que se utilizan repetidas veces, por lo que sería buena idea unificarlos de algún modo, lo que nos daría mayor seguridad de que estamos testeando lo que queremos.
Así que vamos a arreglar eso antes de nada, para que sea más fácil seguir adelante con el desarrollo. Para eso tenemos que mantener los tests en verde, señal de que no hemos roto nada.
Ahora está un poquito mejor, así que: ¡sigamos adelante!
TDD de la Etapa Educativa
En el sistema educativo español hay varias etapas educativas, como son Infantil, Primaria, Secundaria o Bachillerato. Cada etapa se divide, a su vez, en niveles educativos, que es lo que solemos llamar “cursos”. Lo cierto es que para expresar con propiedad el curso en el que se encuentra un estudiante concreto siempre tendríamos que decir a qué etapa pertenece, como 3º de Primaria, 4º de Secundaria, 1º de Infantil, etc.
Pero no estamos aquí para diseñar aplicaciones educativas sino para explicar TDD. Sin embargo, la parrafada anterior es necesaria para entender que ahora nos toca generar el fragmento de ruta que representa la etapa educativa, y esa información la podremos obtener sabiendo el curso en el que se encuentre matriculado nuestro estudiante, Por tanto, necesitaremos obtener un objeto Student
al cual preguntarle todos esos datos.
En nuestro diseño, seguramente Student sea un agregado, una Entidad que incluye diversas entidades y value objects relacionados con una determinada identidad. Para obtener nuestro estudiante concreto preguntaremos a un repositorio de estudiantes por aquél cuya identidad viene especificada en la request. Para simplificar, vamos a imaginar que nuestra clase Student es más o menos así (sí, soy consciente de que simplifico mucho):
Además, contamos con un repositorio de Student que tiene esta interfaz, la cual me servirá para generar un nuevo stub:
Bien, pues dando por supuesto que disponemos de estas clases, vamos a crear un test que falle, asumiendo que nuestro servicio va a necesitar el StudentRepository para obtener un objeto Student a partir de su Id.
El test va a quedar más o menos así:
Por supuesto, no va a pasar.
Ahora tenemos la habitual disyuntiva de hacer la implementación más simple y obvia que es devolver el valor que esperamos y escribir un nuevo test que nos obligue a implementar una solución general; o bien ir directamente a esa solución general.
En esta ocasión me voy a decantar por la primera opción porque, como se puede apreciar, se van a romper los tests anteriores, por lo que prefiero solucionar eso antes. Pero para ello, necesito que este test pase.
Ahí lo tenemos: nuestro test actual pasa, pero rompemos los anteriores. Así que voy a arreglarlos:
¿Ha molado o no ha molado?
Fíjate con esta técnica obtengo exactamente el fragmento de la ruta que quiero, sin tener que prestar atención al resto de la cadena que me devuelve.
Esto es lo que quería señalar, ahora nuestros tests están mirando sólo una parte del algoritmo cada vez. Si en el futuro se rompe alguno, sabré exactamente qué parte ha sido afectada.
Sigamos:
Nuestra última implementación inflexible necesita un masaje… quiero decir: necesita un nuevo test que, fallando, nos fuerce a implementar una solución más general:
El test falla y para hacerlo pasar necesitamos obtener de algún sitio la etapa educativa. Como hemos visto antes, podemos averiguarla preguntando a Student el cual, a su vez, podemos obtener pidiéndolo al StudentRepository mediante su Id, el cual conocemos.
Para ello, nos vamos al método setUp
, generamos y montamos el stub.
El stub por sí mismo no va hacer que pasemos el test. Necesitaremos implementar algo, pero antes me gustaría llamar tu atención sobre un detalle.
En el setUp
programo los stubs para que devuelva algunos valores específicos para los datos por defecto. Para probar con otros valores, no tengo más que programar en los métodos de test concretos los nuevos, como se puede ver en el ejemplo anterior.
De este modo, intento tener siempre un caso por defecto y generar otros casos a medida que los necesite. Lo cual quiere decir que en el test, tengo que programar una nueva respuesta en el stub, que devuelva un Student que sí nos haga cumplir los requisitos del test:
El test sigue fallando porque realmente no hemos implementado nada todavía, lo que no debería darnos muchos problemas:
Con esto, el test ya pasa y podemos irnos al siguiente fragmento de la ruta:
TDD del nivel educativo
La siguiente parte de la ruta es el nivel educativo. A partir de ahora vamos a ir más rápido, en parte porque vamos a hacer pasos un poco más grandes ya que los elementos que vienen son bastante sencillos.
Como siempre, con los tests en verde podríamos ver si tenemos oportunidades de refactorizar. De momento, no hay nada que me llame la atención, así que voy a pasar al siguiente test que falle:
Y, a continuación, la implementación para que pase el test que, gracias a lo que hicimos para la etapa educativa, ahora es bastante trivial:
Y ya tenemos el test pasando.
Ahora vemos que lo que queda un poco feo es la concatenación de los fragmentos con el separador de directorios. La verdad es que podemos hacerlo algo mejor y más bonito. Como tenemos los tests pasando, podemos trabajar con tranquilidad:
Con esto, no sólo sigue pasando el test, sino que es mucho más elegante y clara la forma de montar la URL.
TDD del grupo
Lo mismo que hemos dicho antes se aplica a continuación. Primero, test que falle al canto:
Test en rojo: a implementar se ha dicho, pero ahora ya es muy fácil:
TDD el resto de la ruta
Para nuestro ejemplo no he querido complicarme mucho, por lo que nos vamos a encontrar con que el resto de elementos de la ruta son fáciles de implementar y la forma de hacerlo ahora es bastante evidente.
Por esa razón, no voy a alargar más el capítulo y voy a pasar directamente al resultado final y las conclusiones.
En todo caso, para llegar al final no tenemos más que seguir con nuestro ciclo de siempre: test que falla, implementar hasta conseguir que pase, refactorizar y seguir. El punto final lo tendremos cuando el test de aceptación pase.
Evidentemente, el test de aceptación tal y como estaba escrito originalmente no nos va a servir porque en ese momento no teníamos en cuenta que íbamos a necesitar colaboradores, por lo que tendremos que modificarlo e incluirlos.
Ese test nos va a quedar más o menos así:
Conclusiones
Lo que he tratado de mostrar en este ejercicio es que TDD no consiste sólo en hacer tests antes de escribir el código.
Para que podamos hablar de TDD, los tests tienen que generarnos la necesidad de implementar, impulsando el desarrollo de cada característica de nuestro software.
Si haces TDD a ritmo de baby steps de manera que cada paso te fuerce a implementar la solución más simple, primero, y a refactorizar en busca de un buen diseño, lo cierto es que puedes tener que hacer bastantes tests:
- El primero para hacer una implementación “tonta” e inflexible: el típico devolver exactamente lo que esperas.
- El segundo para provocar que la implementación inflexible falle y forzarte a buscar una solución sencilla, aunque no sea del todo genérica.
- Un tercer test que ponga en cuestión la solución anterior y nos lleve a una más genérica. En este punto seguramente ya podríamos empezar a refactorizar para mejorar el diseño
- Además, podrían haber aparecido casos límite que no pueden tratarse con la solución general y tendrían un test específico.
En cualquier caso esto va a depender del tamaño de los baby steps que decidamos tomar, que dependen de nuestra experiencia, del conocimiento que tengamos de la tarea, etc.
Ahora, en la práctica creo que es perfectamente válido desechar algunos de estos tests si no aportan información extra con el objetivo de aligerar nuestras Suites de Tests. Puedes contemplarlo como un caso de duplicación, y ya sabemos que la duplicación innecesaria hay que eliminarla. Se trataría de dejar los tests que nos podrían funcionar como tests de regresión.
Podemos ver TDD como una metodología iterativa: empezamos con unos requerimientos muy sencillos: que exista una clase, que tenga cierto método, que devuelva un cierto resultado… Cada vez, un nuevo requisito, intentando no ver más allá del problema actual.
En algún momento esto podría alterar el resultado que necesitamos que devuelva nuestra unidad y, por tanto, podríamos vernos en la necesidad de modificar el test. Pero esto ocurre porque nos forzamos a no adelantar acontecimientos, incluso aunque nosotros “sabemos” que nuestra clase va a necesitar colaboradores o que va a cambiar la forma en que devuelve los resultados. Pero en TDD queremos que esas cosas nos las digan los tests.
Usar el code coverage para mejorar los tests
El code coverage es una métrica que conviene coger con pinzas y examinar con mucho cuidado.
En principio, el code coverage nos indica las líneas de código cubiertas por la ejecución de tests y lo deseable sería, sobre el papel, acercarnos lo más posible al 100%.
Por desgracia, una medida alta de coverage no garantiza que los tests prueben adecuadamente el comportamiento de las unidades de software. En ese sentido, es muy fácil incluso falsear la métrica con tests que ejerciten las líneas de código pero que realmente no demuestren gran cosa sobre su comportamiento.
Además, ni siquiera una cobertura del 100% garantiza realmente que todos los casos han sido probados.
Por otra parte, la cobertura total es bastante difícil de conseguir en una base de código que no cuente todavía con tests. Entre otras cosas, porque tampoco es un objetivo deseable, ya que hay muchas partes de una aplicación que ni siquiera merece la pena considerar testear porque el código es trivial o porque el único tipo de test posible resultaría muy frágil.
En realidad, creo que sólo desarrollando con TDD se conseguiría una cobertura completa, pero como consecuencia de la metodología y no porque nos la planteemos como objetivo. Y esto es así porque, por definición, el código de producción sólo se escribe con el objetivo de hacer pasar un test.
En todo caso, y a pesar de estas observaciones, mejorar el nivel de cobertura de tests de una base de código es un objetivo que merece la pena.
Además, el análisis del code coverage es una buena herramienta para ayudarnos a escribir mejores tests, especialmente en situaciones de refactoring de código legacy.
Así que he extraído algún material de un viejo artículo del blog y lo desarrollo aquí un poco mejor.
Code coverage y refactoring
Además de lo que podríamos denominar análisis “a ojímetro” y del uso del depurador, las herramientas de code coverage nos pueden ayudar mucho en el refactoring al permitirnos detectar aquellas partes del código cuyo comportamiento no hayamos descrito todavía.
phpunit nos proporciona todo lo necesario, pero primero tendremos que condigurarlo:
Preparar el entorno para disponer de análisis de Code Coverage
Por una parte, vamos a crear un archivo de configuración de phpunit. Podemos hacerlo mediante el siguiente comando en shell en la raíz del proyecto:
Este comando es interactivo y nos pedirá confirmar algunos valores según nuestro proyecto:
Bootstrap script se ejecutará antes de los tests y nos permitiría, como en el ejemplo, lanzar el autoloader, para disponer de la autocarga de clases a través del namespace o como lo hayamos configurado en nuestro composer.json.
Tests directory indica dónde se encuentran los tests.
Source directory es la ubicación del código.
Lo siguiente será modificar un poco el archivo resultante ya que, por defecto, activa el uso de la anotación @covers
que se usa para indicar explícitamente el código del que queremos obtener informe de cobertura. Por tanto, podremos el atributo forceCoversAnnotion
en false
, para así poder aplicar el análisis a todo el código, poniendo en whitelist nuestra carpeta src
:
Con esto, podremos ejecutar phpunit
con el informe de coverage que más nos convenga:
La línea anterior generará un informe de cobertura en HTML creando la carpeta coverage
si no existe. Abriendo el index.html
en un navegador podremos acceder a él.
En PHPStorm podemos crear una configuración para test indicando simplemente que use el archivo de configuración alternativo que acabamos de crear. Ejecutando los tests con coverage, el propio IDE nos mostrará qué líneas están cubiertas y cuántas no, usando colores verdes y rojo respectivamente. Además, nos mostrará el número de veces que se ejecuta cada línea.
Cómo usar el Code Coverage para crear tests de caracterización
Líneas rojas por las que hay que pasar
La forma más obvia de utilizar Code Coverage para crear tests de caracterización es detectar líneas de código por las que no pasa el flujo de ejecución cuando lanzamos los test.
Las líneas marcadas en rojo nos indican que por ahí no hemos pasado, en consecuencia, necesitamos crear un test que requiera su ejecución para pasar.
El número de pases también cuenta
En algunos casos, el hecho de que la línea se haya ejecutado no garantiza que el caso esté bien cubierto. Para eso nos fijamos en el número de hits, como los denomina PHPStorm, que no es más que el número de tests cuya ejecución pasa por esa línea.
En el caso de una línea o bloque cuya ejecución depende de una combinación de condiciones, tenemos que comprobar que el número de hits es, al menos, igual que el número de posibles resultados de la expresión condicional.
Veámoslo más en detalle:
Condicion1 AND Condicion2: para ejecutar un bloque controlado por esta condicional se tiene que dar un caso en el que se cumplen ambas partes. Por otro lado, también tendríamos que probar el caso de que no se cumple toda la condicional.
Por tanto, para garantizar que esté bien cubierto, y sabiendo que el bloque ha de ejecutarse como mínimo una vez, el número de hits de la expresión condicional ha de ser mayor o igual a dos: en un caso se cumple la expresión condicional y en otro no se cumple. En la siguiente tabla se pueden ver todos los casos:
Condición 1 | Condición 2 | Resultado | ¿Se ejecuta? |
---|---|---|---|
true | true | true | Sí |
true | false | false | No |
false | true | false | No |
false | false | false | No |
Condicion1 OR Condicion2: en este caso el bloque bajo la expresión condicional se ejecutará si al menos una de las dos condiciones se cumple. Para cubrirlo completamente, necesitamos que la expresión se ejecute cuatro veces y el bloque que controla, lo haga al menos tres veces.
Condición 1 | Condición 2 | Resultado | ¿Se ejecuta? |
---|---|---|---|
true | true | true | Sí |
true | false | true | Sí |
false | true | true | Sí |
false | false | false | No |
Condición negada las expresiones condicionales de negación deberían ejecutarse dos veces y el bloque que controlan al menos una vez.
Condición 1 | Resultado | ¿Se ejecuta? |
---|---|---|
true | false | No |
false | true | Sí |
A partir de aquí, las expresiones condicionales complejas requerirán un número de hits acorde con sus posibles estados. Cubrir sólo dos casos (la expresión se cumple o no se cumple) puede ocultarnos información, particularmente en el caso de expresiones que incluyan operadores OR.
Para finalizar
El code coverage es una medida que no debería obsesionarnos. Si hacemos tests, el code coverage crecerá. Y si hacemos TDD, el cede coverage vendrá por si solo.
Pero sí es recomendable utilizarlo como herramienta cuando estamos refactorizando. Bien utilizada, nos indica qué codigo necesita ser cubierto y descrito con tests.
Testeando lo impredecible
¿Cómo testear lo que no podemos predecir? En muchos sentidos los tests se basan en que el comportamiento del código es predecible: si hacemos ciertas operaciones con ciertos datos podemos esperar ciertos resultados y no otros. Pero esto no siempre se cumple, a veces tenemos que testear algo que no sabemos qué será.
Generando contraseñas para humanos
Hace algunos años, cuando trabajaba en un colegio, tenía que crear cuentas para los usuarios de varias aplicaciones. Una queja habitual era la dificultad de recordar o simplemente transcribir las contraseñas que se asignaban y todo el mundo quería cambiarlas por alguna más fácil de memorizar. Y por una buena razón.
Para explicarla, desempolvaré aquí alguno de mis libros de Psicología General, en concreto el artículo clásico de George A. Miller sobre el mágico número siete (más o menos dos.
Resumiendo mucho: nuestro cerebro puede procesar una media de siete unidades de información a la vez. Por ejemplo, para una persona adulta es posible recordar seis ó siete letras al azar sin cometer errores. Si aumentamos el número de letras por encima de ese límite, el recuerdo empeora.
Ahora bien, si podemos agrupar esas letras en sílabas de modo que se mantenga el límite de seis ó siete unidades de información, también llamadas chunks, podríamos llegar a recordar 21 letras suponiendo sílabas de tres letras. Y si esas letras pueden formar palabras, son más memorables todavía.
Por ejemplo, cuando memorizamos números de teléfono lo hacemos organizándolos en grupos de dos ó tres. De este modo, un número que tiene nueve dígitos, se reduce a tres unidades de información, mucho más fácil de recordar.
Así que, volviendo al caso de las contraseñas, en lugar de tener que recordar una serie de más de ocho símbolos al azar, lo ideal sería poder agruparlos en algún tipo de unidad de orden superior cuyo recuerdo sea más económico cognitivamente hablando.
Una palabra es muchísimo más fácil de recordar pues agrupa esos ocho o más caracteres es una única unidad, sin embargo la descartamos como contraseña porque tiene el problema de ser fácil de adivinar.
En cambio, podemos combinar letras para formar palabras que sin tener significado sean pronunciables (balotri, carbinacho…), lo que permite a nuestro cerebro tratarlas como una unidad. Al no estar en un diccionario son más difíciles de adivinar que las palabras reales.
Así que en su día, investigué un poco y encontré algunos generadores de contraseñas legibles o pronunciables por humanos que se basaban en este razonamiento.
La idea básica es que en lugar de formar las contraseñas mezclando caracteres al azar, lo que hacemos es mezclar sílabas, creando palabras posibles en el idioma aunque no tengan ningún significado. De este modo las contraseñas mantienen un compromiso aceptable entre ser fáciles de recordar pero difíciles de adivinar.
Con esta idea en la cabeza escribí un generador de contraseñas legibles que me resultó bastante útil durante algunos años. Recientemente lo hemos rescatado, con algunas modificaciones, para introducirlo de tapadillo en un proyecto del trabajo en el que justamente necesitábamos proporcionar credenciales a un conjunto de usuarios.
El único problema es que, de vez en cuando, aparece alguna contraseña que recuerda vagamente a palabras malsonantes, pero qué le vamos a hacer.
En cualquier caso, nuestro Readable Password Generator plantea unos cuantos problemas interesantes y en este artículo vamos a reescribirlo usando TDD para aprender a resolverlos.
Pero antes, un poco más de paliza teórica para acabar de situarnos… porque sigues ahí, ¿verdad?
Determinismo y predictibilidad
Cuando un algoritmo ofrece unos resultados que podemos predecir a partir de los datos que le proporcionamos decimos que es determinista. Por ejemplo, si multiplicamos 15 * 3 el resultado será siempre 45. Como vimos en un artículo anterior, si al repetir el algoritmo con los mismos datos siempre obtenemos los mismos resultados, también decimos que es idempotente.
Hay operaciones, sin embargo, que no tienen el mismo resultado cada vez. Por ejemplo: si consultamos la hora del sistema la lectura será siempre diferente, asumiendo que la consulta se hace con la precisión necesaria. Por tanto, si un algoritmo depende de la hora del sistema no podríamos predecir su resultado a no ser que supiésemos exactamente el momento en que se consulta.
Una manera más formal de expresar esto mismo es decir que el algoritmo en cuestión depende de un estado global, como puede ser el tiempo transcurrido en el mundo, o al menos, en el equipo sobre el que se ejecuta.
La hora del sistema no es de naturaleza aleatoria, pero normalmente no podemos saber qué valor vamos a encontrar cuando la consultemos. Estaremos en condiciones de conocer algunas de sus características, por ejemplo que ese valor siempre será mayor que el que tenía al iniciarse nuestro algoritmo, pero poco más.
Otro ejemplo es el siguiente. Si nuestro algoritmo necesita valores al azar necesitamos recurrir a un generador de números aleatorios, o al menos pseudoaleatorios. Un ordenador es una máquina determinista pero tenemos algoritmos capaces de generar valores que sin ser estrictamente aleatorios son suficientemente difíciles de predecir como para funcionar como tales.
Así que, cuando tenemos que escribir código que necesita tratar con el tiempo o el azar, ¿cómo podremos testearlo?
Para responder a esta pregunta, tendremos que recurrir al principio de única responsabilidad, descargando a nuestro generador de contraseñas de la tarea de obtener valores aleatorios.
Además, introduciremos una metodología de test basada en propiedades, como la expuesta en esta charla de Pedro Santos, que nos permita testear aquello que no podemos predecir, pero que podemos describir.
Vamos allá.
A ver qué sale
El primer problema a la hora de testear un método que devuelve valores generados de forma aleatoria es justamente no tener ni idea de lo que va a salir.
Una posibilidad es centrarnos en propiedades que describan el resultado que esperamos, las cuales podríamos enunciar como reglas de negocio de nuestro generador de contraseñas.
Por ejemplo:
- La contraseña es de tipo
string
- Tiene una longitud de al menos 6 caracteres (este límite es arbitrario)
- Es un string de al menos 6 símbolos al azar
- Es memorizable para humanos
- Debe incluir al menos un número
- Debe incluir al menos un símbolo no alfanumérico
Así que vayamos paso a paso:
Esto nos permite crear una primera implementación simple para poder empezar:
El return type en generate
hace que el test sea redundante porque nos obliga a devolver el tipo correcto sí o sí. Por tanto lo podremos eliminar aunque nos ha permitido escribir la primera implementación.
Lo que hay en un nombre
Además de tener un tipo string, esperamos que tenga una longitud mínima. Este sería nuestro nuevo test:
De nuevo, podemos hacer una implementación mínima e inflexible:
Esto nos sitúa en verde de nuevo. Podemos echar un vistazo a lo que tenemos para ver si es posible hacer algún refactor, antes de seguir avanzando.
En el código de producción de momento no tenemos nada reseñable, pero en el test tenemos un número mágico. La longitud mínima de la cadena está incrustada en el código y en el nombre del test. Ambas cosas son malas, Si en el futuro esta regla cambia tendremos que cambiar cosas en varios sitios. Es mejor hacerlo ahora.
Empecemos con el nombre del test, haciéndolo más genérico:
Eso está mejor, ahora el nombre del test no está ligado a una longitud concreta, sino a un concepto más abstracto de longitud mínima.
Y luego tendríamos que sustituir el número mágico por una constante o una variable para darle nombre y poder cambiarlo con facilidad llegado el caso. Esta regla debería residir en un único lugar, por lo que la opción más obvia es que esté en el propio generador de contraseñas. Como no se nos pide que sea configurable ni modificable puede ser una constante y la vamos a hacer pública para tener la opción de recurrir a ella en diferentes momentos.
Y ahora podríamos cambiar el test conforme a lo anterior:
Por el momento no tenemos gran cosa ya que nuestra implementación realmente no hace nada, aunque sí cumpla las primeras especificaciones. El problema es que ninguna de ellas nos fuerza a implementar algo más.
Introduciendo el azar
La siguiente especificación nos pide un string
de al menos seis símbolos al azar. No nos especifica qué tipo de símbolos, aunque podemos hacer algunas suposiciones con cierto fundamento como usar números, letras y algunos otros símbolos.
¿Qué es aleatorio?
Pero esta especificación es un poco difusa. En realidad cualquier cadena de caracteres sería válida dado que cualquier cadena puede haber sido generada al azar. Podemos suponer que se refiere a secuencias que no formen una palabra conocida pero, ¿cómo demonios podemos testear eso de una manera eficaz?
En realidad para hacerlo bien deberíamos realizar un análisis estadístico basado en el siguiente razonamiento:
Si tenemos un conjunto finito de n símbolos, la posibilidad de extraer uno cualquiera de ellos del conjunto es 1/n. Por tanto, si repetimos la extracción (con reposición) un gran número de veces (varios cientos, al menos), obtendremos una frecuencia para cada símbolo que será 1/n o un valor muy próximo. Cuanto mayor sea el número de veces que repetimos el experimento, más aproximado será el resultado.
Extrayendo la aleatoridad de nuestra clase
Ahora bien este test no sólo es poco práctico para nuestro caso, sino que además lo que realmente testea es un hipotético generador aleatorio de símbolos, el cual podría ser utilizado por nuestro generador de contraseñas. Este se encargará de pedir al generador aleatorio los símbolos que vaya necesitando y componer la contraseña con ellos.
De momento no vamos a crear el generador aleatorio, pero sí crearemos una interfaz para poder utilizar un test double suyo.
En resumen, nuestro generador de contraseñas utilizará un generador aleatorio de símbolos que le pasaremos como dependencia.
Esto tiene una gran ventaja porque para el test podemos tener un doble del generador aleatorio que no sea aleatorio. De este modo, las contraseñas generadas serán predecibles y podemos testear su construcción.
Para empezar seguimos necesitando un test que nos fuerce a implementar algo en el código de producción.
Una primera idea que podemos experimentar es la siguiente: Podemos hacer que nuestro generador entregue una secuencia concreta de símbolos y testear que la contraseña devuelta reproduzca esa misma secuencia.
De este modo, probaremos que el generador de contraseñas utiliza al colaborador.
Como es obvio, el test no pasará y nos toca implementar algo:
Esta implementación nos permite pasar el test y cumplir la especificación.
Generando un password legible
El tema de la aleatoridad ha abierto la necesidad de crear un colaborador para nuestro generador de contraseñas: un generador aleatorio que nos entregue un valor cada vez que lo llamamos.
De momento hemos creado un doble para usarlo en el test y le hemos fijado la secuencia en la que entrega valores. De este modo, hemos podido testear que el generador de contraseñas lo utiliza y que lo llama las veces necesarias.
Lo bueno, además, es que hemos logrado el acoplamiento mínimo posible del test a la implementación pues no hemos fijado expectativas sobre la forma en que el colaborador es usado.
Así que nos movemos a la siguiente especificación y nos dice que la contraseña ha de ser memorizable. En este caso, asumimos que queremos una contraseña construida uniendo sílabas escogidas al azar.
Nuestra interfaz RandomSymbolGenerator
entrega un string
cada vez que llamamos al método generate
. Así que podríamos crear una implementación que entregue una sílaba escogida al azar cada vez.
Obviamente, si queremos desarrollar esta implementación mediante TDD nos volvemos a topar con el problema del testeo no determinista. En el caso anterior lo hemos solucionado extrayendo la parte aleatoria, ¿podemos hacerlo ahora también?
Añadiendo otro nivel de indirección
Hemos dicho que la responsabilidad de obtener símbolos al azar debería estar fuera del generador de contraseñas. Ahora queremos crear una implementación concreta de ese generador aleatorio de símbolos que nos devuelva sílabas.
Y, de nuevo, podemos pensar en dos responsabilidades: la generación o gestión de los símbolos que vamos a utilizar y la generación de un valor aleatorio que nos permita elegir uno concreto.
Pero esta delegación no puede producirse indefinidamente. Separaremos la lógica en dos clases, una es el RandomSymbolGenerator
que entrega un símbolo de tipo string
(una letra, una sílaba, un número, un bloque cualquiera de símbolos, etc.), y la otra es un RandomnessEngine
que entregará un valor entero aleatorio que nos permitirá elegir un símbolo al azar de entre todos los posibles en el RandomSymbolGenerator
concreto.
Así que vamos con cada uno de ellos.
Un generador de sílabas
En español, una sílaba es un conjunto de letras que cumple las siguientes reglas:
- Tiene al menos una vocal
- Si tiene más vocales, han de formar un diptongo
- Puede comenzar, o no, con una consonante
- O por un grupo de consonantes del conjunto válido
- Puede terminar, o no, con una consonante del conjunto válido
Estas especificaciones son bastante claras, así que vamos a convertirlas en tests. El más básico de todos es el que toda sílaba tiene, al menos, una vocal.
Comencemos por una implementación mínima para pasar el test:
El siguiente requisito es que si hay más de una vocal, deben formar diptongo:
Esto es algo más largo de expresar:
Por tanto, empezamos a implementar y llegamos a esta primera solución preliminar, que consiste en escoger una entre varias opciones de cada tipo:
Pero esta solución no es satisfactoria. Tal como queda reflejada ahora no estamos usando las vocales y, sin embargo, el test pasa igualmente.
Parece más prometedor introducir un nuevo concepto llamado grupo vocálico, que englobe vocales únicas y diptongos. Eso implica que unimos las dos primeras especificaciones en una:
- Una sílaba debe tener siempre una vocal o dos que formen un diptongo.
Existen 14 diptongos en español y, junto a las cinco vocales, dan un total de 19 grupos vocálicos que vamos a admitir en nuestro generador, que numerados como zero indexed nos da los extremos 0 y 18. Hemos decidido excluir los triptongos, para no liarlo mucho más.
La nueva implementación hace fallar el segundo test y eso es una buena noticia porque nos obliga a pensar una implementación más general.
Nuestro problema ahora es que todavía estamos en una implementación inflexible en la que siempre elegimos el mismo grupo vocálico, así que tenemos que encontrar una forma de seleccionarlo. Para eso necesitamos introducir un RandomnessEngine que lo escoja al azar.
¡Ah! Pero estamos en test, necesitamos poder predeterminar qué elemento va a seleccionar nuestro RandomnessEngine. Así que vamos a crear un doble a partir de su interfaz.
Ya tenemos esta interfaz, suficiente para generar nuestro test double.
Ahora modificaremos nuestros tests para forzar que RandomSyllableGenerator
lo utilice. Empecemos por el que está fallando:
De momento, seguirá fallando porque no hemos implementado nada. Así que vamos a hacerlo pasar:
El segundo test ahora pasa, pero la nueva implementación nos obliga a cambiar el primero, que quedaría así:
Con este cambio, hemos conseguido implementar el grupo vocálico obligatorio en cada sílaba, testeando tanto los casos en que se genera una vocal única como los que se genera un diptongo.
Y ahora que tenemos los tests en verde, es momento de refactorizar. Entre otras cosas porque nuestros tests no son buenos.
Veamos: nuestros tests se basan en dos especificaciones que ahora hemos resumido en una sola. Por lo tanto, debería bastarnos con un único test que compruebe si el grupo vocálico es válido ya que podemos asumir que el RandonEngine
, que estamos doblando, siempre nos va a permitir escoger un grupo válido de las opciones disponibles.
Por tanto, unificamos los tests en uno sólo, y aprovechamos para dejar el código un poco más limpio:
Programando el mock de RandomnessEngine
Nos queda un punto problemático: ¿qué valor debe retornar el mock de RandomnessEngine
?
En el test hemos puesto que devuelva 4
, por ningún motivo especial. Podemos asumir que un RandomnessEngine
devolverá siempre valores entre los límites que le indicamos, así que 4 es un valor tan bueno como cualquier otro entre 0 y 18.
En realidad, lo que nos preocupa aquí es que RandomSyllableGenerator
llame al RandomnessEngine
con los valores correctos.
Añadiendo consonantes al principio de la sílaba
En nuestras especificaciones tenemos que las sílabas pueden comenzar, o no, por un grupo consonántico. En realidad, ocurre algo parecido al grupo vocálico. Podemos simplemente asumir que los valores válidos son el conjunto de las consonantes y el conjunto de los grupos de consonantes (por ejemplo, br- o tr-) que son válidos en español.
En total, tenemos 33 opciones, a las que hay que sumar la posibilidad de que la sílaba no comience por consonante, lo que daría un total de 34 posibilidades.
En último término podemos seguir la misma estrategia que usamos con las vocales, con la salvedad de que no es obligatorio que la sílaba comience por consonante. Como siempre, necesitamos enunciarlo en forma de test.
En esta ocasión el salto será un poco más grande de lo habitual, de modo que aquí va todo el test case, bastante arreglado:
En cuanto a la implementación, la sílaba que no empieza consonante puede puede simularse incluyendo un “grupo vacío”, aunque hay otras posibilidades bastante obvias.
Y ahora, sílabas terminadas en consonante
Para terminar la generación de sílabas, seguiremos un procedimiento parecido. En este caso sólo hay cinco terminaciones posibles (n, l, s, r, d), además de la posibilidad de que la sílaba acabe en consonante.
En principio, este será el test con el que probarlo:
Y esta la implementación que lo cumpla:
Ahora que tenemos todos los tests pasando en verde voy a refactorizar los tests, dado que hay unas repeticiones bastante manifiestas:
De forma que quedaría más o menos así:
Testeando el azar
Recapitulemos un poco:
Empezamos creando un generador de contraseñas, hasta que nos vimos en la necesidad de separar responsabilidades: por un lado, el generador de la contraseña PasswordGenerator y, por otro, el generador de símbolos que será del tipo RandomSymbolGenerator y que, para nuestro caso, es RandomSyllableSymbolGenerator.
El generador de la contraseña se limita a concatenar símbolos al azar que obtiene del generador de símbolos. Como tal, el generador no tiene ningún conocimiento acerca de cómo genera su colaborador los símbolos, con tal de que cada vez que lo llame le entregue uno que pueda concatenar. En otras palabras: a PasswordGenerator sólo le importa que se cumpla el contrato o interfaz RandomSymbolGenerator.
Por otra parte, al implementar un RamdomSymbolGenerator identificamos y decidimos separar dos responsabilidades: la composición del símbolo como tal, que de nuevo es concatenar una serie de piezas, y la aleatoriedad en la elección de estas piezas, que hemos extraído a un contrato o interfaz RandomnessEngine.
Esto tiene unas cuantas ventajas:
- A lo hora de testear hemos conseguido aplazar el tener que enfrentarnos con el azar y el no determinismo, aunque ahora nos toca ponernos a ello.
- Podremos elegir diversas estrategias para generar valores aleatorios, dependiendo de las necesidades que tengamos.
Así que ahora vamos a construir nuestro RandomnessEngine con TDD.
Testear el azar mediante propiedades
En esencia, RandomnessEngine es un generador de números aleatorios. Como no sabemos qué número va a generar, no podemos hacer aserciones sobre los valores específicos que nos entrega. A cambio, podemos testear sobre propiedades que deberían cumplir:
- Ser números enteros
- Ser mayores o iguales que un límite inferior
- Ser menores o iguales que un límite superior
Como RandomnessEngine es una interfaz vamos a crear una implementación de la misma, que yo voy a llamar SystemRandomnessEngine. Otra alternativa, sería convertir la interfaz en clase si es que prevemos que será la única implementación.
Como ya sabemos, forzar un tipo de retorno hace que el test de tipo sea redundante, por lo que vamos directamente al primer requisito:
Ejecutamos el test para verlo fallar y escribir el código necesario para que pase.
El valor devuelto es arbitrario, pero no queremos que sea cero por una razón: en nuestro siguiente test vamos a comprobar el otro extremo del intervalo de números permitidos, por lo que devolvemos un número que nos permita fijar un máximo más pequeño y asegurarnos de escribir un test que falle.
Como ya estamos en verde, escribimos otro test que nos fuerce a implementar la generación de números al azar:
En nuestro caso, no nos vamos a complicar mucho la vida, aceptando uno de los generadores incluidos en PHP, de modo que consigamos hacer pasar el test:
Realmente es una implementación trivial, pero lo que intento mostrar aquí no es tanto cómo generar números aleatorios, sino cómo testear eso. Por otro lado, ahora tenemos una clase-servicio que nos proporciona números enteros al azar para cualquier uso que podamos darle.
Así que toca regresar PasswordGenerator
Probamos nuestro generador de contraseñas
Ahora estamos en condiciones de montar PasswordGenerator
y que nos proporcione contraseñas legibles por humanos.
Veamos un ejemplo:
Que genera lo siguiente:
Ciertamente, no son contraseñas muy legibles, pero es que son muy largas y las sílabas son complejas, superan con creces el límite de seis caracteres y al contener muchas sílabas trabadas se hacen complicadas de leer.
Tenemos dos problemas aquí:
- La longitud de la contraseña medida en caracteres no es el mismo concepto que su medida en símbolos, ya que éstos pueden estar compuestos de varios caracteres cada uno. La especificación sigue siendo válida, pero el uso que hacemos de ella para contar el número de símbolos no lo es.
- Por otro lado, tendríamos que manipular el azar para obtener sílabas menos complejas.
Vamos a ello:
Contraseñas más cortas con la misma especificación
Un problema con la especificación original es que sólo pone un límite inferior al tamaño de las contraseñas generadas. Si se hubiera definido también un límite superior quizá no tuviésemos ese problema.
Como hemos mencionado antes, el problema es que comenzamos trabajando con el concepto de contraseña como una sucesión de caracteres y hemos desarrollado una solución que lo define como una sucesión de sílabas y, en algunos momentos, hemos tomado como equivalentes sílabas o símbolos y caracteres individuales, de modo que hemos asumido que nuestro RamdomGenerator
entrega caracteres. Si PHP tuviese un tipo de dato char quizá hubiésemos sido más conscientes de este problema.
Pero bueno, tenemos tests y, en este caso, podemos refactorizar la solución por una equivalente que refleje mejor la diferencia de los conceptos:
¿Es esto un refactor o implementación distinta? Es un tema interesante para discutir, pero desde el punto de vista de la especificación es un refactor. Estos son los resultados que obtenemos al ejecutar nuestro playground:
En realidad estamos tan contentos con el resultado que no vamos a cambiar el tipo de sílabas, aunque estamos pensando que la especificación de seis caracteres como mínimo es demasiado corta.
Queremos contraseñas más difíciles
Todavía nos quedan más requisitos que cumplir. Tenemos que hacer que algunos caracteres estén en mayúsculas y otros sean números o símbolos para lograr que la contraseña sea más difícil de adivinar.
Cambiar algunas letras por sus mayúsculas no afecta en exceso a la legibilidad, si acaso un poco a la facilidad para recordarlas.
Por otra parte, el tema de los números y los símbolos lo complica. Por supuesto, estamos pensando en hacer un poco de escritura H4cK3r, introduciendo símbolos o números que tengan semejanza gráfica con las letras.
Es hora de aplicar el principio Abierto/Cerrado.
Hackerizando la contraseña
El principio Abierto/Cerrado dice que para modificar el comportamiento de un módulo de software existente no deberíamos modificarlo (cerrado a modificación), sino extenderlo (abierto a extensión).
En un desarrollo agile, PasswordGenerator
en su estado actual sería un buen primer entregable, de modo que es posible que lo pudiésemos tener en producción incluso usándose en varias partes de nuestra aplicación.
Puede incluso que, para algunos de esos usos, la funcionalidad actual sea más que suficiente y cambiarla podría ocasionar problemas.
Así que, ¿cómo cambiar la funcionalidad de PasswordGenerator
sin romper el código que la utiliza en su estado actual?
Decorándola.
Decorar es extender por composición
El patrón decorador es una gran solución para estos casos. La idea es tener un objeto con la misma interfaz que el decorado, al cual utiliza mientras modifica su comportamiento en ciertos aspectos.
Los decoradores extienden el comportamiento de otros objetos por composición, no por herencia. De hecho, eso nos permite combinar varios decoradores para obtener comportamientos complejos montados a base de comportamientos más simples.
Como veremos, además, los decoradores son un gran ejemplo de aplicación de principios SOLID:
- SRP: un decorador para cada variedad específica de comportamiento
- OCP: no hay que tocar el objeto original
- LSP: el objeto base y el decorado son intercambiables
- ISP: cuanto más específica la interfaz, más fácil crear decoradores
- DIP: los decoradores y el objeto decorado dependen de interfaces
Por ejemplo, nosotros queremos decorar nuestras contraseñas para que tengan dos características:
- Símbolos y números
- Alguna mayúscula
Eso son dos responsabilidades, así que necesitaremos dos decoradores.
Decorador hacker
Este decorador simplemente tomará la contraseña generada por un PasswordGenerator
con el que se compone y convertirá algunos de sus caracteres en símbolos y números.
Para ello nos interesa que cumpla una interfaz que aún no hemos definido pero que es la misma de PasswordGenerator
: disponer de un método generate
que devuelve un string
. ¿Es el decorador un PasswordGenerator
? En cierto modo sí, aunque es más bien un modificador.
¿Por qué estas disquisiciones? Porque queremos que se cumpla el principio de Liskov y para eso necesitamos una misma interfaz y queremos declararla de forma explícita para poder usar Type Hinting en los casos necesarios. Ahora mismo PasswordGenerator
es una implementación concreta y eso complica un poco el naming
.
Una solución sería crear una interfaz Generator
, que tenga un método generate
, lo que nos permite no tocar la clase PasswordGenerator
salvo para hacer que la implemente, lo que es trivial y no rompe ningún test.
Ahora ya podemos empezar con nuestro Hackerize
. Pero primero, un test:
Empezamos con este test bastante sencillo, y creamos una implementación mínima:
Fíjate que no necesitamos para nada el generador real, ni ninguna de sus dependencias, tan sólo estamos usando un stub que nos devuelve los valores de contraseña que nos interesan.
Dado que vamos a tener contraseñas de varios caracteres, vamos a forzar una nueva implementación con este test:
Este test falla, como toca. Lo cierto es que podríamos seguir haciendo implementaciones inflexibles ad infinitum, así que vamos a pasar ya a una implementación razonablemente funcional:
Una cosa que debemos tener en cuenta es tener en cuenta las mayúsculas, de modo que nos de igual el caso. Hagamos un test para eso:
Y, oye, que basta un cambio mínimo para lograrlo str_ireplace
en lugar de str_replace
:
Realmente, sólo nos queda añadir más sustituciones de símbolos. Podemos refactorizar los tests con un data provider y dejarlo todo más limpio:
La implementación final será:
Decorador con mayúsculas
Como PasswordGenerator
sólo usa minúsculas para construir contraseñas, nos piden aumentar la dificultad añadiendo alguna letra mayúscula. Nosotros vamos a incluir una al azar.
Lo suyo es comenzar con un test muy sencillo, que fallará:
Momento de empezar a implementar:
Para forzar un cambio de implementación, podemos intentar convertir otra contraseña:
Y podríamos seguir hasta cansarnos, así que una implementación general sencilla podría ser la siguiente, de momento:
El caso es que hemos dicho que queremos poner en mayúscula una letra al azar y para probar eso necesitamos dos cosas: contraseñas con varias letras para probar y algo que nos genere aleatoridad.
Este último problema ya lo conocemos ¿Recuerdas que tenemos un generador de números aleatorios en este paquete que estamos creando?
Por otro lado, queremos comprobar que las contraseñas decoradas sólo tienen una letra mayúscula, cosa que podemos hacer eliminado las minúsculas en el resultado y contando lo que quede.
Como test es un poco feo, pero hace lo que necesitamos.
La implementación quedaría así:
Veamos cómo usarlo
Vamos a ver ahora cómo montar nuestro generador de contraseñas con todas estas piezas:
Que da como resultado:
¿Y si le añadimos mayúsculas?
Pues sale esto:
Y, finalmente, combinando ambos decoradores:
Con este resultado, que legible, lo que se dice legible, tampoco lo es mucho:
Cosas por hacer
Espero que el artículo haya servido para ilustrar un caso realista de testeo no determinista, aunque quizá se me ha ido un poco de las manos.
En cualquier caso, este proyecto está abierto a varias mejoras que se podrían tratar en artículos posteriores:
- Dada la complejidad de montar un generador de contraseñas con todas las piezas que hemos creado, podría estar bien introducir el patrón factoría a fin de simplificarlo.
- Otro tema sería poder modular un poco la complejidad de las contraseñas generadas para que aún transformadas no sean tan ilegibles.
- Por último, la posibilidad de montar un paquete para poder instalar el generador como dependencia mediante composer en otros proyectos en los que queramos utilizarlo.
Algunas referencias
Finalmente, algunas referencias sobre el tema que he seguido para fundamentar el capítulo:
Eradicating Non-Determinism in Tests
Este hilo de Stack Exchange
TDD en PHP. Un ejemplo con colecciones (1)
Arrays…
En PHP hemos utilizado arrays para todo tipo de cosas: listas, diccionarios, persistencia en memoria, registros y un largo etcétera.
Lo malo de los arrays es que necesitan mucha supervisión adulta. Al fin y al cabo, nada nos impide hacer cosas como estas:
Es decir, la única forma de garantizar que almacenamos en un array
objetos de un tipo determinado, y siempre del mismo, es controlarlo en el momento de añadirlo, pero también en el de usarlo ya que entre un punto y otro del flujo puede haber pasado cualquier cosa con nuestro array.
Una solución es encapsular el array
en algún tipo de objeto Collection
, que se encargue de asegurar que sólo incorporamos objetos válidos y que pueda realizar ciertas operaciones con ellos, garantizándonos la coherencia de los datos en todo momento.
Existen diversas librerías que aportan colecciones en PHP, a Google pronto:
https://github.com/morrisonlevi/Ardent
https://github.com/allebb/collection
https://github.com/emonkak/php-collection
https://dusankasan.github.io/Knapsack/
http://jmsyst.com/libs/php-collection
Incluso parece que tendremos una implementación canónica en un futuro
http://php.net/manual/en/class.ds-collection.php
Sin embargo siempre es interesante reinventar la rueda para profundizar en un concepto, así que mi intención en este capítulo y los siguientes es desarrollar una clase Collection
usando TDD e ilustrando el proceso de desarrollo. El proyecto original está en Github por si te interesa seguirlo más en detalle.
El objetivo es mostrar un proyecto relativamente complejo desarrollado desde el inicio mediante TDD.
¿Qué tendría que tener una clase Collection?
Hagamos una lista de control. Fundamentalmente pienso que necesitamos:
- Poder añadir elementos a la colección
- Que estos elementos sean objetos
- Que pueda decirnos cuántos objetos está almacenando
- Que sólo pueda añadir objetos de la misma clase o interfaz
- Que pueda añadir objetos de subclases de la original
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Escribiendo el primer mínimo test que falle
Decidir el primer test siempre tiene su dificultad. Después de un tiempo usando phpspec muchas veces comienzo con un test que chequee que puedo instanciar la clase, incluso en phpunit, que es el entorno de test que voy a utilizar. Quedaría algo así:
Es posible este test acabe desapareciendo, una vez que hayamos hecho avanzar un poco el código. La ventaja es que no hay que hacer mucho más que definir la clase para que el test pase, lo que viene siendo un código mínimo de producción.
Alternativamente, o a la vez, podemos escribir un test algo menos minimalista, por ejemplo, un test que verifique que al instanciar la clase tenemos una colección vacía. Eso ya implica crear un método count
que nos proporcione esa información.
Este test nos obliga a crear un primer método count
, que devolverá 0. Para ello, escribimos el mínimo código de producción que haga que el test pase:
En fin, puede que te parezca que de momento vamos muy lentos. Esto es lo que Kent Beck llama baby steps. También dice que cada quien tiene que encontrar el tamaño ideal de sus baby steps incluso dependiendo de cómo nos estemos encontrando en cada fase de desarrollo. Es decir, no hay una medida fija de cuál es el mínimo test o el mínimo código de producción, sino que es algo que podemos modular en función de las necesidades que percibimos al trabajar.
Pongamos un poco de comportamiento aquí
Nuestra lista de tareas empieza a tener algunos elementos menos:
- Poder añadir elementos a la colección
- Que estos elementos sean objetos
- Que pueda decirnos cuántos objetos está almacenando
- Que sólo pueda añadir objetos de la misma clase o interfaz
- Que pueda añadir objetos de subclases de la original
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Bien. Una colección no es nada si no puede coleccionar cosas, así que queremos poder añadirle elementos. Hagamos un test que lo pruebe:
Dos cosas interesantes aquí:
La primera es: ¿por qué testeamos ahora y no antes la capacidad de añadir elementos a la lista? Este es un pequeño debate que mantuve conmigo mismo mientras iba escribiendo. Lo cierto es que el test de la colección vacía es más pequeño y el código que me pide añadir es también menor. Una colección vacía no deja de ser una colección. La siguiente cosa más complicada es tener una colección con al menos un elemento.
La segunda está en la línea $this->append(new class{});
. Se trata de una clase anónima. Es un constructo del lenguaje bastante interesante, con ciertas similitudes con las funciones anónimas, para definir clases sobre la marcha. En este caso nos sirve para obtener un objeto sin tener que definir una clase particular.
Y esta es mi propuesta para pasar el test:
Hemos tenido que añadir un poquito de código para lograr pasar el nuevo test y no romper el anterior. Aquí se puede apreciar que nuestro test de Collection vacía es útil: para no romperlo tenemos que asegurar que el método count
devuelve 0 si no hemos añadido ningún elemento a la colección.
Para triangular este test, podríamos controlar que podemos añadir algún elemento más:
Y este test nos sale directamente en verde.
Siempre que un nuevo test nos sale en verde nos plantea una disyuntiva: o bien el test pasa porque ya hemos hecho la implementación obvia general o bien el test pasa porque no estamos testeando lo que debemos.
El caso es que nuestra implementación era bastante obvia y resulta que es la implementación general, así que, podríamos decir que este último test incluso sobra.
Controlando qué ponemos en la colección
Repasemos lo conseguido hasta ahora:
- Poder añadir elementos a la colección
- Que estos elementos sean objetos
- Que pueda decirnos cuántos objetos está almacenando
- Que sólo pueda añadir objetos de la misma clase o interfaz
- Que pueda añadir objetos de subclases de la original
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Para asegurar que los elementos que añadimos a la colección sean objetos y que sean de un tipo lo primero que tenemos que hacer es un test que lo pruebe. Lo cierto es que si podemos asegurar que son objetos de una clase, automáticamente estamos validando la condición de que sean objetos.
Si pasamos un objeto de la clase incorrecta deberíamos tener una excepción. En este caso he optado por OutOfBoundsException
. Podríamos cambiarla más adelante por otra más explícita ya que no es lo más importante de nuestro proyecto.
Y es ahora cuando empiezan los problemas: ¿Cómo testeamos esto? ¿Cómo sabe Collection
qué tipos son válidos y cuáles no? Empecemos por el test más básico:
Este test fallará, lo que está bien.
Pero para pasar a verde vamos a tener pensar varias cosas. Una forma podría ser simplemente hacer un Type Hinting en el método append
, pero ¿contra qué tipo? Si fijamos un type hinting en append
no vamos a poder extender la clase Collection
para poder usar otros tipos, así que tenemos que buscar otra forma de controlarlo.
Por otra parte, la clase Collection
necesitará saber contra qué tipo validar los objetos que le añadamos y ese conocimiento debería ser obligatorio. Por tanto, nos hace falta un test previo para controlar que puedo definir el tipo de la colección:
La nota interesante en este caso es esa especie de self-shunt que nos hemos marcado. En lugar de inventarnos un tipo, usamos el propio tipo de nuestro TestCase. De paso, modificamos el test anterior.
La técnica de self-shunt consiste en utilizar el propio TestCase como Doble para los tests, así no tienes que crear nuevas clases para tener un objeto que pasar. Aprendí esta técnica en las Rigor Talks de Carlos Buenosvinos.
Para pasar este test, necesitamos añadir un constructor a nuestra clase que admita un parámetro en forma de string que sea opcional, a fin de no romper los tests anteriores.
Ahora podemos crear Collections con tipo, pero quizá deberíamos parar un momento y refactorizar nuestros tests, la duplicación que tenemos puede ponerse problemática.
Refactorizar el test
Refactorizamos los tests porque son parte integral de nuestro desarrollo. Y como también son código deberíamos aplicar las mismas buenas prácticas que a nuestro código de producción.
En este caso debería ser evidente que hay una duplicación importante: cada vez que instanciamos nuestro Subject Under Test repetimos el mismo código y, aunque puede parecer trivial, nos conviene reducirla extrayendo el código común a un método.
También hay una repetición en los dos test que inicializan Collection
especificando un tipo, así que también lo extraemos:
De momento, se queda así.
Ahora bien, nuestro test para controlar que Collection
sólo admite objetos de un tipo sigue fallando y tendremos que hacer algo al respecto para ponernos en verde.
Parece que es obvio que hay que añadir un control que compare el tipo del objeto que se pasa en append
con el que hemos registrado en Collection
.
Esto va a hacer que fallen nuestros tests anteriores porque al hacer que type
sea opcional, también tenemos que asegurarnos de que controlamos el tipo sólo si tenemos alguno definido. Esto va a suponer un problema conceptual que tendremos que tratar, pero de momento vamos a aparcarlo.
El código de producción tendría que quedar así:
Ya que estamos, vamos a testear e implementar que podemos añadir objetos que sean subclase del tipo aceptado por la lista. Test que falle al canto:
Nuestro test fallará. Esto es por que nuestro control del tipo es demasiado estricto, podemos relajarlo con is_a
, una función que nos dice si un objeto es, o hereda, de la clase indicada:
Ahora nuestro test pasa y es momento de refactorizar. Como se puede ver, la cláusula de guarda que hemos puesto para controlar el tipo hace rato que ha dejado de ser fácil de leer, por lo que sería buena idea extraerla y ocultar su complejidad en un método con un nombre más explícito.
Por fin, podemos tachar algunos elementos de nuestra lista:
- Poder añadir elementos a la colección
- Que estos elementos sean objetos
- Que pueda decirnos cuántos objetos está almacenando
- Que sólo pueda añadir objetos de la misma clase o interfaz
- Que pueda añadir objetos de subclases de la original
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Antes de terminar, hagamos unos arreglos
Ahora mismo hemos cubierto la mitad de nuestra lista pero, como mencioné antes, tenemos un problema conceptual importante que no hemos afrontado.
Nuestra Collection
maneja un tipo específico de datos, pero actualmente permitimos que se puedan crear instancias de Collection sin especificar tipo. Si hacemos que el tipo sea obligatorio en la construcción romperemos buena parte de los tests, por lo cual deberíamos refactorizarlos de nuevo. Y el caso es que nuestra anterior refactorización nos ayuda al centralizar la creación de Collections para tests.
Lo haré en varios pasos.
Primero, modificamos getCollection
para que instancie la colección dándole un tipo (haciendo esta especie de self-shunt para no tener que añadir nada innecesario). Para eso nos basta con llamar internamente a getTypedCollection
.
Esto hace que fallen los tests que controlan que podemos añadir objetos a la colección. Era de esperar ya que estamos pasando clases anónimas, cambiemos eso haciendo un self-shunt.
Ok. Ahora los tests pasan. Todavía podemos hacer un arreglillo: getCollection
y getTypedCollection
hacen exactamente lo mismo, así que podemos quitar uno de los dos. Creo que podemos dejar getCollection
y que se quede con el código del otro método. Cambiamos las llamadas en los tests que hagan falta y el TestCase nos queda así.
Fíjate que siguen pasando los tests y que, en cierto modo, hemos usado el código de producción como “test del test” para hacer este refactoring.
Ahora ya podemos tocar la clase Collection
y ver si al hacer obligatoria la definición del tipo se rompe algo. La respuesta es que no. Además, podemos quitar el feo control de null, ya que ahora el parámetro siempre estará presente. Por cierto, que al ser privado y pasarse sólo en el constructor, resulta que es inmutable desde fuera de Collection
y eso es bueno.
Y un extra
Para ser más semánticos, podríamos añadir un named constructor de modo que la instanciación de nuevas colecciones se haga de una manera más expresiva. Algo así:
También haremos privado el constructor. Para eso podemos simplemente modificar el método factoría que tiene el test, automáticamente todos los tests fallarán, pero que no cunda el pánico:
Al fin y al cabo, sólo hay que hacer unas pequeñas modificaciones para volver a verde:
Fin del primer acto
Con esto terminamos la primera parte, nuestra clase Collection
admite objetos de una clase y sus subclases. También permite objetos que implementen la misma interfaz, algo que no hemos hecho explícito en los tests, pero que podría ser innecesario ya que el mecanismo de control de tipo funciona tanto para clases como para interfaces.
Lo interesante creo que está en el proceso seguido y en algunas técnicas que hemos ido aplicando. Por ejemplo:
- El uso de TDD para modelar la clase
Collection
, usando el ciclo Rojo->Verde->Refactor. - El diálogo entre los tests y el código de producción, hasta el punto de que en algún momento el código de producción nos sirve como red de seguridad para refactorizar los tests.
- La importancia de refactorizar tanto el código de producción como los tests.
- El uso de clases anónimas para crear dummies para tests.
- El uso de técnicas self-shunt para evitar tener que crear clases o dobles para ciertos tests.
- El uso de métodos factoría en los tests para crear instancias de nuestro Subject Under Test, gracias a lo cual podemos controlar más fácilmente los parámetros de creación si los hubiese.
En el próximo capítulo añadiremos comportamientos que nos permitirán hacer cosas interesantes con nuestra Collection y trataremos de hacerlo de manera interesante también.
TDD en PHP. Un ejemplo con colecciones (2)
Ahora que tenemos una clase Collection a la que podemos añadir objetos de un tipo determinado o sus descendientes, vamos a desarrollar algo de comportamiento. Al fin y al cabo, queremos nuestras colecciones para hacer algo con sus elementos, no sólo para admirarlas.
Testing dirigido por Checklist
Antes de continuar con el desarrollo, voy a detenerme en una cuestión práctica: la checklist
de tests.
En el libro de TDD by Example, Kent Beck recomienda utilizar una lista de control para ir anotando en ella todas las cosas que queremos testear, tanto las que pensamos a priori, como las que vayan surgiendo a medida que avanzamos en el trabajo.
El mejor soporte para esto es papel y lápiz. En último término es una especie de backlog de las especificaciones que queremos cubrir con tests. Cada vez que completamos una tarea la tachamos y seleccionamos la que nos parezca más propicia para realizar a continuación.
Además, si nos surge alguna idea de algo que deberíamos probar, lo anotamos y nos olvidamos temporalmente del asunto. Lo mismo si, al repasar la lista, observamos que hay algún asunto que podríamos reformular de algún modo.
Esta era nuestra lista inicial:
- Poder añadir elementos a la colección
- Que estos elementos sean objetos
- Que pueda decirnos cuántos objetos está almacenando
- Que sólo pueda añadir objetos de la misma clase o interfaz
- Que pueda añadir objetos de subclases de la original
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Y esta es la lista al final del artículo anterior:
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Filosofía de las colecciones
Llegados a este punto debo hablar un poco de lo que tengo en mente sobre las colecciones.
Un primer enfoque tiene que ver con las estructuras de datos tradicionales. Algunas librerías de Colecciones ofrecen colas, pilas, heaps y demás. Sin embargo, de momento no estoy interesado en usarlas, ya que mi objetivo es más bien el manejo de colecciones con las que pueda:
- accionar todos los elementos.
- filtrar elementos conforme a un criterio para obtener un subconjunto de la colección.
- agregar datos (recuento, etc).
- extraer información de todos los elementos de la colección.
Así que voy más bien en la línea de poder recorrer los elementos de las colecciones, para lo cual PHP me ofrece diversos recursos, como implementar las interfaces de Iterator
y Traversable
, de modo que pueda utilizar mis colecciones con foreach
y otros bucles. Pero ¿por qué no hacer que sean las propias colecciones las que se ocupen de sus propios elementos?
Esto está influenciado por algunos artículos y screencasts de Adam Wathan, autor del libro Refactoring to collections, en el que explica cómo evolucionar el código en estilo imperativo hacia un estilo funcional, eliminado bucles y condicionales gracias al uso de colecciones y pipelines de colecciones.
Algunas de las piezas necesarias existen en PHP, como las funciones array_map
, array_reduce
o array_filter
, que nos permiten escribir en estilo funcional lo que, de otra manera haríamos mediante bucles foreach.
En otros lenguajes, sin embargo, los arrays son objetos e incorporan este tipo de métodos, como es el caso de Javascript o Java, y esto es algo que me gustaría reproducir en este proyecto.
Así que podríamos comenzar por each
.
Implementar el método each
Volvamos a nuestra lista de tareas:
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
Como se puede ver, cada una de los requerimientos de esta lista está asociado con un método específico, orientado a realizar las manipulaciones deseadas en los elementos de la colección.
Por otro lado, antes he mencionado los pipelines, vamos a comentar brevemente sobre ellos. En pocas palabras, los pipelines consisten en componer una serie de operaciones sobre un conjunto de datos de modo que cada una se ejecute con los resultados de la anterior. Una forma elegante de expresar eso en código es hacer que cada operación devuelva un objeto del mismo tipo, que soporte las mismas operaciones.
Como esto me interesa, lo añado a la lista:
- Que pueda iterar a través de todos los elementos y hacer algo con ellos (each)
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder encadenar operaciones
Y, ahora, centrémonos en each
.
Lo que queremos es poder decirle a la colección que los elementos de la lista hagan algo, pero este algo no devolverá resultados. ¿Cómo podemos testear esto?
El método each
tiene que aceptar un Callable
que tome un objeto del tipo coleccionado como argumento, recorrer la lista de elementos y ejecutar el Callable
con cada uno.
Una forma de testear esto podría ser añadir algunos elementos a la lista y definir una función que simplemente se ejecute una vez por cada elemento. Suena un poco “sucio”, pero podría servir.
Pero en realidad, hay un test que debo realizar antes: una Collection
vacía no debería ejecutar nada. Esta es mi primera tentativa en CollectionTest.php (sólo incluyo la parte relevante):
Lo primero es hacerme con una instancia de Collection
mediante el método getCollection
que, como quizá recordéis, utilizaba la técnica de Self-shunt, de modo que la propia clase CollectionTest actúa como elemento coleccionable.
Para poder registrar su actividad, paso por referencia una variable $log
en la que iré acumulando un asterisco por cada ejecución. En este primer test no debería ocurrir nada.
Ejecutamos el test y falla, lo que nos indica la necesidad de implementar un método each
, que debería aceptar un Callable
.
Ahora el test pasa. Estamos en verde, hay que pensar otro test, ahora con, al menos, un elemento.
Este test, como era de esperar, falla. Así que implementemos lo mínimo posible: ejecutar una vez la función.
Lanzamos el test y pasa, pero ahora se rompe el test anterior. Y esto es bueno, nos dice que hay que implementar algo. Podríamos implementar ya la iteración, pero vamos a esperar un poco:
Con esto nos ponemos en verde.
El motivo de hacer este baby step es el siguiente: si implementamos la iteración con un sólo elemento, nuestro siguiente test sería irrelevante y no nos iba a aportar información nueva.
Por otro lado podría ocurrir que tanto el caso “0 elementos” como el “1 elemento” fuesen especiales (no dejan de ser casos límite al hablar de colecciones) y tener tests específicos para ellos podría desvelar esa peculiaridad.
En nuestro ejemplo es previsible que la solución general funcione también para los casos de 0 y 1 elementos, pero para eso, nada mejor que hacer un nuevo test y ver qué pasa:
El test, como era de esperar, no pasa. Toca añadir código de producción:
Dos elementos ya son una colección, así que vamos a implementar la iteración, de una forma bien sencilla:
El nuevo test ha pasado, pero se ha roto el test de la colección vacía. Tenemos que tratar este caso límite.
Y con esto hemos vuelto a verde.
Podríamos generalizar este test para recorrer un número arbitrario de elementos, pero lo voy a obviar ya que podemos afirmar que each ejecuta la función tantas veces como elementos hay en la colección.
En cambio, quiero detenerme en una situación que no hemos testeado todavía: que la función pasada al método each
recibe el elemento correspondiente a la iteración. Todavía no hemos demostrado que eso ocurra. De hecho, hemos utilizado para el test, una función que no recibe parámetros. Necesitamos una nueva que pueda recibir el parámetro.
Ya hemos testeado la iteración, así que ahora nos centramos en el simple hecho de que cada elemento de la Colección sea pasado a la función. Por tanto, es lo único que vamos a comprobar. A decir verdad, ni siquiera tenemos que hacer nada con el elemento ya que la declaración de la función nos obliga a pasarle un objeto de tipo CollectionTest. las funciones que vayamos a querer usar necesitarán sus propios tests.
En fin, como el pase del parámetro no está implementado el test falla.
El cambio es simple:
Volvemos a verde. Podríamos pensar en refactorizar. Por una parte, nuestro objetivo con el método each es encapsular el funcionamiento de foreach
, siendo la función que pasamos el código que estaría dentro del bucle. Por otra parte, podríamos usar una de las funciones de array de PHP, como por ejemplo:
Y esto resulta deliciosamente conciso.
Recapitulando each
Debo confesar que al empezar a escribir el capítulo no las tenía todas conmigo respecto a la posibilidad de testear como es debido tanto el tema de pasar un Callable
y registrar sus efectos sin complicar en exceso los tests.
Al final, con pequeños pasos, hemos podido implementar each
en nuestra clase Collection
.
La lista de tareas, queda así, eliminando la que acabamos de terminar:
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder encadenar operaciones
Ahora bien, al examinar el último punto me vienen a la cabeza algunas cuestiones: ¿deberían ser las colecciones objetos inmutables? Por ejemplo, en el caso de each
si la acción que se ejecuta en el objeto lo modifica de algún modo, ¿debemos realizarlo sobre una copia y devolver ésta?
En otros métodos en los que nos interesa devolver otro objeto Collection
con los elementos seleccionados o transformados, es posible que nos interese poder alimentar la colección mediante un array de objetos adecuados.
Además, existen una serie de métodos que podrían ser útiles para conocer el estado de la lista (isEmpty
), para comprobar si cierto elemento existe en ella o incluso para obtener un elemento según ciertos criterios. Así que nuestro checklist vuelve a crecer:
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder encadenar operaciones
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Alimentar una lista a partir de un array
- Método isEmpty que nos diga si la colección está vacía
TDD en PHP. Un ejemplo con colecciones (3)
Veamos como está nuestra lista de tareas:
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder encadenar operaciones
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Alimentar una lista a partir de un array
- Método isEmpty que nos diga si la colección está vacía
Una cosa que no he reflejado en esta lista es que debería poder pedirle objetos concretos a Collection, bien sea por un criterio, bien por su posición. Así que añado estas tareas.
- Que pueda devolver un array de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder encadenar operaciones
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Alimentar una lista a partir de un array
- Método isEmpty que nos diga si la colección está vacía
- Método para obtener uno o más objetos de la lista, por criterio, posición, etc.
Fíjate que estoy mezclando diversos tipos de ideas más o menos concretas. Esto es lo de menos porque la iremos reescribiendo continuamente. Pero aquello que está en la checklist
está fuera de nuestra cabeza y, por lo tanto, nos deja más recursos para trabajar en la tarea concreta que tengamos entre manos.
Pipeline en el método each
Al final del capítulo anterior quedaba implementado el método each
, pero como nos hemos planteado la posibilidad de poder montar pipelines me gustaría abordarlo ahora antes de entrar a trabajar con otros métodos.
En líneas generales, necesitamos resolver dos cosas:
- Que el método devuelva un objeto
Collection
para poder aplicarle los mismos métodos. - Si el objeto
Collection
va a ser inmutable con respecto al métodoeach
y devolverá unCollection
nuevo con las modificaciones aplicadas.
El primer punto es casi trivial y muy fácil de testear. Simplemente esperamos que each
devuelva un objeto del tipo Collection.
El test no pasa, pero la implementación es sencilla:
Y nos ponemos en verde enseguida.
Pero, ¿y si la colección está vacía?
Pues pasa que el test falla dado que estamos devolviendo null
. Hacer algo con una colección vacía no tiene mucho sentido, pero tal vez no nos interese romper el pipeline, por lo que simplemente devolvemos la misma colección.
Lo anterior es un ejemplo de la problemática de escoger bien el primer test que hacemos. En each
comenzamos por una colección vacía, y al ir avanzando con nuevos tests, descubrimos que ese era un caso límite para el problema que estamos tratando puesto que al ir evolucionando la implementación llega un momento en que ese test falla.
En esta ocasión, al “saltarnos” la situación de colección vacía no hemos detectado el caso límite, sino que lo hemos tenido que pensar nosotros. Por eso, es conveniente detenerse a pensar un poco más el test inicial más sencillo posible.
De momento, no voy a tachar nada de mi lista para recordar este tema al implementar otros métodos.
Una digresión: mutable, modificable o todo lo contrario
La verdad es que llevo un buen rato dándole vueltas a este tema. En algunos lenguajes, como Scala, se ofrecen colecciones inmutables y mutables. Cada tipo tiene sus ventajas e inconvenientes. El que una colección sea inmutable no implica que no podamos realizar operaciones con ella, pero estas operaciones devolverán una colección nueva, que es copia de la original y a la cual se le aplica la transformación. De este modo, la colección original permanece inalterada.
La mutabilidad o inmutabilidad no afecta a la interfaz. Sencillamente en una colección inmutable los métodos clonan la colección actual y aplican la transformación sobre ella.
Hay varias cuestiones con respecto a la mutabilidad y la inmutabilidad de la colección:
- Que se puedan, o no, añadir, quitar o cambiar elementos a la colección, una vez creada. En algunos casos, necesitamos que la colección funcione como si fuese un repositorio en memoria. En otros, nos interesa una colección constante de la que obtener ciertos datos. Una colección que no se pueda modificar en este sentido, no expone métodos append o remove o, si lo hace, estos devuelven una instancia nueva de la colección.
- Que ciertas operaciones devuelvan la colección transformada o bien una colección nueva con la transformación. Una operación de filtrado siempre debería devolvernos una colección nueva, dejando la original intacta, para poder realizar nuevas búsquedas o selecciones en ella.
- Que se puedan modificar elementos o no, en el sentido de cambiar el estado de los elementos, pero no la colección como tal. El hecho de que la colección no pueda variar el número de elementos no implica que éstos no puedan cambiar de algún modo. El método
each
, implementado en el artículo anterior, encaja aquí.
Es buena idea leerse este artículo de Martin Fowler sobre las collection pipelines. Además, nos da un montón de pistas sobre qué funcionalidad añadir en ella.
Fin de la digresión.
Antes de implementar el método map
La idea de map
es aplicar una transformación a cada elemento de la colección actual, creando una nueva colección con los resultados de esa transformación. Collection
es inmutable con respecto a map
y tampoco los objetos deberían ver su estado cambiado.
En cierto modo, map
es lo mismo que each
, pero devolviendo resultados.
Un problema que nos plantea map
tiene que ver con lo que devuelve. Si queremos poder hacer pipelines, debería devolver un objeto Collection
(en principio nos da igual qué objetos colecciona), por lo que vamos a necesitar poder crearlo a partir de arrays de objetos. Eso es algo que habíamos puesto en nuestra lista de tareas, en el apartado de ideas a considerar, pero que ahora lo vamos a reformular.
Además, me estoy fijando que hay algunas ideas que no están bien expresadas y que incluso entran en contradicción, como que el método map
devuelva un array, cuando quiero que devuelva un Collection
y así poderlo encadenar.
Sin embargo, hay una cuestión que me preocupa: ¿y si no devuelvo objetos en la función de mapeo? Por ejemplo, a lo mejor sólo quiero obtener una lista simple de nombres a partir de una colección de objetos.
Una solución es forzar que todas las transformaciones den como resultado objetos, que no tienen que ser del mismo tipo que los de la colección original, de modo que pueda coleccionarlos sin más. Eso me lleva a pensar en que podría ser necesario un método mapToArray
o toArray
(o ambos), con el que mapear una colección a un array y que sería el punto final de un pipeline.
Otra solución sería generalizar Collection
para permitir cualquier tipo de dato, de modo que pueda coleccionar cualquier cosa. Esta idea es correcta y es interesante. Podríamos poder seguir especificando el tipo para garantizar que la lista se mantenga coherente. Aún siguiendo este desarrollo, sigue siendo interesante incluir el método mapToArray
para poder obtener la colección en ese formato que suele ser útil para interactuar con otro código existente.
¿Cuál de las dos elegir? Pues da un poco igual. Como estamos desarrollando con TDD estamos protegidos para realizar cualquier cambio, no sólo refactoring, sino también cambios de funcionalidad. Mi opción va a ser la primera (sólo Objetos) y luego, ya veremos. Lo anoto para no olvidarlo, además de reorganizar un poco la lista conforme a las reflexiones que he estado haciendo:
- Que pueda devolver una Collection de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Método isEmpty que nos diga si la colección está vacía
- Método para obtener uno o más objetos de la lista, por criterio, posición, etc.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
Ahora sí, ahora vamos con map
.
Implementando map
Repasemos lo que sabemos sobre map
:
- Tiene que devolver
Collection
- Tiene que aceptar un
Callable
- Este
Callable
tiene que devolver objetos coleccionables, que no tienen por qué ser del mismo tipo que los coleccionables
Así que tendremos que probar eso.
El caso más sencillo sería el de la colección vacía, como hemos dicho antes, no queremos romper el pipeline, por lo que el test podría ser el siguiente:
Obviamente, el test va a fallar porque no tenemos método map
. Lo creamos y pasamos de nuevo el test para ver que falla. Después haremos la implementación más sencilla posible, que es devolver la misma colección.
Y esto nos coloca en verde de nuevo.
Ocurre, sin embargo, que no queremos que map devuelva la misma Collection
, sino otra. Así que necesitamos un test que pruebe eso:
El test falla porque devolvemos la misma colección, vamos a ver cómo solucionar eso de momento:
Ahora que estamos en verde, haremos un test para probar que se realiza el mapeo. Para ello añadimos un elemento a la colección y esperamos que la colección devuelta tenga un elemento. Por desgracia, este test va a pasar:
He puesto este test como ejemplo de test mal escogido. La información que devuelve no nos aporta nada porque no chequea que el cambio deseado se produzca. Aunque la medida es compatible con lo que esperamos (una colección con un elemento), tal y como la recogemos no nos permite discriminar nada.
Lo mejor sería que la función que pasamos a map devuelva un objeto distinto y chequear que la nueva colección maneja objetos de ese tipo.
Así que creamos un objeto simple para este propósito:
Y cambiamos el test:
Estamos chequeando un estado privado del objeto $result
, que sabemos que es del tipo Collection
por los tests anteriores. Siendo estrictos no deberíamos chequear propiedades privadas, aunque creo que hay situaciones en las que por pragmatismo es mejor hacerlo. Por otro lado, poder obtener el tipo de objeto de una colección sería razonable, así que podríamos añadir a la lista esa característica.
- Que pueda devolver una Collection de transformaciones de los objetos (map)
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Método isEmpty que nos diga si la colección está vacía
- Método para obtener uno o más objetos de la lista, por criterio, posición, etc.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método getType devuelve tipo de la colección
En cualquier caso, ahora hemos conseguido un test que falla, con lo que estamos listos para implementar.
Ahora el test pasa, pero no prueba que estemos mapeando, sólo prueba que devolvemos una Collection
con el tipo MappedObject
, el objeto resultado del mapeo. Nuestro test necesita una cierta triangulación, es decir, varias aserciones que, juntas, prueben lo que queremos mostrar con el test. Debemos volver al rojo, retomando un test que descartamos antes:
Ahora ya tenemos mejor información. La implementación mínima es la siguiente:
Hemos vuelto a verde, pero hay una cosa que me enciende una pequeña luz de alarma. Los tests con colecciones vacías no fallan aunque nuestra implementación actual fuerza a devolver una colección con un objeto. Necesitamos un test que verifique que devolvemos una nueva colección vacía:
Creamos una implementación que contemple este caso límite:
Nos vamos acercando, estamos de nuevo en verde. Necesitamos un test más que nos fuerce a implementar una solución más general:
Este test ya hace fallar nuestra implementación obvia, ahora toca encontrar una solución que sea general.
Para crear nuestra colección necesitamos determinar el tipo de los objetos devueltos por nuestra función de transformación aplicada sobre los objetos existentes en la colección. Una forma más o menos económica es usar el primer elemento para obtener esa información, crear la colección y empezar a poblarla. Luego seguimos el resto de elementos hasta el final. Aquí tenemos una primera implementación, que hace que el test pase:
Y como el código es poco inteligible, vamos a refactorizar un poco aprovechando que estamos en verde:
Para finalizar (por ahora)
Al igual que ocurre con each
, no hay razón para pensar que haya otros casos límite con colecciones de más de dos elementos, por lo que no merece la pena escribir tests para probar que podemos mapear colecciones más grandes.
Nos quedan unas cuantas cosas pendientes en la lista de tareas, pero las afrontaremos en los siguientes capítulos.
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Método isEmpty que nos diga si la colección está vacía
- Método para obtener uno o más objetos de la lista, por criterio, posición, etc.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método getType devuelve tipo de la colección
TDD en PHP. Un ejemplo con colecciones (4)
En este capítulo voy a intentar desarrollar el método filter
, el cual también nos dará un punto de partida para otros métodos.
Y nuestra lista de tareas había quedado así:
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Devolver la colección o la colección generada para poder encadenar operaciones
- Considerar la cuestión de la inmutabilidad
- Método isEmpty que nos diga si la colección está vacía
- Método para obtener uno o más objetos de la lista, por criterio, posición, etc.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método getType devuelve tipo de la colección
Antes de nada, voy a hacer un poco de limpieza en la lista.
Los puntos de devolver la colección para hacer pipelines y el tema de la inmutabilidad están más o menos recogidos en las implementaciones que hemos hecho hasta ahora y lo cierto es que la que vamos a afrontar ahora (la del método filter
) lo implica claramente, así que las voy a tachar de la lista.
Por otro lado, voy a reorganizarla un poco para poner cerca cuestiones que son similares. Finalmente, la lista queda así:
- Que pueda devolver una Collection de objetos filtrados conforme a un criterio (filter)
- Que pueda obtener uno o más objetos de la lista, por criterio, posición, etc.
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Estos capítulos tratan de TDD más que de Collections
A estas alturas debería estar claro que lo que me importa de estos capítulos es más el aprendizaje de la metodología TDD que la creación de una biblioteca de Collections. La biblioteca puede ser útil per se y podríamos hablar de ello en otro lugar, pero para mí este ejercicio es algo parecido a una kata con la que mejorar mis habilidades como desarrollador que utiliza TDD siempre que puede. Y, en el contexto del libro, es un modo de recapitular todo lo que hemos ido aprendiendo.
En realidad, a medida que profundizo en este proyecto, me doy cuenta de la capacidad de TDD para aprender a programar mejor y para conseguir mejores diseños de software:
- La metodología te va guiando paso por paso: no importa lo complejo que pueda ser el problema porque lo estás dividiendo en trozos muy pequeños y manejables.
- Cada fragmento del problema acaba teniendo una implementación cuya dificultad oscila entre lo obvio y lo bastante sencillo. Si la implementación es complicada, es porque seguramente estamos testeando algo que no debemos o no estamos desmenuzando bien el problema.
- El ciclo test mínimo que falle - implementación mínima para pasar el test te permite no agobiarte tratando de mantener una imagen completa del problema en la cabeza. Vas dando pequeños pasos y, cuando te das cuenta, has llegado al final sin cansarte.
- Y cuando llegas al final tienes un producto que funciona, que posiblemente no de grandes problemas de integración (y si los da, puedes crear nuevos tests para probarlos) y que tiene una cobertura de tests del 100%, por lo que cualquier regresión se manifestará enseguida.
Pero vamos al lío, que es para lo que estamos aquí.
Filtrando una colección
Cuando tenemos una colección de objetos suele interesarnos poder realizar búsquedas y selecciones en base a algún criterio, así que vamos a implementar eso en nuestra clase Collection.
Fíjate que tenemos dos situaciones:
- En unos casos queremos conseguir todos los objetos de la colección que cumplen el criterio, que es lo que entendemos como una búsqueda o un filtrado, y que nos devolverá una nueva colección que contenga los objetos seleccionados (o ninguno, si ninguno cumple los criterios).
- En otros casos queremos obtener sólo un elemento que cumpla las condiciones. En ese caso, devolverá un objeto del tipo contenido en la colección si es que alguno cumple los criterios. En caso de que no los cumpla puede no devolver nada, puede devolver un objeto nulo o puede lanzar una excepción si partimos del supuesto de que el objeto debería estar ahí.
Ambas situaciones son parecidas, pero no exactamente iguales. Por el momento, nos vamos a centrar en la primera: crear un método filter
que nos devuelva una colección de objetos seleccionados por un criterio.
La idea es que el método filter
reciba una función booleana que devuelva true si el objeto cumple los criterios y false si no los cumple. En el primer caso, lo añadiremos a la nueva colección. Al terminar de revisar todos los elementos devolvemos la colección que haya resultado. Obviamente esta colección será del mismo tipo que aquella sobre la que operamos.
Aprovechando lo que hemos aprendido hasta ahora, sabemos que el test más sencillo con el que podemos empezar es el de la colección vacía, que devolverá una colección vacía, que será del mismo tipo que la original y que, además, no ha de ser el mismo objeto. Esto son cuatro tests:
- Filter devuelve un objeto Collection
- El tipo de objeto que maneja es el mismo de la Collection original
- La Collection devuelta no tiene elementos
- La Collection devuelta no es la misma que la original
Podemos adoptar dos enfoques. Hasta ahora, hemos escrito un test para probar cada una de estas condiciones, con una aserción por test. Alternativamente podríamos escribir un sólo test con las cuatro aserciones.
¿Qué es mejor? La primera opción nos dará una información más explícita si al ir implementando hacemos fallar alguno de estos tests, pues nos señala claramente dónde hemos metido la pata. La segunda opción nos permite avanzar un poco más rápido si tenemos confianza en lo que estamos haciendo o simplemente nos parece que podemos tratar el problema como un todo. A cambio perdemos un poco de resolución: en caso de que falle el test, todavía tendremos que examinar cuatro aserciones para descubrir qué hemos roto.
Yo voy a optar por la primera y dar pasos más cortos.
Mi primer test mínimo prueba que filter
devuelve un objeto Collection
:
El test fallará puesto que no existe el método filter
y volverá a fallar al proponer una implementación vacía.
De momento, nos bastará retornar la propia Collection para volver a verde. Sí, ya sé que esa es una de las cosas que no queremos hacer, pero dejemos que nos lo pida un test más adelante. No anticipemos los problemas pues esa prisa es la que nos va a llevar a crear mal código.
Ahora que estamos en verde y que no hay implementación más sencilla posible, vayamos al siguiente punto, que es el que trata sobre el tipo de objeto de la lista. Nos damos cuenta de que ese test no nos va a servir de nada, al menos no en este momento, así que lo dejaremos para el final. ¿Por qué sabemos que no nos va a servir de nada? Pues porque ese test va a pasar a la primera ya que estamos devolviendo el mismo objeto Collection
sobre el que operamos. Y lo mismo ocurre con el siguiente (la colección devuelta está vacía).
Lo que necesitamos siempre para avanzar es un test que falle y eso nos lleva al punto cuatro: la Collection
no es la misma que la original. Este test sí va a fallar, obligándonos a introducir un cambio en la implementación suficiente para pasar:
Bien. El test falla, así que toca implementar algo.
No hay que complicarse mucho, creamos una lista nueva y como hemos de asignarle un tipo de objeto tiramos del que tenemos más cerca, el tipo de la clase que contiene el método o, como en el ejemplo, de stdClass
, la clase básica de PHP.
Ahora volvemos a los puntos que hemos pospuesto. ¿Podemos hacer un test que falle para probarlos?
En el caso comprobar el tipo de objeto, sí que podemos.
Prueba superada. El método filter
devuelve una Collection
de stdClass
y nosotros queremos una de CollectionTest
. Por tanto, debemos cambiar la implementación para que podamos volver al verde:
Y, finalmente, tenemos que probar que la nueva colección creada está vacía. Sin embargo, tal como está la implementación sabemos que el test va a pasar, incluso si añadimos objetos a nuestra colección bajo test: la nueva colección se crea vacía y, de momento, no estamos haciendo nada con ella.
Así que el siguiente test mínimo que sí podría fallar es un test en el que añadimos un objeto a la colección bajo test, aplicamos una función que devuelve true
, indicando que esos objetos deben incluirse en la selección y esperando que nos devuelva la nueva colección con el objeto incluido.
Aquí tenemos el test que prueba lo que acabamos de decir:
Este test sí falla y, por tanto, nos obliga a implementar algo.
El test pasa, pero se nos rompen los tests anteriores. Tenemos una regresión, esperable por otra parte, debido al caso límite de colección vacía, que ya conocemos de la implementación de los otros métodos.
Trataremos el caso particular con una cláusula de guarda, sin más.
Ahora, podríamos probar el caso de que la función de filtrado devuelva false
. Entonces la colección devuelta por filter no podrá tener elementos. Este test falla:
Obligándonos a hacer una implementación mínima del filtrado para que el test pase.
Para nuestro siguiente test necesitamos que la lista tenga más de un elemento. En la implementación de los métodos each
y map
llegamos a la conclusión de que dos elementos serían suficientes para probar que la función funcionaría bien para cualquier tamaño de colección.
El test falla, ya que la implementación actual sólo añade el primer elemento de la colección, tendríamos que recorrer los elementos y probarlos con la función de filtro.
Finalmente, el test pasa con esta implementación, que pone punto final al desarrollo del método filter
.
Pero… Nuestro abogado del diablo lleva un rato sugiriendo que deberíamos probar varias condiciones más. Por ejemplo:
- Que la función de filtro permita probar que unos objetos pasan y otros no pasan (ahora mismo cuando hacemos un test usamos una función que siempre devuelve lo mismo). Realmente no es necesario. Lo que nosotros tenemos que probar es que
filter
utiliza el resultado de la función para decidir si incluye o no un objeto en la lista, cosa que hemos probado ya con un par de tests. Si la función filtra bien o no, es cuestión del test de la propia función. - Que los objetos de la colección deberían ser instancias distintas (ahora son la misma). Tampoco es necesario, sencillamente no los consideramos en la función de filtro, tan sólo necesitamos que estén llenando la colección en un número conocido.
- Que tenemos que probar que estamos iterando la colección. De momento sólo hemos probado que si esperamos un número de elementos (porque se han de incluir o todos o ninguno, según lo que devuelva la función de filtrado), recibiremos ese número de elementos en la colección filtrada, podría ser el mismo elemento repetido el número de veces deseado.
Y aquí nos ha sembrado una duda razonable. Como nosotros podemos ver la implementación, estamos razonablemente seguros de que recorremos la colección y que, por tanto, nuestro algoritmo es correcto. Pero, ¿qué haríamos si no supiésemos nada de la implementación? ¿Cómo testeamos eso?
En ese caso, tendríamos que introducir instancias diferentes en la colección original y ver si la colección filtrada tiene ambas. En principio, podríamos comprobar si ambas colecciones son iguales (que no la misma).
Pero para probar eso no necesitamos hacer otro test, sino arreglar el último que hemos hecho ya que no demuestra que hayamos iterado la colección, siendo ese su objetivo. Creo que nos basta montar la colección con un objeto y con su clon. Y después ver si la colección resultante equivale a la original. No nos hace falta triangular que la colección probada y la filtrada no son la misma instancia, pues es algo que hemos demostrado al principio.
Mi apuesta es que el test pasará.
Y lo hace.
Ahorrando algunos tests con return type y type hinting
La primera regla de TDD dice que lo primero es escribir el test más sencillo posible que falle (y no compilar es fallar). Esto quiere decir que si el test no se puede ejecutar porque hemos cometido un error al escribir la implementación, o aún no la hemos escrito, es lo mismo que decir que el test falla. El error nos dice qué tenemos que hacer.
En PHP podemos hacer equivalente no compilar con tener algún tipo de error que impida que el test se ejecute.
Eso nos permite evitar escribir unos cuantos tests. Es algo que no he tenido en cuenta mientras escribía estos artículos y me gustaría comentar.
En PHP 7, como ocurría hace tiempo con otros lenguajes, ya es posible definir el tipo de retorno de métodos y funciones. Si lo que devuelve el método o función no coincide con el tipo declarado se lanzará un error. Y si estamos escribiendo un test, quiere decir que el test fallará.
En la práctica esto significa que realmente no necesitamos escribir tests que prueben explícitamente el tipo que devuelve una función o método: si declaramos el tipo de retorno y no coincide, el intérprete de PHP lanzará un error y cualquier test que pruebe ese código fallará.
Por ejemplo, el primer test de filter
comprobaba justamente eso:
Pero usando return type, el test resulta innecesario, ya que el intérprete me obliga a devolver el tipo declarado. Esto es, el siguiente código:
No funcionaría porque el intérprete lanza un error, y no se ejecuta el código hasta que devolvemos un Collection
, haciendo el test innecesario por redundante. Si más adelante la implementación provocase devolver un objeto que no fuese Collection
, el propio intérprete haría fallar todos los tests implicados.
Es más, incluso es posible que nuestro test del tipo devuelto sea insuficiente si el método puede tener varios puntos de salida, con la posibilidad de devolver cosas diferentes:
Este código podría no hacer fallar el test del tipo devuelto si $someCondition
no se cumple al ejecutarlo (en el caso de que el test no contemple la posibilidad de que haya varios puntos de retorno), aunque sí podría hacer que fallasen otros.
Pero con return type el intérprete fallará en el momento en que el flujo intente retornar por la rama del if
, haya o no haya tests que lo comprueben explícitamente.
Ocurre lo mismo si hacemos Type hinting en los parámetros de los métodos, incluso de los privados, si el parámetro que se pasa no es del tipo indicado, se lanzará un error y los tests correspondientes fallarán. Eso nos indica, además, que es una buena práctica hacer type hinting en los métodos privados para aumentar la confianza en ese código. Si la implementación cambia en el futuro y deja de respetarse el tipo del parámetro, los tests que ejecuten esa llamada fallarán, alertándonos de una regresión.
Los programadores de otros lenguajes fuertemente tipados llevan años disfrutando de esta ventaja y es una práctica que merece la pena adoptar.
¿Algo que refactorizar?
Ahora que hemos avanzado tanto y que estamos en verde puede ser buen momento para ver si hay algo que podamos refactorizar. Idealmente lo vamos haciendo en cada ciclo red-green-refactor, pero ocurre muchas veces que al revisar un código en otra sesión de trabajo observamos cosas que nos gustaría cambiar.
Por ejemplo, en el método map
usaba self::of
para crear la nueva colección. Creo que Collection::of
es mucho más expresivo y es lo que he usado al implementar filter
, así que lo he cambiado. Los tests siguen pasando, lo que indica que mi refactor es correcto.
Tampoco acaba de convencerme el método protected instanceCollection
, ya que hace dos cosas: instancia la nueva colección y le añade el primer elemento. Así que voy a reescribir map
para que quede un poco más claro, haciendo innecesaria la extracción de dicho método:
Volvemos a pasar los tests para asegurarnos de que no rompemos nada.
Devolver un objeto
Muy relacionado con el método filter
estaría el tener un método que nos permite recuperar un elemento de la colección que cumpla un criterio. Al igual que en el método de filtrado, pasaremos una función que encapsule ese criterio.
La diferencia es que nuestro nuevo método debe devolver el primer objeto que encuentre cumpliendo el criterio. Le vamos a llamar getBy
.
En este caso no podemos hacer return type y será necesario comprobar que el objeto recibido es del tipo deseado.
El principal problema que nos plantea este método es qué hacer en caso de que no existan elementos de la colección que cumplan los criterios definidos. Las opciones principales son retornar null
o lanzar una excepción.
En el segundo caso, la excepción expresaría el hecho de que el elemento debería estar y que lo “raro” es que no esté. Esto tiene sentido en ciertas situaciones, por ejemplo, si hacemos una búsqueda de un objeto por su ID, que sabemos que existe. Otro ejemplo es que hayamos ejecutado filter antes y que hayamos extraído los criterios de getBy
de los resultados de esa búsqueda.
Yo voy a lanzar una excepción, pero en algunas aplicaciones podría tener sentido otra opción, inclusive devolver un objeto nulo.
Empecemos por el caso de la colección vacía, que ya sabemos que es un buen test mínimo que falla:
Y el test falla porque no tenemos implementación de getBy
. Hacemos el ciclo habitual: Primero implementación vacía para que no falle el intérprete:
Fallo del test y nueva implementación mínima para pasar el test:
Al volver a verde es hora de pensar un nuevo test mínimo que falle. Si la colección no está vacía y no se encuentra el elemento buscado devolveremos una excepción, pero no del mismo tipo.
La implementación supone controlar el caso especial de colección vacía:
Pero si el objeto está en la colección no debe saltar ninguna excepción y el método devolverá el objeto encontrado. Vamos a probarlo:
Y el test falla porque tira la excepción. No está pidiendo a gritos implementar algo, ¿no? La implementación pasa por tener en cuenta el resultado de la función pasada.
De momento, vamos bien, pero ahora necesitamos estar seguros de que la función devuelve el objeto deseado y no el primero que haya. Tenemos que escribir un test mínimo que pruebe eso. Para ello, vuelvo a tirar de self-shunt, de modo que simplemente añado una propiedad que sólo está seteada en uno de los objetos, así como un método para comprobarla. De este modo es posible rastrearlo.
Este es el código del test y la parte del self-shunt.
Para que el test pase, tengo que asegurarme de que examino todos los elementos de la lista:
¿Y sabes qué? Que el test pasa y hemos terminado de implementar getBy
.
Fin del capítulo
Nuestra lista queda como sigue:
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Todavía nos quedan por desarrollar unos cuantos métodos interesantes, pero los dejaremos para el próximo capítulo.
TDD en PHP. Un ejemplo con colecciones (5)
Todavía nos quedan unas cuentas cosas pendientes en nuestra lista:
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Seleccionar cuál es la tarea que vamos a afrontar a continuación depende sobre todo de lo que deseemos o de lo que necesitemos. En un entorno de trabajo real esa decisión vendrá marcada por aquellas características a las que damos más valor y que ayudan a configurar un producto mínimo viable lo antes posible.
Pero en nuestro ejercicio la selección de la próxima tarea se mueve por otros criterios, como puede ser que nos ayude a demostrar o ilustrar algún punto concreto de la metodología de TDD. Así, en esta serie hemos trabajado en lo siguiente:
En cuanto a la metodología TDD:
- La importancia de escoger buenos tests mínimos que fallen
- Qué código mínimo de producción escribir para que el test pase
Es decir, cumplir las tres leyes de TDD de Robert C. Martin:
- No escribirás código de producción sin antes escribir un test que falle.
- No escribirás más de un test unitario suficiente para fallar (y no compilar es fallar)
- No escribirás más código del necesario para hacer pasar el test.
Y, por otro lado, algunas técnicas prácticas, como:
- Descartar o posponer los tests que no fallan a la primera (violación de la primera ley de TDD).
- Usar clases anónimas para disponer de test doubles de bajo coste y desechables.
- Usar el self-shunt cuando necesitamos algún test double, lo que nos evita tener que tirar de generadores de doubles o inventarnos clases sin necesidad. Esto es: usar la propia clase TestCase como double.
- Usar el código de producción como test para refactorizar el test: vamos modificando el test procurando que se mantenga en verde.
- Identificar casos límite al descubrir que fallan tests anteriores, y que antes pasaban, en el último paso de implementación.
Y también alguna técnica organizativa útil:
- Usar una lista de tareas para anotar en ella todas las ideas que se nos van ocurriendo, nuevos tests que deberíamos crear, etc, de modo que podamos mantener nuestra atención centrada en el test concreto en el que estamos trabajando.
Reduciendo colecciones
El primer elemento de la lista de tareas es implementar el método reduce
. El concepto de reduce
consiste en “resumir” la colección en un valor que agregue de algún modo sus elementos por medio de la función que le pasemos. Para ello, reduce
tiene que poder arrastrar un acumulador que sea actualizado y devuelto por la función reductora. También podemos necesitar un valor para iniciar ese acumulador.
reduce
puede devolver cualquier cosa, desde un número a un array o incluso algún objeto. No hay limitaciones aquí. Lo más importante es que aquello que devuelva la función de reducción debe pasársele como parámetro, junto con el elemento actual.
En fin, ¿cuál podría ser el test más sencillo que falle para este método? Pues siguiendo la línea de los artículos anteriores podemos empezar por el test de la colección vacía. Una colección vacía no acumularía nada ni podría reducirse a nada, así que parece bastante razonable esperar que nos devuelva null
. Lo malo es que ese test va a pasar a la primera puesto que cualquier método que no devuelva nada explícitamente devolverá null
.
Por lo tanto, este test no nos vale. ¿Qué podríamos hacer entonces? Resulta que hemos mencionado que podríamos pasar un valor inicial del acumulador, por lo que en el caso de la lista vacía podríamos devolver ese mismo valor ya que al no tener elementos que iterar no se podría aplicar la función de reducción.
El test fallará por razones obvias y nos pide crear el método reduce
, cosa que ya podemos hacer con la implementación obvia devolviendo 0
, es decir, el mínimo código para que el test pase.
Bien, ¿y por qué no devolver directamente el valor que pasamos en $initial
?
Después de un tiempo practicando TDD puedes pensar que este baby step es demasiado baby y que puedes lidiar con confianza con algunos pasos más grandes. Y no te equivocarías. Como he mencionado en algún momento de la serie, estos pasos se van adaptando a las circunstancias y los puedes ampliar o reducir dependiendo, precisamente, de tu confianza en lo que estás haciendo.
Pero yo ahora prefiero hacer que los tests me vayan marcando el camino. Así, en lugar de dar un paso grande, voy a dar uno más pequeño, que además me servirá para probar que $initial
puede ser cualquier tipo de valor. Crearé otro test.
Este test falla y, al fallar, me fuerza a una nueva implementación no tan obvia y más general.
Si usase la implementación obvia mínima para pasar este nuevo test, que sería devolver la cadena vacía, el test anterior dejaría de pasar. Eso indica que tengo que implementar algo que pueda satisfacer ambos tests a la vez. Y eso, niñas y niños, es la razón por la que deberíamos dar pasos cortos para forzar que los tests nos digan lo que debemos hacer.
En este caso, la implementación más sencilla para eso es devolver el propio parámetro.
Hemos dicho que reduce
puede devolver cualquier cosa, pero pasando un valor inicial es bastante lógico suponer que el tipo devuelto por reduce
es el mismo que el del valor inicial que se pasa. Debería ser obvio que probar esto, en este momento, es inútil puesto que al devolver lo mismo que recibimos el test no nos va a aportar nada. Por tanto, deberíamos buscar otra cosa para probar.
Por ejemplo, podríamos probar que la función de reducción se aplica para una colección de un elemento.
Nuestra función de reducción de prueba es muy sencilla y se limita a incrementar el acumulador que se le pasa como segundo parámetro, así que nuestro nuevo test podría ser este:
Como el test falla, implementemos algo para que pase:
Y, aunque el nuevo test pasa, se nos rompen los dos test anteriores. Nuestra implementación tiene que lidiar con un caso límite que, ¡sorpresa! es el de la colección vacía.
Y con esta implementación volvemos a verde.
He mencionado varias veces que la colección vacía es un caso límite, pero no he explicado cómo podemos decir esto. Aprovecho ahora:
La colección vacía es un caso límite porque no puede ser tratado por el algoritmo general. Es una situación especial que no cumple los supuestos que asumimos respecto a las situaciones cubiertas por el algoritmo. Normalmente podemos detectar estos casos con TDD cuando falla un test anterior a la implementación de una solución general.
Podemos prever algunos casos límite si conocemos el dominio. Por ejemplo, en el caso de las colecciones, tenemos tres casos claros:
- La colección no tiene ningún elemento.
- La colección tiene un elemento.
- La colección tiene más de un elemento.
Por esa razón intentamos crear tests que cubran las tres situaciones. Al hacerlo podemos descubrir varias cosas:
- Al implementar una solución más general para pasar el test de un caso, se rompen tests previos: eso indicaría que los tests rotos se aplican sobre un caso especial.
- Al implementar una solución más general para pasar el test de un caso, no se rompen tests previos: indicaría que los casos tratados por esos tests no son especiales.
- Al crear un nuevo test para probar otro caso, el test falla: indicaría que no hemos implementado una solución lo bastante general.
- Al crear un nuevo test para probar otro caso, el test pasa a la primera: indicaría que ya hemos implementado una solución general.
En principio nos quedaría probar con una colección de más elementos. El resultado de este test es previsible: tenemos un fallo porque la solución no es lo bastante general.
La razón es que no estamos iterando:
Y con esto resulta que hemos conseguido implementar reduce
. Algo que podemos tachar de la lista de tareas.
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Métodos útiles para nuestras colecciones
En nuestra lista nos quedan varios métodos que pueden ser de utilidad para crear nuestras colecciones.
El primero de ellos tiene que ver con la posibilidad de crear una colección a partir de un array
, se supone que de objetos.
En este caso, parece buena idea usar un named constructor, que instancie una nueva colección a partir de un array que contenga al menos un objeto. Si el array estuviese vacío no podríamos instanciar Collection
porque no sabríamos el tipo de objetos que contiene, salvo que se lo indicásemos explícitamente, que es lo que hacemos con Collection::of.
Por otra parte, pueden existir arrays no válidos, aparte del vacío, como aquellos que no contengan objetos o que lleven mezclados objetos de distinto tipo, con elementos que no sean objetos.
Así que tenemos que poner algunas reglas para definir el comportamiento de este método, que será lo que testeemos:
- Si el array está vacío, lanzar una excepción.
- Si el primer elemento del array no es un objeto válido lanzar una excepción.
- Si el array tiene al menos un elemento que es un objeto, crear la colección, tomando como tipo el del primer objeto presente en el array.
- Una vez determinado el tipo de la colección, añadimos todos los objetos de ese tipo.
- Si encontramos algún objeto de otro tipo lanzamos una excepción.
Así que ahora tenemos una lista específica de tareas para desarrollar este método.
¿Cuál sería el mejor punto para empezar? Podríamos hacerlo siguiendo la lista de tareas. Otro enfoque sería comenzar por la situación válida más sencilla (la tercera de nuestra lista) y añadir posteriormente las demás. La verdad es que, como veremos, va a dar un poco igual.
Particularmente no me gusta comenzar por un caso que lanza una excepción, se llaman así por ser excepcionales, así que me voy directamente al primer caso de uso normal y decido que este será el test mínimo:
El test falla porque no existe el método collect
. Lo creamos y observamos que vuelve a fallar porque no devolvemos nada y es, por tanto, momento de implementar alguna solución.
La implementación más sencilla podría ser esta:
Que nos sirve para pasar el test.
Ahora quiero probar que el método toma en cuenta el array que le pasamos para instanciar la clase. Para eso hago un test que falle.
Y como falla, me obliga a implementar. Si ahora forzase a crear una Collection
con CollectionTest::class
el test anterior fallaría, por lo que debo implementar una solución más general.
Este test pasa, pero falla el anterior. Como hemos visto antes, un test anterior que falla suele implicar un caso límite que aparece al intentar generalizar un algoritmo. Pero es que este caso coincide con uno de los casos que queríamos controlar en particular, el array vacío que iba a generar una excepción.
Necesitamos un test que compruebe específicamente este caso. Con esto me doy cuenta de que he comenzado por un test que no sirve, lo que me muestra que siguiendo la metodología TDD los tests parecen cuidarse a sí mismos. Es decir: incluso no teniendo las cosas muy claras al principio, TDD nos va llevando hacia un camino productivo.
En resumidas cuentas, eliminamos el test malo y preparamos un test adecuado a lo que queremos probar ahora:
Hay que implementar para volver a verde:
Ahora tenemos que probar que collect
es capaz de llenar la colección con los objetos que se encuentran en el array. El test mínimo que lo demuestra podría ser este:
Y una implementación mínima sería la siguiente:
Para forzarnos a implementar el método general necesitamos un nuevo test, que pruebe que un array de varios elementos genera una colección con esos elementos.
Para pasar el test, ya podríamos implementar el método general:
La siguiente tarea que tenemos es lanzar una excepción si algún elemento del array no es del tipo adecuado para la colección. Podríamos hacer un test para probarlo, pero este test va a pasar a la primera.
Esto era de esperar porque ya estaba contemplado en el método append
, al que recurrimos para añadir los elementos del array a la colección en vez de incluirlos a mano en el almacén interno. Este patrón se llama self-encapsulation y consiste precisamente en que una clase utiliza internamente métodos para alterar sus propiedades, en vez de manejarlas directamente, de tal manera que estos métodos pueden encapsular guardas, saneamientos y otras operaciones.
Ahora podemos considerar que hemos terminado de implementar el método collect
. Es momento de refactorizarlo.
Los tests nos protegen contra problemas derivados de los cambios que hagamos. Al refactorizar sólo estamos cambiando la implementación, no la interfaz ni el comportamiento público, y eso es lo que nos aseguran los tests en este momento.
Aquí está nuestra lista de tareas actualizada.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Devolviendo el contenido de la colección
Usar colecciones puede ser muy útil y elegante, pero si interactuamos con código de terceros es muy posible que necesitemos disponer del contenido de la colección en un array. Lo cierto es que lo estamos almacenando internamente en un array por lo que, simplemente, podríamos devolverlo y punto.
Pero, como siempre, deberíamos probar eso con un test.
Como suele pasar con estos tests iniciales, no existe el método y nos pide una implementación mínima, que es bastante obvia.
Para que sea útil, el método debe trabajar con Collections que tengan algún elemento.
La siguiente implementación obvia romperá nuestro test anterior sobre la colección vacía:
Así que hay que contemplar el caso límite, cosa que no nos debería sorprender:
No merece la pena probar nuevos tamaños de colección, cualquier test que se nos ocurra al respecto pasará y, por tanto, no aportará ninguna información que nos fuerce a realizar cambios en la implementación.
Pero lo cierto es que también planteamos un método mapToArray
. La idea es la siguiente:
En algunas ocasiones nos interesa convertir nuestros objetos a una estructura de array asociativo (diversos mecanismos de persistencia nos piden esto). Por desgracia nuestra definición de Collection
impide que podamos mapear los objetos como array para generar una “colección de arrays”, aunque existe un atajo:
Esta solución funciona, pero sería interesante encapsularla, de modo que fuese más fácil de usar. Una posibilidad es crear un método mapToArray
, pero ¿por qué no encapsularla en toArray
pasando la función de conversión a array como un parámetro opcional? Al fin y al cabo, generar un array a partir de la colección es el caso más simple de mapeo.
Por supuesto, debemos probar esto con un test.
El caso de la colección vacía ya lo hemos probado con el test anterior, por lo que podemos pasar al siguiente test mínimo:
Como no hemos implementado ningún mapeo, el test no pasa.
La forma de hacerlo pasar es sencilla:
Con esto, el test pasa, pero rompemos un test anterior, el de la definición actual del método toArray
. Es buena cosa, porque nos obliga a implementar algo diferente.
Por ejemplo, esto:
Nos queda menos. El siguiente test probará que podemos mapear dos elementos en el array, pero aquí voy a hacer algo que puede parecer un churro pero que me va a servir para hacer una explicación que hasta ahora he pasado por alto sobre la naturaleza de los baby-steps.
Pero primero, el test:
Falla. Implementemos una solución:
¿Cómo te quedas?
Nuestro último test pasa, nuestro test anterior se rompe. Este baby-step parece ridículo, pero no lo es, de ningún modo. Vamos a ver lo que nos aporta:
- En primer lugar, nos ha permitido tener un test que pasa y que es válido, facilitándonos cambiar una implementación para cubrir un nuevo caso.
- Pero al fallar un test anterior, nos dice que debemos buscar una implementación que pueda dar cuenta de los dos tests. Es decir, un algoritmo más general.
- En tercer lugar, la propia solución apunta que debemos iterar elementos para lograr el resultado deseado.
Así que vamos a implementar de otra manera, en este caso, dando un paso un poco más largo:
Esta implementación ya es lo bastante general como para que no necesitemos más tests. Posiblemente podamos refactorizar nuestra solución y hacerla más concisa:
La lista se reduce y ya estamos acabando:
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Métodos de utilidad
Tenemos un par de métodos de utilidad para nuestra Collection
y que no hubiera estado de más implementar antes. Lo bueno es que serán fáciles de implementar y nos servirán para aprender un par de cosas más:
Testear un método que va a dar un resultado obvio como un getter no tiene mucho sentido, a no ser que exista una expectativa razonable de que no va a ser un getter “tonto” y que, con el tiempo, podría recibir algún tipo de implementación. En ese caso, el test nos serviría para cubrir una posible regresión.
Pero en muchos casos estos test simplemente no se hacen hasta que son necesarios. Los únicos beneficios que se me ocurre que podría ofrecer el test de un getter “tonto” serían:
- Forzarnos a hacer la implementación
- Contribuir al índice de cobertura de código
La implementación es obvia:
Por último, isEmpty
tiene un poco más de comportamiento. Es un método de utilidad para encapsular una información que podemos obtener de otra manera, aunque un poco más alambicada:
Hagamos un test que falle:
Obviamente nos pide implementar y devolver true
:
Pero si la colección tiene elementos, debería devolver false
.
Y la implementación necesaria es sencilla:
Y, con esto, terminamos.
Refactor final
Hemos desarrollado nuestra clase Collection
y tachado todos los elementos de la lista. Seguramente queda mucho campo para mejorar esta clase y, tal vez, implementar más métodos. Por el momento, la dejamos así.
Puede ser buen momento para refactorizar el código, que está completamente protegido por los tests. De este modo, podemos encontrar implementaciones mejores o más elegantes que, en un futuro, nos permitan intervenir sobre el código, bien para corregir problemas, bien para añadir nuevas funcionalidades o modificar comportamientos de la clase.
Por mi parte, voy a revisar cuestiones como los return type de los métodos y refactorizar algunas cosas con auto-encapsulación y, si fuese posible, eliminar algunos bucles. También puede ser el momento de reordenar los métodos para agruparlos por afinidad. Este ha sido el resultado:
También podríamos refactorizar el test. Ahora que hemos creado algunos métodos de utilidad como isEmpty
o getType
, podemos cambiar algunos tests para emplearlos, de modo que sean más sencillos y más explícitos. También nos permiten eliminar las aserciones sobre propiedades privadas, que aunque se pueden hacer no deberían hacerse si es posible evitarlo.
A mí me ha quedado así:
Apéndices
- PHPUnit Instrucciones básicas para instalar y configurar phpunit en un proyecto.
- PHPSpec Instrucciones básicas para instalar y configurar phpspec en un proyecto, junto con una pequeña guía para entender sus similitudes y diferencias con phpunit.
- Codeception Instrucciones básicas para instalar y configurar codecepcion en un proyecto, junto con una pequeña guía para entender sus similitudes y diferencias con phpunit.
- Talking Bit dojo + PHPStorm Entorno dockerizado para practicar testing con PHP 7.2, Symfony 4 y PHPUnit preinstalados.
Apéndice 1: PhpUnit
PhpUnit es el framework de test por excelencia de PHP. Pertenece a la familia xUnit y con él puedes desarrollar todo tipo de tests.
Instalación
Nos situamos dentro de la carpeta del proyecto, creándola si es necesario:
Dentro del proyecto asumimos la convención de tener las las carpetas src
y tests
Si no lo hemos hecho antes, iniciamos el proyecto mediante composer init
y como primera dependencia requerimos phpunit
.
Por último, configuraremos los namespaces del proyecto en composer.json, que quedará más o menos así:
También hemos añadido la clave config
, con bin-dir,
de este modo, los paquetes como phpunit
y otros crearán un alias de su ejecutable en la carpeta bin
, con lo que podremos lanzarlos fácilmente con bin/phpunit
.
Después de este cambio puedes hacer un composer install
o un composer dump-autoload
, para ponerte en marcha.
Configuración básica
phpunit
necesita un poco de configuración, así que vamos a prepararla ejecutando lo siguiente. Es un interactivo y normalmente nos servirán las respuestas por defecto:
Esto generará un archivo de configuración por defecto phpunit.xml
(más información en este artículo). Normalmente hago un pequeño cambio para poder tener medida de cobertura en cualquier código y no tener que pedirlo explícitamente en cada test, poniendo el parámetro forceCoversAnnotation
a false
:
Si es necesario, añadimos el control de versiones:
Y con esto, podemos empezar.
Apéndice 2: PhpSpec
Phpspec es un framework para BDD (Behavior Driven Design). Se trata de una variante de TDD que se centra en la descripción del comportamiento de los objetos mediante ejemplos.
Principalmente es una herramienta de diseño, y no tanto de testing, aunque es válida para test unitarios, y en cualquier caso no puede usarse para test de integración o de aceptación. Para esos casos utilizaríamos behat, una herramienta de la misma familia, phpunit o codeception.
Instalación
Nos situamos dentro de la carpeta del proyecto, creándola si es necesario:
Dentro del proyecto asumimos la convención de tener las las carpetas src
y spec
. Esta última se crea automáticamente, por lo que este paso es opcional.
Si planeas usar plantillas de código (ver más abajo) debes crear la carpeta .phpspec en la raíz del proyecto.
Si no lo hemos hecho antes, iniciamos el proyecto mediante composer init
y como primera dependencia requerimos phpspec
.
Configuración inicial
En cuanto a la configuración, este sería un buen composer.json mínimo para usar phpspec con soporte de PSR-4. También puedes configurar el autoload
para que use PSR-0.
Si basas el autoload en PSR-0 no necesitarías nada más, pero como nosotros lo vamos a configurar para PSR-4, aquí tienes un archivo phpspec.yml de ejemplo (y que podríamos ampliar más adelante para dar soporte a otras características).
-
example_suite: un nombre para la suite de specs que queramos configurar.
-
namespace: es la raíz del namespace que hayas definido en composer.json bajo
autoload: psr-4
. - psr4-prefix: en principio, coincide con la anterior, pero se refiere a la ruta que debe usar en el sistema de archivos para guardar el código.
- spec_prefix: es el nombre de la carpeta que contiene los archivos de especificaciones, que se nombran con el sufijo *Spec.php.
-
src_path: es la ruta bajo la que se guardará el código generado. En el ejemplo %paths.config% apunta a la raíz del proyecto y la carpeta es
src
. - spec_path es la ruta bajo la que se almacenarán las especificaciones, creándose en ella la carpeta definida en spec_prefix.
-
namespace: es la raíz del namespace que hayas definido en composer.json bajo
Para nota: plantillas
phpspec es capaz de ayudarnos con los pasos más tediosos de la generación de código, para lo que utiliza un sistema de plantillas que se pueden personalizar guardando archivos con extensión .tpl en una carpeta .phpspec en la raíz del proyecto. Por ejemplo:
class.tpl
specification.tpl
Specification by example
En el fondo, las especificaciones mediante ejemplos son equivalentes a los tests de phpunit, pero la forma particular de realizarlas nos ayuda a analizar la clase en cuanto a su comportamiento.
Por ejemplo, en phpunit escribiríamos un test como este:
En phpspec, lo equivalente sería escribir el siguiente ejemplo:
Veamos las diferencias una a una:
En phpspec
- Los TestCase se llaman Specification.
- Los tests se llaman ejemplos y se nombrar comenzando por
it_
oits_
. - En una Specification
$this
es un proxy a nuestro Subject Under Test. - En lugar de assertions usamos matchers, que verifican lo que devuelve el método probado.
Además de estas diferencias que se pueden observar, en phpspec:
- No se pueden aplicar matchers sobre otra cosa que no sean los métodos del Subject Under Test, dado que
$this
es un proxy que captura la salida del método original y nos permite testearla. - Se pueden definir TestDoubles de forma muy sencilla que son generados mediante el framework Prophecy. Basta indicarlos como parámetros en los ejemplos, tipados con la interfaz o clase que queremos doblar.
Primera especificación
Para ahorrarnos un poco de trabajo phpspec se maneja con dos comandos principales:
- describe: con el que inicializamos la descripción o spec de una clase.
- run: con el que ejecutamos los tests.
Describe
El primero es describe y nos permite iniciar la descripción de una clase a través de ejemplos.
Tomando como punto de partida la configuración que acabamos de hacer, vamos a imaginar que queremos describir una clase Dojo\Domain\Customer\Customer
. Lo haríamos así:
Una forma alternativa que usa la sintaxis de los namespace es:
Al ejecutar este comando se creará una especificación inicial para esta clase, que se guardará en el archivo spec/Dojo/Domain/Customer/CustomerSpec.php.
El programa devolverá el siguiente resultado:
Y la Spec, creada en la ruta indicada, tendrá esta pinta:
spec/Domain/Customer/CustomerSpec.php
Cosas interesantes:
- La clase
CustomerSpec
viene a ser el equivalente de un TestSuite de PHPUnit, pero está creado de tal manera que$this
es usado como proxy a la claseCustomer
que es la que estamos especificando. Dicho de otra forma:$this
es nuestro SUT (Subject Under Test). - El método
it_is_initializable
es un ejemplo. Equivale a un test. Se escriben en snake_case y deben comenzar porit
oits
. - En el método podemos ver un matcher, un concepto similar a una aserción, y que, en este caso es
shouldHaveType
(el equivalente assertInstanceOf).
Run
Una vez que hemos escrito nuestra primera Spec, el siguiente paso es ejecutarla, como corresponde a una metodología TDD. Evidentemente, como nos exige el ciclo Red-Green-Refactor de TDD, no hemos creado todavía la clase Customer
, pero eso llegará en su momento.
El comando anterior ejecutará todas las Spec que pueda encontrar. Si queremos ser más precisos podemos indicarlo de varias maneras.
Por ejemplo, usando el namespace (las comillas son obligatorias):
O bien, la ruta completa al archivo de la Spec:
O bien, indicando una ruta en la que esté incluida nuestra Spec:
En cualquier caso, el test fallará y nos devolverá el siguiente resultado (los detalles pueden cambiar un poco dependiendo de la forma de invocarla):
Fíjate que al final se ofrece a crear la clase descrita por ti, para lo cual bastará con responder y
.
¿Qué ha pasado aquí?
Pues que phpspec ha creado la clase descrita en el lugar adecuado y ha vuelto a ejecutar la Spec, que ahora está en verde (no está coloreada la salida).
La clase ha sido creada así:
spec/Domain/Customer/CustomerSpec.php
En general, cada vez que phpspec se encuentre con algo que puede ayudarnos a crear nos ofrecerá la opción. Como veremos más adelante, eso incluye los nuevos métodos que podamos añadir a nuestra clase, así como Interfaces de colaboradores. Pero ahora no adelantemos acontecimientos.
A partir de ahora, nuestra tarea será ir creando nuevos ejemplos de test que fallen, ejecutarlos y, cuando fallen, implementar el código mínimo necesario para pasar.
Apéndice 3: Codeception
Codeception es un framework preparado para resolver todo tipo de necesidades de testing. Si bien, con phpunit podríamos montar cualquier tipo de test que necesite nuestro desarrollo, codeception ofrece un entorno listo tanto para test unitarios, de integración, de aceptación e incluso para Behavior Driven Development.
Instalación
Con composer sólo hay que requerir la dependencia:
Si estamos en un proyecto Symfony 4, Flex detectará que puede instalar una receta específica y generará todo lo necesario para empezar con Codeception, aunque esta solución no me convence nada.
Puedes invocar codeception en:
Opcionalmente, puedes hacer un enlace simbólico o alias para poder invocarlo desde bin
, ejecutando este comando desde la raíz del proyecto.
Si no se ha generado automáticamente, puedes preparar el proyecto para usar codeception lanzando:
Esto preparará todo lo necesario, incluyendo las carpetas en las que organizar los tests y las definiciones de las suites.
Ejemplo de uso
Para dar un ejemplo del modo en que trabaja codeception, vamos a mostrar cómo podríamos hacer una suite para probar los endpoint de nuestras API.
Test de api
Vamos a ver un ejemplo en el que testeamos una variante del ejemplo del QuickStart de Symfony en el que, en lugar de generar una página web con un número aleatorio, devolvemos una respuesta JSON con el número aleatorio generado.
Este es el controlador tal y como lo tenemos en nuestro proyecto y que está en src/Controller/LuckyController.php:
Primero, generamos una suite:
Esto creará una carpeta tests/api
y una serie de archivos necesarios para ejecutar estos tests. Entre ellos, uno de configuración de la suite, que personalizaremos para nuestro caso y que está en tests/api.suite.yml:
Nota: En el ejemplo, estoy usando un entorno docker en el que la ip del servidor web es 172.22.0.4. Este dato podría ser diferente en tu caso concreto.
Fundamentalmente, lo que hace este archivo es indicarle a codeception que utilice un actor llamado ApiTester y un helper llamado Api, lo cual nos proporcionará métodos específicos para escribir los tests de api de una manera significativa.
A continuación, vamos a generar una plantilla para un primer test:
Que nos creará lo siguiente en tests/api/LuckyCest.php:
A partir de esta plantilla, escribimos nuestro primer test:
Lo que vemos en este test es bastante interesante:
Para empezar, el parámetro $I
es el tester y nos permite escribir los test en forma de acciones de un sujeto, con lo que resultan muy expresivas y fáciles de entender.
La primera línea, $I->sendGET('/lucky/number');
, nos dice que enviamos una petición GET a una URI, que es el endpoint bajo test.
La segunda línea, $I->seeResponseCodeIs(HttpCode::OK)
, nos dice que lo que esperamos es ver que el código de respuesta de la petición anterior debería ser 200 (OK).
La última línea, $I->seeResponseMatchesJsonType(['number' => 'integer']);
, verifica que la respuesta tiene un campo number
que es un entero.
Y ejecutamos la suite mediante el siguiente comando:
Que nos da como resultado:
Fíjate que bajo codeception se encuentra el viejo phpunit dando soporte al motor de test. Sin embargo, compara este test con uno similar creado directamente en phpunit:
En comparación, el test de phpunit resulta más oscuro y técnico, mientras que el de codeception puede leerse como una descripción casi narrativa de lo que debería ocurrir en ese endpoint.
Básicamente, lo que hace codeception es poner una capa sobre phpunit que adapta el framework para que sea más fácil escribir los distintos niveles de tests, eliminando algunos de los preparativos que tendríamos que incluir para que pueda funcionar.
Apéndice 4: Talking Bit dojo + PHPStorm
En este apéndice te presentamos un entorno de desarrollo virtualizado con docker y asumiendo que usas PHPStorm como IDE.
Talking Bit Dojo es un proyecto muy simple creado para servir como base para practicar tanto los ejemplos del libro, como para experimentar cualquier idea que se te ocurra.
Se trata de un entorno dockerizado que podrías instalar en cualquier máquina en la que desees trabajar. Lo hemos preparado usando el generador que puedes encontrar en PHPDocker. Se trata de una herramienta capaz de generar los archivos y configuraciones necesarias para montar un entorno de desarrollo a medida.
En su versión actual ofrece lo siguiente:
- PHP 7.2 con varias librerías básicas (BCMath, GD, ImageMagick)
- Nginx como servidor web, aunque para practicar basta con el de PHP
- PostgreSQL
- Memcached
- XDebug
En principio, para usarlo no tienes más que hacer un fork del repositorio y ejecutar:
Uso básico de docker
Para levantar los contenedores tienes que ejecutar en la raíz del proyecto:
El flag -d
hace que el proceso pase a segundo plano. En la primera ejecución se descargarán y prepararán las imágenes, por lo que tardara un ratito en ponerse todo en marcha.
Puedes entrar a la línea de comando de cualquiera de los contenedores. Ten en cuenta que se trata de máquinas virtuales con un mínimo de herramientas instaladas. Habitualmente, la que más usarás será la que corresponde a php
, bien para verificar cosas en el código o ejecutar comandos de consola de Symfony o los que vayas creando. Puedes acceder al contenedor así:
Esto te abrirá bash
dentro del contenedor. Observarás que entras en la carpeta remota /application
que está mapeada con la raíz de tu proyecto, de modo que la carpeta del mismo y su código está allí. Por lo general, prefiero ejecutar composer
y otros comandos de la consola (como migraciones o los comandos que desarrollo) desde dentro del contenedor.
Nota: habitualmente con remoto nos referimos a los contenedores y local a nuestra máquina física.
Las ventajas de utilizar un entorno basado en Docker (o en general, sistemas virtualizados como Vagrant) son principalmente:
- Se trata de un entorno fácilmente portable a cualquier equipo en el que quieras trabajar.
- Es predecible: independientemente de la máquina que lo esté ejecutando sabes exactamente cuales son sus detalles, lo que permite que un equipo de desarrollo trabaje sobre exactamente las mismas condiciones.
- Es un entorno definido y reproducible, de modo que puedes tener, por ejemplo, la misma configuración de tus sistemas de producción en desarrollo, lo que reduce el riesgo de problema a la hora de desplegar.
Configuración de PHPStorm
Es fácil configurar PHPStorm para trabajar con este dojo, aunque hay algunas cositas un poco particulares.
Normalmente tendrás que configurar:
- El ejecutable de CLI de PHP
- La configuración del Debugger
- Los frameworks de test
Un detalle importante es que es este tipo de entornos virtualizados tenemos que considerar la dualidad entre sistema huésped y el virtualizado a la hora de definir rutas y dónde se encuentran las cosas. Para ello, PHPStorm genera diversos mapeados entre uno y otro. La mayoría de problemas con estas configuraciones vienen de mezclar rutas de archivos entre el sistema huésped y el virtual.
Otra fuente de problemas tiene que ver con el estado de las máquinas virtuales. Además del problema básico de controlar que el contenedor esté levantado y funcionado, hay que tener en cuenta que PHPStorm levanta y apaga nuestro contenedor PHP al lanzar los tests, lo que nos puede llevar a confusión si, por algún motivo, queremos entrar en él para hacer alguna operación manualmente.
Vamos a ver, entonces, como configurarlo:
PHP CLI
En PHPStorm, abres Preferencias > Languages > PHP.
En Cli Interpreter, pulsa el botón […] Para añadir un nuevo intérprete (pulsa el botón [+] en el siguiente diálogo).
Escoge la opción From Docker, Vagrant, VM, Remote… para configurarlo.
Entre las opciones que se te ofrecen a continuación, puedes escoger Docker o Docker Compose. Nosotros escogeremos Docker Compose.
Al hacerlo tendremos que indicar el archivo docker-compose.yml en el que se define nuestro entorno y que estará en la raíz del proyecto.
Dado que el docker-compose.yml define varios servicios tenemos que indicarle cuál es el que contiene el ejecutable de php, que será php-fpm. El campo PHP Executable indica cómo invocar el intérprete php desde la línea de comandos y debería rellenarse automáticamente con php
. PHPStorm entonces lo utilizará para chequear la instalación y, si todo va bien, deberías ver lo siguiente:
- PHP version: 7.2.14…
- Configuration file: apuntando a /etc/php/7.2/cli/php.ini
- Debugger: Xdebug 2.6.1
Nota: es posible que estos valores en la última versión del proyecto cambien respecto a los que mostramos aquí.
En caso de problemas, verifica que has seleccionado el archivo docker-compose y el servicio correcto.
Xdebug
La configuración de Xdebug es prácticamente automática, pero hay que tocar un par de puntos para que funcione perfectamente. En el apartado de PHPStorm Preferences > Languages > PHP > Debug dispones de una pequeña utilidad que te permitirá validar que todo es correcto.
Para que todos los puntos pasen, tendremos que modificar el archivo phpdocker/php-fpm/php-ini-overrides.ini, activando la depuración remota.
También necesitamos añadir un server_name
en el archivo de configuración de nginx, que está en phpdocker/nginx/nginx.conf:
Yo he puesto localhost
porque el objetivo de este proyecto es tener un servidor local muy sencillo para hacer ejemplos y experimentos y nunca se usará en producción.
Para aplicar estos cambios y que PHPStorm pueda verificar que funcionen tendrás que levantar los contenedores:
Si es la primera vez, el proceso tardará un poco mientras Docker descarga y prepara las imágenes. En lo sucesivo todo irá mucho más rápido.
Nuestro docker-compose.yml está definido de modo que el servidor web atiende en localhost
en el puerto 8080 cuando lo visitamos desde fuera del contenedor. Las páginas son servidas desde el directorio public
de nuestro proyecto.
Al lanzar el validador tendremos que indicar los siguientes datos:
Path to create validation string: /Path/to/project/folder/tb/public
Siendo /Path/to/project/folder/tb
la ruta de tu local a la carpeta que contiene el proyecto.
Url to validation script: http://127.0.0.1:8080/
phpunit
PHPStorm tiene una integración muy útil con los principales frameworks de test, como phpunit. Hay varias cosas que me resultan particularmente útiles:
- Poder crear conjuntos de tests (PHPStorm los llama configurations) de forma sencilla.
- Repetir la ejecución sólo de los tests que hayan fallado tras el último pase.
- Depuración integrada. Puedes ejecutar tests con el debugger activado y parar e inspeccionar el código en cualquier punto que necesites.
La integración de PHPStorm con phpunit en el entorno de este dojo tiene algunos detalles que pueden hacer un poco frustrante la configuración, pero vamos a resolverlos antes.
Lo primero que necesitamos es haber ejecutado composer para instalar el framework de test que deseamos configurar. Nosotros hemos puesto el Bridge de phpunit para Symfony (que tiene algunas peculiaridades). Si has instalado phpunit “puro”, los datos cambian ligeramente.
Nos vamos a Preferences > Languages and frameworks > PHP > Test frameworks.
Haz clic en el botón [+] para añadir PHPUnit by Remote Interpreter. Lo primero será indicar el CLI interpreter, que ya tendrás definido como php-fpm
(o el nombre que le hayas puesto). Pulsa OK para aceptarlo.
A continuación, tendrás que indicar cómo obtener la biblioteca phpunit.
Para este proyecto, con el bridge de Symfony, la solución que ha funcionado es escoger path to PHPUnit phar y poner la siguiente ruta.
En una instalación común, la ruta suele ser:
También es recomendable indicar el archivo de configuración por defecto, que será:
Es importante señalar que estamos poniendo las rutas absolutas dentro del contenedor. De otro modo, hay varias situaciones en las que PHPStorm no es capaz de encontrar los archivos necesarios para ejecutar los tests, particularmente cuando ejecutamos tests seleccionados de forma arbitraria o tests determinados dentro de un TestCase.
Un detalle importante es que cuando ejecutemos los tests desde dentro de PHPStorm éste levantará el contenedor de php-fpm
y, al terminar, lo bajará, por lo que tendrás que levantarlo de nuevo si estás probando cosas con el navegador o quieres entrar a la línea de comandos. He visto alguna solución propuesta para este problema, pero las que he probado no me convencen, pero la versión 2018 del IDE parece que lo va a solucionar.
Esto debería bastar:
Consideraciones finales
Probablemente haya aspectos de este entorno que podrían mejorarse, pero para empezar funciona bastante bien. Por tanto, si tienes alguna idea puedes hacer un pull request al repositorio del proyecto para probarlo e incorporarlo.
Notas
1Por eso no es buena práctica que los tests hagan aserciones sobre mensajes, ya que es muy fácil que queramos cambiarlos o que cambien sin que se altere realmente el comportamiento testeado provocando que el test pueda fallar por razones incorrectas.↩
2Lo que podría ser una forma de aproximarse al problema que señalaba al principio de no cubrir correctamente algunos casos en los tests de integración.↩
3Los Mocks
me plantean un problema, pues se llevan las aserciones fuera del flujo Given-When-Then del test hasta el punto de tener tests sin aserciones explícitas y, de hecho, acoplan el test a la implementación del subject under test, algo que me fastidia sobremanera porque revientan cuando necesitas hacer un cambio.↩