Testing y TDD para PHP
Testing y TDD para PHP
Fran Iglesias
Buy on Leanpub

Tabla de contenidos

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:

1 0    10                  50                      100
2 |----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
3   0 ][         10%       ][          15%         ][           20%   

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:

 1 function shouldCalculateFee()
 2 {
 3     // Given / Arrange 
 4     var consumption = 1000;
 5     var power = 1200;
 6     var optimalPower = 1150;
 7     
 8     // When / Act
 9     var fee = calculateFee(consumption, power, optimalPower);
10     
11     // Then / Assert
12     if (45 == fee) {
13         return 'ok'
14     } 
15     
16     throw Exception('CalculateFee failed')
17     
18 }

Usando un framework el test podría ser así:

 1 function shouldCalculateFee()
 2 {
 3     // Given / Arrange 
 4     var consumption = 1000;
 5     var power = 1200;
 6     var optimalPower = 1150;
 7     
 8     // When / Act
 9     $fee = calculateFee(consumption, power, optimalPower);
10     
11     // Then / Assert
12     assertEquals(45, fee);
13 }

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.

Why does Kent Beck refer to the “rediscovery” of test-driven development? What’s the history of test-driven development before Kent Beck’s rediscovery?

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:

1 $result = // exec some software unit
2 
3 if ($expected === $result) {
4 	echo 'OK: the software unit works as expected';
5 } else {
6 	echo 'Something is wrong!'
7 }

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. 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”.
  2. 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:

 1 function triple(float $number)
 2 {
 3     return $number * 3;
 4 }
 5 
 6 function test()
 7 {
 8     $result = triple(5);
 9     
10     if (15 === $result) {
11         echo 'Pass';
12     } else {
13         echo 'Fail';
14     }
15 }
16 
17 test();
18 
19 >>> Pass

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

  1 <?php
  2 declare(strict_types=1);
  3 
  4 namespace Dojo\Shop;
  5 
  6 use ArrayIterator;
  7 use Countable;
  8 use DomainException;
  9 use Iterator;
 10 use IteratorAggregate;
 11 use UnderflowException;
 12 
 13 class Cart implements IteratorAggregate, Countable
 14 {
 15     /** @var string */
 16     private $id;
 17     /** @var array */
 18     private $lines;
 19 
 20     public function __construct(string $id)
 21     {
 22         $this->id = $id;
 23         $this->lines = [];
 24     }
 25 
 26     public static function pickUp(): Cart
 27     {
 28         $id = md5(uniqid('cart.', true));
 29 
 30         return new static($id);
 31     }
 32 
 33     public static function pickUpWithProduct(
 34         ProductInterface $product,
 35         int $quantity
 36     ): Cart {
 37         $cart = static::pickUp();
 38 
 39         $cart->addCartLine(new CartLine($product, $quantity));
 40 
 41         return $cart;
 42     }
 43     
 44     public function drop()
 45     {
 46         $this->lines = [];
 47     }
 48 
 49     public function id(): string
 50     {
 51         return $this->id;
 52     }
 53 
 54     public function addProductInQuantity(
 55         ProductInterface $product,
 56         int $quantity
 57     ): void {
 58         $this->addCartLine(new CartLine($product, $quantity));
 59     }
 60 
 61     public function removeProduct(ProductInterface $product): void
 62     {
 63         if (!isset($this->lines[$product->id()])) {
 64             throw new UnderflowException(
 65                 sprintf('Product %s not in this cart', $product->id())
 66             );
 67         }
 68 
 69         unset($this->lines[$product->id()]);
 70     }
 71     
 72     public function amount(): float
 73     {
 74         return array_reduce(
 75             $this->lines,
 76             function (
 77                 float $accumulated,
 78                 CartLine $line
 79             ) {
 80                 $product = $line->product();
 81                 $accumulated += $product->price() * $line->quantity();
 82                 return $accumulated;
 83             },
 84             0
 85         );
 86     }
 87 
 88     public function totalProducts(): int
 89     {
 90         return array_reduce(
 91             $this->lines,
 92             function (
 93                 int $accumulated,
 94                 CartLine $line
 95             ) {
 96                 $accumulated += $line->quantity();
 97 
 98                 return $accumulated;
 99             },
100             0
101         );
102     }
103 
104     public function isEmpty()
105     {
106         return 0 === $this->count();
107     }
108 
109     public function count(): int
110     {
111         return \count($this->lines);
112     }
113 
114     public function getIterator(): Iterator
115     {
116         return new ArrayIterator($this->lines);
117     }
118 
119     private function addCartLine(Cartline $cartLine): void
120     {
121         $product = $cartLine->product();
122         $this->lines[$product->id()] = $cartLine;
123     }
124 }

¿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:

1 $cart = Cart::pickUp();

Y lo que queremos comprobar es que se le ha asignado un identificador y que no contiene ningún producto.

1 $id = $cart->id();
2 $numberOfProducts = $cart->count();

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

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 use Phpunit\Framework\TestCase;
 7 
 8 class CartTest extends TestCase
 9 {
10 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 use Phpunit\Framework\TestCase;
 7 
 8 class CartTest extends TestCase
 9 {
10     public function testShouldInstantiateAnEmptyCartWithId(): void
11     {
12         
13     }
14 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 use Phpunit\Framework\TestCase;
 7 
 8 class CartTest extends TestCase
 9 {
10     /** @test */
11     public function shouldInstantiateAnEmptyCartWithId(): void
12     {
13 
14     }
15 }

¿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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 use Phpunit\Framework\TestCase;
 7 
 8 class CartTest extends TestCase
 9 {
10     public function testShouldInstantiateAnEmptyCartIdentifiedWithAnId(): void
11     {
12         
13     }
14 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 use Phpunit\Framework\TestCase;
 7 
 8 class CartTest extends TestCase
 9 {
10     public function testShouldInstantiateAnEmptyCartIdentifiedWithAnId(): void
11     {
12         $cart = Cart::pickUp();
13         
14         $this->assertNotEmpty($cart->id());
15         $this->assertEquals(0, $cart->totalProducts());
16     }
17 }

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:

1 bin/phpunit Dojo\Shop\CartTest tests/Dojo/Shop/CartTest.php

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.

1 $cart = Cart::pickUpWithProduct($product, 1);
1 $id = $cart->id();
2 $numberOfProducts = $cart->count();

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í:

 1     public function testShouldInstantiateCartWithAPreselectedProduct(): void
 2     {
 3         $product = $this->getProduct('product-1', 10);
 4         $cart = Cart::pickUpWithProduct($product, 1);
 5 
 6         $this->assertNotEmpty($cart->id());
 7         $this->assertEquals(1, $cart->totalProducts());
 8     }
 9     
10     private function getProduct($id, $price): ProductInterface
11     {
12         //...
13     }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 interface ProductInterface
 7 {
 8     public function id(): string;
 9 
10     public function price(): float;
11 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo\Shop;
 5 
 6 class Product implements ProductInterface
 7 {
 8     /** @var string */
 9     private $id;
10     /** @var float */
11     private $price;
12 
13     public function __construct(string $id, float $price)
14     {
15         $this->id = $id;
16         $this->price = $price;
17     }
18 
19     public function id(): string
20     {
21         return $this->id;
22     }
23 
24     public function price(): float
25     {
26         return $this->price;
27     }
28 }

En consecuencia, simplemente podríamos crear un producto instanciando esa clase. Nuestro método getProduct, quedaría así;

1     private function getProduct($id, $price): ProductInterface
2     {
3         return new Product($id, $price);
4     }

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í:

1     private function getProduct($id, $price): ProductInterface
2     {
3         /** @var ProductInterface | MockObject $product */
4         $product = $this->createMock(ProductInterface::class);
5         $product->method('id')->willReturn($id);
6         $product->method('price')->willReturn($price);
7         
8         return $product;
9     }

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.

1 $product->method('id')->willReturn($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.

1 public function testShouldAddAProduct(): void
2 {
3     $product = $this->getProduct('product-1', 10);
4     $cart = Cart::pickUp();
5 
6     $cart->addProductInQuantity($product, 1);
7     
8     $this->assertCount(1, $cart);
9 }

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:

 1 public function testShouldAddAProductInQuantity(): void
 2 {
 3     $product = $this->getProduct('product-1', 10);
 4     $cart = Cart::pickUp();
 5 
 6     $cart->addProductInQuantity($product, 10);
 7     
 8     $this->assertCount(1, $cart);
 9     $this->assertEquals(10, $cart->totalProducts());
10 }

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.

 1 public function testShouldAddSeveralProductsInQuantity(): void
 2 {
 3     $product1 = $this->getProduct('product-1', 10);
 4     $product2 = $this->getProduct('product-2', 15);
 5     
 6     $cart = Cart::pickUp();
 7 
 8     $cart->addProductInQuantity($product1, 5);
 9     $cart->addProductInQuantity($product2, 7);
10 
11     $this->assertCount(2, $cart);
12     $this->assertEquals(12, $cart->totalProducts());
13 }

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:

 1 public function testShouldAddSameProductsInDifferentMoments(): void
 2 {
 3     $product1 = $this->getProduct('product-1', 10);
 4     $product2 = $this->getProduct('product-2', 15);
 5 
 6     $cart = Cart::pickUp();
 7 
 8     $cart->addProductInQuantity($product1, 5);
 9     $cart->addProductInQuantity($product2, 7);
10     $cart->addProductInQuantity($product2, 3);
11 
12     $this->assertCount(2, $cart);
13     $this->assertEquals(15, $cart->totalProducts());
14 }

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:

 1 public function testShouldAddSameProductsInDifferentMoments(): void
 2 {
 3     $product1 = $this->getProduct('product-1', 10);
 4     $product2 = $this->getProduct('product-2', 15);
 5 
 6     $cart = Cart::pickUp();
 7 
 8     $cart->addProductInQuantity($product1, 5);
 9     $cart->addProductInQuantity($product2, 7);
10     $cart->addProductInQuantity($product2, 3);
11 
12     $this->assertCount(2, $cart);
13 }

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.

 1 private function addCartLine(Cartline $cartLine): void
 2 {
 3     $product = $cartLine->product();
 4     
 5     if (! isset($this->lines[$product->id()])) {
 6         $this->lines[$product->id()] = $cartLine;
 7 
 8         return;
 9     }
10     
11     $newCartLine = new CartLine(
12         $product,
13         $cartLine->quantity() + $this->lines[$product->id()]->quantity()
14     );
15 
16     $this->lines[$product->id()] = $newCartLine;
17 }

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.

1 public function testEmptyCartShouldHaveZeroAmount(): void
2 {
3     $cart = Cart::pickUp();
4 
5     $this->assertEquals(0, $cart->amount());
6 }

Obviamente, a continuación deberíamos testear que se contabilizan los precios de los productos que añadimos.

1 public function testShouldCalculateAmountWhenAddingProduct(): void
2 {
3     $cart = Cart::pickUp();
4 
5     $product = $this->getProduct('product-01', 10);
6     $cart->addProductInQuantity($product, 1);
7 
8     $this->assertEquals(10, $cart->amount());
9 }

Así como que tiene en cuenta las cantidades.

1 public function testShouldTakeCareOfQuantitiesToCalculateAmount(): void
2 {
3     $cart = Cart::pickUp();
4 
5     $product = $this->getProduct('product-01', 10);
6     $cart->addProductInQuantity($product, 3);
7 
8     $this->assertEquals(30, $cart->amount());
9 }

Y que podemos combinar productos y cantidades:

 1 public function testShouldTakeCareOfQuantitiesAndDifferentProductsToCalculateAmount(\
 2 ): void
 3 {
 4     $cart = Cart::pickUp();
 5 
 6     $product1 = $this->getProduct('product-01', 10);
 7     $product2 = $this->getProduct('product-02', 7);
 8     
 9     $cart->addProductInQuantity($product1, 3);
10     $cart->addProductInQuantity($product2, 4);
11 
12     $this->assertEquals(58, $cart->amount());
13 }

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.

1 public function testShouldFailRemovingNonExistingProduct() : void
2 {
3     $cart = Cart::pickUp();
4 
5     $product = $this->getProduct('product-1', 10);
6 
7     $this->expectException(UnderflowException::class);
8     $cart->removeProduct($product);
9 }

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:

 1 public function testShouldLeaveNoProductWhenRemovingTheLastOne(): void
 2 {
 3     $cart = Cart::pickUp();
 4 
 5     $product = $this->getProduct('product-1', 10);
 6 
 7     $cart->addProductInQuantity($product, 1);
 8     $cart->removeProduct($product);
 9 
10     $this->assertEquals(0, $cart->count());
11 }

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:

 1 public function testShouldLeaveOneProduct(): void
 2 {
 3     $cart = Cart::pickUp();
 4 
 5     $product = $this->getProduct('product-1', 10);
 6 
 7     $cart->addProductInQuantity($product, 2);
 8     $cart->removeProduct($product);
 9 
10     $this->assertEquals(1, $cart->count());
11 }

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:

 1 public function removeProduct(ProductInterface $product): void
 2 {
 3     if (!isset($this->lines[$product->id()])) {
 4         throw new UnderflowException(
 5             sprintf('Product %s not in this cart', $product->id())
 6         );
 7     }
 8 
 9     $line = $this->lines[$product->id()];
10 
11     $newQuantity = $line->quantity() - 1;
12 
13     $this->lines[$product->id()] = new CartLine($product, $newQuantity);
14 }

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í:

 1 public function testShouldEmptyTheCart() : void
 2 {
 3     $cart = Cart::pickUp();
 4 
 5     $product = $this->getProduct('product-1', 10);
 6     $cart->addProductInQuantity($product, 2);
 7 
 8     $cart->drop();
 9     
10     $this->assertEmpty($cart);
11 }

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):

1 public function testShouldReportIsEmpty() : void
2 {
3     $cart = Cart::pickUp();
4     
5     $this->assertTrue($cart->isEmpty());
6 }

Como el negativo (contiene algún producto):

1 public function testShouldReportIsNotEmpty() : void
2 {
3     $cart = Cart::pickUp();
4     
5     $product = $this->getProduct('product-1', 10);
6     $cart->addProductInQuantity($product, 2);
7     
8     $this->assertFalse($cart->isEmpty());
9 }

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:

  1 <?php
  2 declare(strict_types=1);
  3 
  4 namespace Dojo\Shop;
  5 
  6 use Phpunit\Framework\MockObject\MockObject;
  7 use Phpunit\Framework\TestCase;
  8 use UnderflowException;
  9 
 10 class CartTest extends TestCase
 11 {
 12     public function testShouldInstantiateAnEmptyCartIdentifiedWithAnId(): void
 13     {
 14         $cart = Cart::pickUp();
 15 
 16         $this->assertNotEmpty($cart->id());
 17         $this->assertEquals(0, $cart->totalProducts());
 18     }
 19 
 20     public function testShouldInstantiateCartWithAPreselectedProduct(): void
 21     {
 22         $product = $this->getProduct('product-1', 10);
 23         $cart = Cart::pickUpWithProduct($product, 1);
 24 
 25         $this->assertNotEmpty($cart->id());
 26         $this->assertEquals(1, $cart->totalProducts());
 27     }
 28 
 29     public function testShouldAddAProduct(): void
 30     {
 31         $product = $this->getProduct('product-1', 10);
 32         $cart = Cart::pickUp();
 33 
 34         $cart->addProductInQuantity($product, 1);
 35         $this->assertCount(1, $cart);
 36     }
 37 
 38     public function testShouldAddAProductInQuantity(): void
 39     {
 40         $product = $this->getProduct('product-1', 10);
 41         $cart = Cart::pickUp();
 42 
 43         $cart->addProductInQuantity($product, 10);
 44         $this->assertCount(1, $cart);
 45         $this->assertEquals(10, $cart->totalProducts());
 46     }
 47 
 48     public function testShouldAddSeveralProductsInQuantity(): void
 49     {
 50         $product1 = $this->getProduct('product-1', 10);
 51         $product2 = $this->getProduct('product-2', 15);
 52         $cart = Cart::pickUp();
 53 
 54         $cart->addProductInQuantity($product1, 5);
 55         $cart->addProductInQuantity($product2, 7);
 56 
 57         $this->assertCount(2, $cart);
 58         $this->assertEquals(12, $cart->totalProducts());
 59     }
 60 
 61     public function testShouldAddSameProductsInDifferentMoments(): void
 62     {
 63         $product1 = $this->getProduct('product-1', 10);
 64         $product2 = $this->getProduct('product-2', 15);
 65 
 66         $cart = Cart::pickUp();
 67 
 68         $cart->addProductInQuantity($product1, 5);
 69         $cart->addProductInQuantity($product2, 7);
 70         $cart->addProductInQuantity($product2, 3);
 71 
 72         $this->assertCount(2, $cart);
 73         $this->assertEquals(15, $cart->totalProducts());
 74     }
 75 
 76     public function testEmptyCartShouldHaveZeroAmount(): void
 77     {
 78         $cart = Cart::pickUp();
 79 
 80         $this->assertEquals(0, $cart->amount());
 81     }
 82 
 83     public function testShouldCalculateAmountWhenAddingProduct(): void
 84     {
 85         $cart = Cart::pickUp();
 86 
 87         $product = $this->getProduct('product-01', 10);
 88         $cart->addProductInQuantity($product, 1);
 89 
 90         $this->assertEquals(10, $cart->amount());
 91     }
 92 
 93     public function testShouldTakeCareOfQuantitiesToCalculateAmount(): void
 94     {
 95         $cart = Cart::pickUp();
 96 
 97         $product = $this->getProduct('product-01', 10);
 98         $cart->addProductInQuantity($product, 3);
 99 
100         $this->assertEquals(30, $cart->amount());
101     }
102 
103     public function testShouldTakeCareOfQuantitiesAndDifferentProductsToCalculateAmo\
104 unt(): void
105     {
106         $cart = Cart::pickUp();
107 
108         $product1 = $this->getProduct('product-01', 10);
109         $product2 = $this->getProduct('product-02x', 7);
110 
111         $cart->addProductInQuantity($product1, 3);
112         $cart->addProductInQuantity($product2, 4);
113 
114         $this->assertEquals(58, $cart->amount());
115     }
116 
117     public function testShouldFailRemovingNonExistingProduct() : void
118     {
119         $cart = Cart::pickUp();
120 
121         $product = $this->getProduct('product-1', 10);
122 
123         $this->expectException(UnderflowException::class);
124         $cart->removeProduct($product);
125     }
126 
127     public function testShouldLeaveNoProductWhenRemovingTheLastOne(): void
128     {
129         $cart = Cart::pickUp();
130 
131         $product = $this->getProduct('product-1', 10);
132 
133         $cart->addProductInQuantity($product, 1);
134         $cart->removeProduct($product);
135 
136         $this->assertEquals(0, $cart->count());
137     }
138 
139     public function testShouldLeaveOneProduct(): void
140     {
141         $cart = Cart::pickUp();
142 
143         $product = $this->getProduct('product-1', 10);
144 
145         $cart->addProductInQuantity($product, 2);
146         $cart->removeProduct($product);
147 
148         $this->assertEquals(1, $cart->count());
149     }
150 
151     public function testShouldEmptyTheCart() : void
152     {
153         $cart = Cart::pickUp();
154 
155         $product = $this->getProduct('product-1', 10);
156         $cart->addProductInQuantity($product, 2);
157 
158         $cart->drop();
159 
160         $this->assertEmpty($cart);
161     }
162 
163     public function testShouldReportIsEmpty() : void
164     {
165         $cart = Cart::pickUp();
166 
167         $this->assertTrue($cart->isEmpty());
168     }
169 
170     public function testShouldReportIsNotEmpty() : void
171     {
172         $cart = Cart::pickUp();
173 
174         $product = $this->getProduct('product-1', 10);
175         $cart->addProductInQuantity($product, 2);
176 
177         $this->assertFalse($cart->isEmpty());
178     }
179 
180     private function getProduct(
181         $id,
182         $price
183     ): ProductInterface {
184         /** @var ProductInterface | MockObject $product */
185         $product = $this->createMock(ProductInterface::class);
186         $product->method('id')->willReturn($id);
187         $product->method('price')->willReturn($price);
188 
189         return $product;
190     }
191 }

¿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í:

1 $validDni = new Dni('00000000T');
2 
3 printf('%s is a valid DNI', (string) $validDni);
4 
5 //
6 
7 $invalidDni = new Dni('00000000G');
8 
9 >>> Throws Exception

¿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

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Tests\Dojo;
 5 
 6 use Dojo\Dni;
 7 use LengthException;
 8 use PHPUnit\Framework\TestCase;
 9 
10 class DniTest extends TestCase
11 {
12     public function testShouldFailWhenDniLongerThanMaxLenght()
13     {
14         $this->expectException(LengthException::class);
15         $this->expectExceptionMessage('Too long');
16         $dni = new Dni('0123456789');
17     }
18 }

Si lanzamos el test este es el resultado:

1 Failed asserting that exception of type "Error" matches expected exception "LengthEx\
2 ception". Message was: "Class 'Dojo\Dni' not found" at
3 /Users/franiglesias/PhpstormProjects/dojo/tests/Dojo/DniTest.php:16.

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):

1 <?php
2 declare(strict_types=1);
3 
4 namespace Dojo;
5 
6 class Dni
7 {
8 
9 }

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:

1 Failed asserting that exception of type "LengthException" is thrown.

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     public function __construct()
11     {
12         throw new LengthException('Too long');
13     }
14 }

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.

1 public function testShouldFailWhenDniShorterThanMinLenght(): void
2 {
3     $this->expectException(LengthException::class);
4     $this->expectExceptionMessage('Too short');
5     $dni = new Dni('01234567');
6 }

Si ahora lanzamos el test veremos que falla. Hemos decidido que se lanza el mismo tipo de excepción, pero con distinto mensaje.

1 Failed asserting that exception message 'Too long' contains 'Too short'.

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     public function __construct()
11     {
12         throw new LengthException('Too short');
13     }
14 }

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:

1 s\Dojo\Dni
2  [ ] Should fail when dni longer than max lenght
3  [x] Should fail when dni shorter than min lenght

Es decir, que tenemos que resolver el problema planteado por el test anterior primero y luego aplicar la implementación obvia.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     public function __construct(string $dni)
11     {
12         if (strlen($dni) > 9) {
13             throw new LengthException('Too long');
14         }
15         throw new LengthException('Too short');
16     }
17 }

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:

1 public function testShouldFailWhenDniEndsWithANumber(): void
2 {
3     $this->expectException(DomainException::class);
4     $this->expectExceptionMessage('Ends with number');
5     $dni = new Dni('012345678');
6 }

El mensaje que obtenemos al ejecutar el test nos dice lo que necesitamos saber:

1 Failed asserting that exception of type "LengthException" matches expected exception\
2  "DomainException". Message was: "Too short" at
3 /Users/franiglesias/PhpstormProjects/dojo/src/Dni.php:15
4 /Users/franiglesias/PhpstormProjects/dojo/tests/Dojo/DniTest.php:31
5 .

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     public function __construct(string $dni)
11     {
12         if (\strlen($dni) > 9) {
13             throw new LengthException('Too long');
14         }
15         if (\strlen($dni) < 9) {
16             throw new LengthException('Too short');
17         }
18 
19         throw new \DomainException('Ends with number');
20     }
21 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     public function __construct(string $dni)
11     {
12         if (\strlen($dni) > 9) {
13             throw new LengthException('Too long');
14         }
15         if (\strlen($dni) < 9) {
16             throw new LengthException('Too short');
17         }
18 
19         throw new \DomainException('Ends with number');
20     }
21 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         if (\strlen($dni) > self::VALID_LENGTH) {
15             throw new LengthException('Too long');
16         }
17         if (\strlen($dni) < self::VALID_LENGTH) {
18             throw new LengthException('Too short');
19         }
20 
21         throw new \DomainException('Ends with number');
22     }
23 }

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í:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         if (\strlen($dni) !== self::VALID_LENGTH) {
15             throw new LengthException(
16                 \strlen($dni) > 9 ? 'Too long': 'Too short'
17             );
18         }
19 
20         throw new \DomainException('Ends with number');
21     }
22 }

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.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         throw new \DomainException('Ends with number');
17     }
18 
19     private function checkDniHasValidLength(string $dni): void
20     {
21         if (\strlen($dni) !== self::VALID_LENGTH) {
22             throw new LengthException(
23                 \strlen($dni) > 9 ? 'Too long' : 'Too short'
24             );
25         }
26     }
27 }

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.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Tests\Dojo;
 5 
 6 use Dojo\Dni;
 7 use DomainException;
 8 use LengthException;
 9 use PHPUnit\Framework\TestCase;
10 
11 class DniTest extends TestCase
12 {
13     public function testShouldFailWhenDniLongerThanMaxLenght(): void
14     {
15         $this->expectException(LengthException::class);
16         $dni = new Dni('0123456789');
17     }
18 
19     public function testShouldFailWhenDniShorterThanMinLenght(): void
20     {
21         $this->expectException(LengthException::class);
22         $dni = new Dni('01234567');
23     }
24 
25     public function testShouldFailWhenDniEndsWithANumber(): void
26     {
27         $this->expectException(DomainException::class);
28         $this->expectExceptionMessage('Ends with number');
29         $dni = new Dni('012345678');
30     }
31 
32 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         throw new \DomainException('Ends with number');
17     }
18 
19     private function checkDniHasValidLength(string $dni): void
20     {
21         if (\strlen($dni) !== self::VALID_LENGTH) {
22             throw new LengthException('Too long or too short');
23         }
24     }
25 }

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:

1 public function testShouldFailWhenDniEndsWithAnInvalidLetter(): void
2 {
3     $this->expectException(DomainException::class);
4     $this->expectExceptionMessage('Ends with invalid letter');
5     $dni = new Dni('01234567I');
6 }

Test que, al ejecutarlo, falla:

1 Failed asserting that exception message 'Ends with number' contains 'Ends with inval\
2 id letter'.

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/\d$/', $dni)) {
17             throw new \DomainException('Ends with number');
18         }
19         throw new \DomainException('Ends with invalid letter');
20     }
21 
22     private function checkDniHasValidLength(string $dni): void
23     {
24         if (\strlen($dni) !== self::VALID_LENGTH) {
25             throw new LengthException('Too long or too short');
26         }
27     }
28 }

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:

1 public function testShouldFailWhenDniHasLettersInTheMiddle(): void
2 {
3     $this->expectException(DomainException::class);
4     $this->expectExceptionMessage('Has letters in the middle');
5     $dni = new Dni('012AB567R');
6 }

El test falla:

1 Failed asserting that exception message 'Ends with invalid letter' contains 'Has let\
2 ters in the middle'.

Como estamos en rojo, vamos a implementar algo que nos permita pasar el test:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/\d$/', $dni)) {
17             throw new \DomainException('Ends with number');
18         }
19 
20         if (preg_match('/[UIOÑ]$/u', $dni)) {
21             throw new \DomainException('Ends with invalid letter');
22         }
23         throw new \DomainException('Has letters in the middle');
24     }
25 
26     private function checkDniHasValidLength(string $dni): void
27     {
28         if (\strlen($dni) !== self::VALID_LENGTH) {
29             throw new LengthException('Too long or too short');
30         }
31     }
32 }

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:

1 public function testShouldFailWhenDniStartsWithALetterOtherThanXYZ(): void
2 {
3     $this->expectException(DomainException::class);
4     $this->expectExceptionMessage('Starts with invalid letter');
5     $dni = new Dni('A1234567R');
6 }

El código de producción que hace pasar este test es el siguiente:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/\d$/', $dni)) {
17             throw new \DomainException('Ends with number');
18         }
19 
20         if (preg_match('/[UIOÑ]$/u', $dni)) {
21             throw new \DomainException('Ends with invalid letter');
22         }
23 
24         if (! preg_match('/\d{7,7}.$/', $dni)) {
25             throw new \DomainException('Has letters in the middle');
26         }
27         throw new \DomainException('Starts with invalid letter');
28     }
29 
30     private function checkDniHasValidLength(string $dni): void
31     {
32         if (\strlen($dni) !== self::VALID_LENGTH) {
33             throw new LengthException('Too long or too short');
34         }
35     }
36 }

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í:

1 public function testShouldFailWhenInvalidDni(): void
2 {
3     $this->expectException(InvalidArgumentException::class);
4     $dni = new Dni('00000000S');
5 }

El cual falla porque no se lanza la excepción esperada:

1 Failed asserting that exception of type "DomainException" matches expected exception\
2  "InvalidArgumentException". Message was: "Starts with invalid letter" at
3 /Users/frankie/Sites/dojo/src/Dni.php:27
4 /Users/frankie/Sites/dojo/tests/Dojo/DniTest.php:57.

De nuevo, para pasar el test debemos resolver primero el problema que dejamos pendiente en el anterior:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/\d$/', $dni)) {
17             throw new \DomainException('Ends with number');
18         }
19 
20         if (preg_match('/[UIOÑ]$/u', $dni)) {
21             throw new \DomainException('Ends with invalid letter');
22         }
23 
24         if (! preg_match('/\d{7,7}.$/', $dni)) {
25             throw new \DomainException('Has letters in the middle');
26         }
27 
28         if (! preg_match('/^[XYZ0-9]/', $dni)) {
29             throw new \DomainException('Starts with invalid letter');
30         }
31         throw new \InvalidArgumentException('Invalid dni');
32     }
33 
34     private function checkDniHasValidLength(string $dni): void
35     {
36         if (\strlen($dni) !== self::VALID_LENGTH) {
37             throw new LengthException('Too long or too short');
38         }
39     }
40 }

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í:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Tests\Dojo;
 5 
 6 use Dojo\Dni;
 7 use DomainException;
 8 use InvalidArgumentException;
 9 use LengthException;
10 use PHPUnit\Framework\TestCase;
11 
12 class DniTest extends TestCase
13 {
14     public function testShouldFailWhenDniLongerThanMaxLenght(): void
15     {
16         $this->expectException(LengthException::class);
17         $dni = new Dni('0123456789');
18     }
19 
20     public function testShouldFailWhenDniShorterThanMinLenght(): void
21     {
22         $this->expectException(LengthException::class);
23         $dni = new Dni('01234567');
24     }
25 
26     public function testShouldFailWhenDniEndsWithANumber(): void
27     {
28         $this->expectException(DomainException::class);
29         $dni = new Dni('012345678');
30     }
31 
32     public function testShouldFailWhenDniEndsWithAnInvalidLetter(): void
33     {
34         $this->expectException(DomainException::class);
35         $dni = new Dni('01234567I');
36     }
37 
38     public function testShouldFailWhenDniHasLettersInTheMiddle(): void
39     {
40         $this->expectException(DomainException::class);
41         $dni = new Dni('012AB567R');
42     }
43 
44     public function testShouldFailWhenDniStartsWithALetterOtherThanXYZ(): void
45     {
46         $this->expectException(DomainException::class);
47         $dni = new Dni('A1234567R');
48     }
49 
50     public function testShouldFailWhenInvalidDni(): void
51     {
52         $this->expectException(InvalidArgumentException::class);
53         $dni = new Dni('00000000S');
54     }
55 }

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í:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/[UIOÑ\d]$/u', $dni)) {
17             throw new \DomainException('Ends with invalid letter');
18         }
19 
20         if (! preg_match('/\d{7,7}.$/', $dni)) {
21             throw new \DomainException('Has letters in the middle');
22         }
23 
24         if (!preg_match('/^[XYZ0-9]/', $dni)) {
25             throw new \DomainException('Starts with invalid letter');
26         }
27         throw new \InvalidArgumentException('Invalid dni');
28     }
29 
30     private function checkDniHasValidLength(string $dni): void
31     {
32         if (\strlen($dni) !== self::VALID_LENGTH) {
33             throw new LengthException('Too long or too short');
34         }
35     }
36 }

Y luego las negativas, aprovechando para hacerla un poco más concisa:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15 
16         if (preg_match('/[UIOÑ\d]$/u', $dni)) {
17             throw new \DomainException('Ends with invalid letter');
18         }
19 
20         if (! preg_match('/^[XYZ\d]\d{7,7}.$/', $dni)) {
21             throw new \DomainException('Starts with invalid letter');
22         }
23 
24         throw new \InvalidArgumentException('Invalid dni');
25     }
26 
27     private function checkDniHasValidLength(string $dni): void
28     {
29         if (\strlen($dni) !== self::VALID_LENGTH) {
30             throw new LengthException('Too long or too short');
31         }
32     }
33 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use LengthException;
 7 
 8 class Dni
 9 {
10     private const VALID_LENGTH = 9;
11 
12     public function __construct(string $dni)
13     {
14         $this->checkDniHasValidLength($dni);
15         
16         if (! preg_match('/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u', $dni)) {
17             throw new \DomainException('Bad format');
18         }
19 
20         throw new \InvalidArgumentException('Invalid dni');
21     }
22 
23     private function checkDniHasValidLength(string $dni): void
24     {
25         if (\strlen($dni) !== self::VALID_LENGTH) {
26             throw new LengthException('Too long or too short');
27         }
28     }
29 }

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:

1 Failed asserting that exception of type "DomainException" matches expected exception\
2  "LengthException". Message was: "Bad format" at
3 /Users/frankie/Sites/dojo/src/Dni.php:17
4 /Users/frankie/Sites/dojo/tests/Dojo/DniTest.php:17
5 .

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Tests\Dojo;
 5 
 6 use Dojo\Dni;
 7 use DomainException;
 8 use InvalidArgumentException;
 9 use PHPUnit\Framework\TestCase;
10 
11 class DniTest extends TestCase
12 {
13     public function testShouldFailWhenDniLongerThanMaxLenght() : void
14     {
15         $this->expectException(DomainException::class);
16         $dni = new Dni('0123456789');
17     }
18 
19     public function testShouldFailWhenDniShorterThanMinLenght() : void
20     {
21         $this->expectException(DomainException::class);
22         $dni = new Dni('01234567');
23     }
24 
25     public function testShouldFailWhenDniEndsWithANumber() : void
26     {
27         $this->expectException(DomainException::class);
28         $dni = new Dni('012345678');
29     }
30 
31     public function testShouldFailWhenDniEndsWithAnInvalidLetter() : void
32     {
33         $this->expectException(DomainException::class);
34         $dni = new Dni('01234567I');
35     }
36 
37     public function testShouldFailWhenDniHasLettersInTheMiddle() : void
38     {
39         $this->expectException(DomainException::class);
40         $dni = new Dni('012AB567R');
41     }
42 
43     public function testShouldFailWhenDniStartsWithALetterOtherThanXYZ() : void
44     {
45         $this->expectException(DomainException::class);
46         $dni = new Dni('A1234567R');
47     }
48 
49     public function testShouldFailWhenInvalidDni() : void
50     {
51         $this->expectException(InvalidArgumentException::class);
52         $dni = new Dni('00000000S');
53     }
54 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     public function __construct(string $dni)
12     {
13         if (!preg_match('/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u', $dni)) {
14             throw new DomainException('Bad format');
15         }
16 
17         throw new InvalidArgumentException('Invalid dni');
18     }
19 }

Los tests pasan y nuestra clase Dni es ahora más compacta, podemos mejorar un poquito su legibilidad:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12 
13     public function __construct(string $dni)
14     {
15         $this->checkIsValidDni($dni);
16 
17         throw new InvalidArgumentException('Invalid dni');
18     }
19 
20     private function checkIsValidDni(string $dni) : void
21     {
22         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
23             throw new DomainException('Bad format');
24         }
25     }
26 }

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.

1 public function testShouldConstructValidDNIEndingWithT() : void
2 {
3     $dni = new Dni('00000000T');
4     $this->assertEquals('00000000T', (string) $dni);
5 }

El test no pasará porque no hay nada implementado:

1 InvalidArgumentException : Invalid dni
2  /Users/frankie/Sites/dojo/src/Dni.php:17
3  /Users/frankie/Sites/dojo/tests/Dojo/DniTest.php:57

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     /** @var string */
13     private $dni;
14 
15     public function __construct(string $dni)
16     {
17         $this->checkIsValidDni($dni);
18 
19         if ('00000000T' !== $dni) {
20             throw new InvalidArgumentException('Invalid dni');
21         }
22         
23         $this->dni = $dni;
24     }
25 
26     public function __toString(): string
27     {
28         return $this->dni;
29     }
30 
31     private function checkIsValidDni(string $dni) : void
32     {
33         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
34             throw new DomainException('Bad format');
35         }
36     }
37 }

Y podemos seguir con otros ejemplos:

1 public function testShouldConstructValidDNIEndingWithR() : void
2 {
3     $dni = new Dni('00000001R');
4     $this->assertEquals('00000001R', (string) $dni);
5 }

Resuelto con:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     /** @var string */
13     private $dni;
14 
15     public function __construct(string $dni)
16     {
17         $this->checkIsValidDni($dni);
18 
19         if ('00000000T' !== $dni) {
20             throw new InvalidArgumentException('Invalid dni');
21         }
22         
23         if ('00000001R' !== $dni) {
24             throw new InvalidArgumentException('Invalid dni');
25         }
26 
27         $this->dni = $dni;
28     }
29 
30     public function __toString(): string
31     {
32         return $this->dni;
33     }
34 
35     private function checkIsValidDni(string $dni) : void
36     {
37         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
38             throw new DomainException('Bad format');
39         }
40     }
41 }

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:

1 public function testShouldConstructValidDNIEndingWithW() : void
2 {
3     $dni = new Dni('00000002W');
4     $this->assertEquals('00000002W', (string) $dni);
5 }

Y ahora empezamos a tratar la cadena recibida para separarla en partes, manteniendo los tests en verde.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     /** @var string */
13     private $dni;
14 
15     public function __construct(string $dni)
16     {
17         $this->checkIsValidDni($dni);
18 
19         $number = (int)substr($dni, 0, - 1);
20         $letter = substr($dni, -1);
21 
22         $mod = $number % 23;
23 
24         if ($mod === 0 && $letter !== 'T') {
25             throw new InvalidArgumentException('Invalid dni');
26         }
27 
28         if ($mod === 1 && $letter !== 'R') {
29             throw new InvalidArgumentException('Invalid dni');
30         }
31 
32         if ($mod === 2 && $letter !== 'W') {
33             throw new InvalidArgumentException('Invalid dni');
34         }
35         
36         $this->dni = $dni;
37     }
38 
39     public function __toString(): string
40     {
41         return $this->dni;
42     }
43 
44     private function checkIsValidDni(string $dni) : void
45     {
46         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
47             throw new DomainException('Bad format');
48         }
49     }
50 }

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.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     /** @var string */
13     private $dni;
14 
15     public function __construct(string $dni)
16     {
17         $this->checkIsValidDni($dni);
18 
19         $number = (int)substr($dni, 0, - 1);
20         $letter = substr($dni, -1);
21 
22         $mod = $number % 23;
23 
24         $map = [
25             0 => 'T',
26             1 => 'R',
27             2 => 'W'
28         ];
29 
30 
31         if ($letter !== $map[$mod]) {
32             throw new InvalidArgumentException('Invalid dni');
33         }
34         
35         $this->dni = $dni;
36     }
37 
38     public function __toString(): string
39     {
40         return $this->dni;
41     }
42 
43     private function checkIsValidDni(string $dni) : void
44     {
45         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
46             throw new DomainException('Bad format');
47         }
48     }
49 }

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:

1 $map = 'TRW';
2 
3 if ($letter !== $map[$mod]) {
4     throw new InvalidArgumentException('Invalid dni');
5 }

Y convertirlo en una constante, a la vez que añadimos el resto de letras que nos permitirá validar cualquier posible DNI.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     private const CONTROL_LETTER_MAP = 'TRWAGMYFPDXBNJZSQVHLCKE';
13     
14     /** @var string */
15     private $dni;
16 
17     public function __construct(string $dni)
18     {
19         $this->checkIsValidDni($dni);
20 
21         $number = (int)substr($dni, 0, - 1);
22         $letter = substr($dni, -1);
23 
24         $mod = $number % 23;
25         
26         if ($letter !== self::CONTROL_LETTER_MAP[$mod]) {
27             throw new InvalidArgumentException('Invalid dni');
28         }
29 
30         $this->dni = $dni;
31     }
32 
33     public function __toString(): string
34     {
35         return $this->dni;
36     }
37 
38     private function checkIsValidDni(string $dni) : void
39     {
40         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
41             throw new DomainException('Bad format');
42         }
43     }
44 }

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.

1 public function testShouldConstructValidNIEStartingWithX() : void
2 {
3     $dni = new Dni('X0000000T');
4     $this->assertEquals('X0000000T', (string) $dni);
5 }

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:

1 $number = (int)substr($dni, 0, - 1);

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:

1 public function testShouldConstructValidNIEStartingWithX() : void
2 {
3     $dni = new Dni('Y0000000Z');
4     $this->assertEquals('Y0000000Z', (string) $dni);
5 }

El algortimo de validación dice que debemos sustituir la Y por un 1 y proceder de la manera habitual:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     private const CONTROL_LETTER_MAP = 'TRWAGMYFPDXBNJZSQVHLCKE';
13     /** @var string */
14     private $dni;
15 
16     public function __construct(string $dni)
17     {
18         $this->checkIsValidDni($dni);
19 
20         $numeric = substr($dni, 0, - 1);
21         $number = (int)str_replace('Y', '1', $numeric);
22         $letter = substr($dni, -1);
23 
24         $mod = $number % 23;
25 
26         if ($letter !== self::CONTROL_LETTER_MAP[$mod]) {
27             throw new InvalidArgumentException('Invalid dni');
28         }
29 
30         $this->dni = $dni;
31     }
32 
33     public function __toString(): string
34     {
35         return $this->dni;
36     }
37 
38     private function checkIsValidDni(string $dni) : void
39     {
40         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
41             throw new DomainException('Bad format');
42         }
43     }
44 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     private const CONTROL_LETTER_MAP = 'TRWAGMYFPDXBNJZSQVHLCKE';
13     private const NIE_INITIAL_LETTERS = ['X', 'Y', 'Z'];
14     private const NIE_INITIAL_REPLACEMENTS = ['0', '1', '2'];
15     private const DIVISOR = 23;
16 
17     /** @var string */
18     private $dni;
19 
20     public function __construct(string $dni)
21     {
22         $this->checkIsValidDni($dni);
23 
24         $mod = $this->calculateModulus($dni);
25 
26         $letter = substr($dni, -1);
27 
28         if ($letter !== self::CONTROL_LETTER_MAP[ $mod ]) {
29             throw new InvalidArgumentException('Invalid dni');
30         }
31 
32         $this->dni = $dni;
33     }
34 
35     public function __toString() : string
36     {
37         return $this->dni;
38     }
39 
40     private function checkIsValidDni(string $dni) : void
41     {
42         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
43             throw new DomainException('Bad format');
44         }
45     }
46 
47     private function calculateModulus(string $dni) : int
48     {
49         $numeric = substr($dni, 0, -1);
50         $number = (int) str_replace(self::NIE_INITIAL_LETTERS, self::NIE_INITIAL_REP\
51 LACEMENTS, $numeric);
52 
53         return $number % self::DIVISOR;
54     }
55 }

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.

1 public function testShouldConstructValidDNIWithLowerCaseLetter(): void
2 {
3     $dni = new Dni('00000002w');
4     $this->assertEquals('00000002W', (string) $dni);
5 }

El test falla, con el siguiente mensaje:

1 InvalidArgumentException : Invalid dni

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Dojo;
 5 
 6 use DomainException;
 7 use InvalidArgumentException;
 8 
 9 class Dni
10 {
11     private const VALID_DNI_PATTERN = '/^[XYZ\d]\d{7,7}[^UIOÑ\d]$/u';
12     private const CONTROL_LETTER_MAP = 'TRWAGMYFPDXBNJZSQVHLCKE';
13     private const NIE_INITIAL_LETTERS = ['X', 'Y', 'Z'];
14     private const NIE_INITIAL_REPLACEMENTS = ['0', '1', '2'];
15     private const DIVISOR = 23;
16 
17     /** @var string */
18     private $dni;
19 
20     public function __construct(string $dni)
21     {
22         $dni = strtoupper($dni);
23         
24         $this->checkIsValidDni($dni);
25 
26         $mod = $this->calculateModulus($dni);
27 
28         $letter = substr($dni, -1);
29 
30         if ($letter !== self::CONTROL_LETTER_MAP[ $mod ]) {
31             throw new InvalidArgumentException('Invalid dni');
32         }
33 
34         $this->dni = $dni;
35     }
36 
37     public function __toString() : string
38     {
39         return $this->dni;
40     }
41 
42     private function checkIsValidDni(string $dni) : void
43     {
44         if (!preg_match(self::VALID_DNI_PATTERN, $dni)) {
45             throw new DomainException('Bad format');
46         }
47     }
48 
49     private function calculateModulus(string $dni) : int
50     {
51         $numeric = substr($dni, 0, -1);
52         $number = (int) str_replace(self::NIE_INITIAL_LETTERS, self::NIE_INITIAL_REP\
53 LACEMENTS, $numeric);
54 
55         return $number % self::DIVISOR;
56     }
57 }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Tests\Dojo\Luhn;
 5 
 6 use Dojo\Luhn\LuhnValidator;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class LuhnValidatorTest extends TestCase
10 {
11     public function testShouldValidateAllZeros(): void
12     {
13         $validator = new LuhnValidator();
14         $this->assertTrue($validator->isValid('00000000000'));
15     }
16 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11     }
12 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         return true;
12     }
13 }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Tests\Dojo\Luhn;
 5 
 6 use Dojo\Luhn\LuhnValidator;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class LuhnValidatorTest extends TestCase
10 {
11     public function testShouldValidateAllZeros(): void
12     {
13         $validator = new LuhnValidator();
14         $this->assertTrue($validator->isValid('00000000000'));
15     }
16 
17     public function testShouldNotValidateAllZerosEndingInOne(): void
18     {
19         $validator = new LuhnValidator();
20         $this->assertFalse($validator->isValid('00000000001'));
21     }
22 }

El test falla, por lo que vamos a implementar.

Lo primero es invertir la cadena, cosa que en PHP se puede hacer así:

1 $inverted = strrev($luhnCode);

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12         if ($inverted[0] !== '0') {
13             return false;
14         }
15         return true;
16     }
17 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         return !($inverted[0] !== '0');
14     }
15 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Tests\Dojo\Luhn;
 5 
 6 use Dojo\Luhn\LuhnValidator;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class LuhnValidatorTest extends TestCase
10 {
11     public function testShouldValidateAllZeros(): void
12     {
13         $validator = new LuhnValidator();
14         $this->assertTrue($validator->isValid('00000000000'));
15     }
16 
17     public function testShouldNotValidateAllZerosEndingInOne(): void
18     {
19         $validator = new LuhnValidator();
20         $this->assertFalse($validator->isValid('00000000001'));
21     }
22 
23     public function testShouldNotValidateOneInThirdPositionFromEnding(): void
24     {
25         $validator = new LuhnValidator();
26         $this->assertFalse($validator->isValid('00000000100'));
27     }
28 }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         return !($inverted[0] !== '0' || $inverted[2] !== '0');
14     }
15 }

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:

1     public function testShouldValidateWithTwoNoZerosAddingTen(): void
2     {
3         $validator = new LuhnValidator();
4         $this->assertTrue($validator->isValid('00000000406'));
5     }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $inverted[0] + $inverted[2];
14         
15         if ($oddAdded % 10 === 0) {
16             return true;
17         }
18         
19         return false;
20     }
21 }

En este ejemplo, que hace pasar el test, también vemos una oportunidad de refactor:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $inverted[0] + $inverted[2];
14 
15         return $oddAdded % 10 === 0;
16     }
17 }

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

1     public function testShouldConsiderFifthPosition(): void
2     {
3         $validator = new LuhnValidator();
4         $this->assertFalse($validator->isValid('00000010000'));
5     }

Este test no pasa porque el algoritmo no tiene en cuenta todavía la quinta posición. Así que toca implementar algo:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8     public function isValid(string $luhnCode) : bool
 9     {
10         $inverted = strrev($luhnCode);
11 
12         $oddAdded = $inverted[0] + $inverted[2] + $inverted[4];
13 
14         return $oddAdded % 10 === 0;
15     }
16 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14 
15         return $oddAdded % 10 === 0;
16     }
17 
18     private function addOddDigits(string $inverted): int
19     {
20         return $inverted[0] + $inverted[2] + $inverted[4];
21     }
22 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Tests\Dojo\Luhn;
 5 
 6 use Dojo\Luhn\LuhnValidator;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class LuhnValidatorTest extends TestCase
10 {
11     public function testShouldValidateAllZeros(): void
12     {
13         $validator = new LuhnValidator();
14         $this->assertTrue($validator->isValid('00000000000'));
15     }
16 
17     public function testShouldConsiderFirstPosition(): void
18     {
19         $validator = new LuhnValidator();
20         $this->assertFalse($validator->isValid('00000000001'));
21     }
22 
23     public function testShouldConsiderThirdPosition(): void
24     {
25         $validator = new LuhnValidator();
26         $this->assertFalse($validator->isValid('00000000100'));
27     }
28 
29     public function testShouldValidateTwoEvenPositionsAddingTen(): void
30     {
31         $validator = new LuhnValidator();
32         $this->assertTrue($validator->isValid('00000000406'));
33     }
34 
35     public function testShouldConsiderFifthPosition(): void
36     {
37         $validator = new LuhnValidator();
38         $this->assertFalse($validator->isValid('00000010000'));
39     }
40 
41     public function testShouldConsiderSeventhPosition(): void
42     {
43         $validator = new LuhnValidator();
44         $this->assertFalse($validator->isValid('00001000000'));
45     }
46 
47     public function testShouldConsiderNinthPosition(): void
48     {
49         $validator = new LuhnValidator();
50         $this->assertFalse($validator->isValid('00100000000'));
51     }
52 
53     public function testShouldConsiderEleventhPosition(): void
54     {
55         $validator = new LuhnValidator();
56         $this->assertFalse($validator->isValid('10000000000'));
57     }
58 }

Y este es el código de producción, que es bastante feo pero pasa.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14 
15         return $oddAdded % 10 === 0;
16     }
17 
18     private function addOddDigits(string $inverted) : int
19     {
20         return $inverted[0]
21             + $inverted[2]
22             + $inverted[4]
23             + $inverted[6]
24             + $inverted[8]
25             + $inverted[10];
26     }
27 }

Es hora de hacer un refactor, manteniendo los tests en verde:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14 
15         return $oddAdded % 10 === 0;
16     }
17 
18     private function addOddDigits(string $inverted) : int
19     {
20         $oddAdded = 0;
21         for ($position = 0; $position < 11; $position += 2) {
22             $oddAdded += $inverted[$position];
23         }
24         return $oddAdded;
25     }
26 }

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:

1     public function testShouldConsiderSecondPosition(): void
2     {
3         $validator = new LuhnValidator();
4         $this->assertFalse($validator->isValid('00000000010'));
5     }

El test falla, implementemos algo para pasar el test:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14         $evenAdded = $inverted[1] * 2;
15 
16         return ($oddAdded + $evenAdded) % 10 === 0;
17     }
18 
19     private function addOddDigits(string $inverted) : int
20     {
21         $oddAdded = 0;
22         for ($position = 0; $position < 11; $position += 2) {
23             $oddAdded += $inverted[$position];
24         }
25         return $oddAdded;
26     }
27 }

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):

 1     public function testShouldConsiderSecondPosition(): void
 2     {
 3         $validator = new LuhnValidator();
 4         $this->assertFalse($validator->isValid('00000000010'));
 5     }
 6 
 7     public function testShouldConsiderFourthPosition(): void
 8     {
 9         $validator = new LuhnValidator();
10         $this->assertFalse($validator->isValid('00000001000'));
11     }
12 
13     public function testShouldConsiderSixthPosition(): void
14     {
15         $validator = new LuhnValidator();
16         $this->assertFalse($validator->isValid('00000100000'));
17     }
18 
19     public function testShouldConsiderEighthPosition(): void
20     {
21         $validator = new LuhnValidator();
22         $this->assertFalse($validator->isValid('00010000000'));
23     }
24 
25     public function testShouldConsiderTenthPosition(): void
26     {
27         $validator = new LuhnValidator();
28         $this->assertFalse($validator->isValid('01000000000'));
29     }
30         self.assertFalse(luhn_validator.is_valid('01000000000'))

Y este es el código que estos tests me han permitido escribir, una vez refactorizado:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14         $evenAdded = $this->addEvenDigits($inverted);
15 
16         return ($oddAdded + $evenAdded) % 10 === 0;
17     }
18 
19     private function addOddDigits(string $inverted) : int
20     {
21         $oddAdded = 0;
22         for ($position = 0; $position < 11; $position += 2) {
23             $oddAdded += $inverted[ $position ];
24         }
25 
26         return $oddAdded;
27     }
28 
29     private function addEvenDigits(string $inverted)
30     {
31         $evenAdded = 0;
32         for ($position = 1; $position < 11; $position += 2) {
33             $evenAdded += $inverted[ $position ] * 2;
34         }
35 
36         return $evenAdded;
37     }
38 }

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:

1     def test_credit_card_with_only_second_digit_five_is_invalid(self):
2         luhn_validator = LuhnValidator()
3         self.assertFalse(luhn_validator.is_valid('00000000050'))

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14         $evenAdded = $this->addEvenDigits($inverted);
15 
16         return ($oddAdded + $evenAdded) % 10 === 0;
17     }
18 
19     private function addOddDigits(string $inverted) : int
20     {
21         $oddAdded = 0;
22         for ($position = 0; $position < 11; $position += 2) {
23             $oddAdded += $inverted[ $position ];
24         }
25 
26         return $oddAdded;
27     }
28 
29     private function addEvenDigits(string $inverted)
30     {
31         $evenAdded = 0;
32         for ($position = 1; $position < 11; $position += 2) {
33             $double = $inverted[ $position ] * 2;
34             if ($double >= 10) {
35                 $double = intdiv($double, 10) + $double % 10;
36             }
37             $evenAdded += $double;
38         }
39 
40         return $evenAdded;
41     }
42 }

Es un código bastante feo, pero hace su trabajo.

Podríamos mejorarlo un poco, aprovechando que nos cubren los tests:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Dojo\Luhn;
 5 
 6 class LuhnValidator
 7 {
 8 
 9     public function isValid(string $luhnCode) : bool
10     {
11         $inverted = strrev($luhnCode);
12 
13         $oddAdded = $this->addOddDigits($inverted);
14         $evenAdded = $this->addEvenDigits($inverted);
15 
16         return ($oddAdded + $evenAdded) % 10 === 0;
17     }
18 
19     private function addOddDigits(string $inverted) : int
20     {
21         $oddAdded = 0;
22         for ($position = 0; $position < 11; $position += 2) {
23             $oddAdded += (int) $inverted[ $position ];
24         }
25 
26         return $oddAdded;
27     }
28 
29     private function addEvenDigits(string $inverted) : int
30     {
31         $evenAdded = 0;
32         for ($position = 1; $position < 11; $position += 2) {
33             $double = (int) $inverted[ $position ] * 2;
34             $evenAdded += $this->reduceToOneDigit($double);
35         }
36 
37         return $evenAdded;
38     }
39 
40     private function reduceToOneDigit($double) : int
41     {
42         if ($double >= 10) {
43             $double = intdiv($double, 10) + $double % 10;
44         }
45 
46         return $double;
47     }
48 }

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**

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace Tests\Dojo\Luhn;
 5 
 6 use Dojo\Luhn\LuhnValidator;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class LuhnValidatorAcceptanceTest extends TestCase
10 {
11     public function testShouldValidateRealCardNumber(): void
12     {
13         $validator = new LuhnValidator();
14         $this->assertTrue($validator->isValid('49927398716'));
15     }
16 }

¿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.

1 // Not so good
2 
3 public function testShouldNotAcceptStringsLongerThanNineCharacters()
4 
5 // Better
6 
7 public function testShouldNotAcceptStringsLongerThanAMaximumOfCharacters()

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:

1 // Not so good
2 
3 public function testFindAllByClass()
4 
5 // Better
6 
7 public function testShouldRetrieveStudentsInAGivenClass()

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:

1 /** @test */
2 public function shouldRetrieveStudentsInAGivenClass()

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:

1 public function shouldRetrieveStudentsInAGivenClass()

En este mismo sentido, me gusta la sintaxis de phpspec, que usa it como prefijo, lo que es más natural que test:

1 public function it_should_create_a_user_from_retrieved_data();

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.

1 public function testShouldCalculateTheDiscountedPrice()

He visto alguna propuesta de utilizar Should como sufijo en el nombre del TestCase, cosa que es posible configurar:

1 class MyClassShould extends TestCase
2 {
3     /** @test */
4     public function doThatThing()
5     {
6         // given, when, then code...
7     }
8 }

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:

1 public function testShouldNotCreateACommercialManagerWithoutCommercialAdmin
2 {
3 }

O este:

1 public function testShouldNotAllowNifsLongerThanMaxCharacters
2 {
3 }

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:

1 public function testCalculatesPriceForSevenOrLess()
2 {
3     $priceCalculator = new PriceCalculator();
4     $this->assertEquals(0, $priceCalculator->forAge(5));
5 }

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:

 1 public function testCalculatesPriceForAChild()
 2 {
 3     $childAge = 5;
 4     $expectedPrice = 0;
 5     $priceCalculator = new PriceCalculator();
 6     $this->assertEquals(
 7         $expectedPrice, 
 8         $priceCalculator->forAge($childAge)
 9     );
10 }

Bueno, esto ha mejorado bastante. Ahora el test es mucho más comunicativo. Pero creo que aún quedaría mejor si usamos constantes;

1 public function testCalculatesPriceForAChild()
2 {
3     $priceCalculator = new PriceCalculator();
4     $this->assertEquals(
5         self::PRICE_FOR_CHILDREN, 
6         $priceCalculator->forAge(self::CHILD)
7     );
8 }

Obviamente podríamos utilizar un Data Provider, aunque eso no elimina lo anterior:

 1 /** @dataProvider agePriceProvider */
 2 public function testCalculatesPriceForAAge(int $ageGroup, int $expectedPrice)
 3 {
 4     $priceCalculator = new PriceCalculator();
 5     $this->assertEquals(
 6         $expectedPrice, 
 7         $priceCalculator->forAge($ageGroup)
 8     );
 9 }
10 
11 public function agePriceProvider()
12 {
13     return [
14         'Child' => [self::CHILD, self::PRICE_FOR_CHILDREN],
15         'Teen' => [self::TEEN, self::PRICE_FOR_TEENS]
16         //... you see the point
17     ];
18 }

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.

 1 public function testCalculatesPriceForAChild()
 2 {
 3     $priceCalculator = new PriceCalculator();
 4     
 5     $priceForAChild = $priceCalculator->forAge(self::CHILD);
 6     
 7     $this->assertEquals(
 8         self::PRICE_FOR_CHILDREN, 
 9         $priceForAChild
10     );
11 }

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):

 1 public function testShouldRetrieveStudentsInAGivenClass()
 2 {
 3     $studentsRepository = $this->StudentsRepositoryDoubleBuilder()
 4         ->loadWithFixtureDataFromFile('../students.yml')
 5         ->assertFindByClass()
 6         ->build();
 7         
 8     $classRepository = $this->classRepositoryDoubleBuilder()
 9         ->loadWithFixtureDataFromFile('../classes.yml')
10         ->assertFindByName('Class A')
11         ->build();
12             
13     $getStudentsInClass = new GetStudentsInClass(
14         $studentsRepository,
15         $classRepository
16     );
17     
18     $request = new GetStudentsInClassRequest('Class A');
19     $listOfStudents = $getStudentsInClass->execute($request);
20     
21     $this->assertCount(self::STUDENTS_COUNT_IN_CLASS_A, $listOfStudents);
22 }

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?

 1 public function testShouldRetrieveStudentsInAGivenClass()
 2 {
 3     $studentsRepository = $this->prepareStudentRepository();
 4     $classRepository = $this->prepareClassRepository();
 5                     
 6     $getStudentsInClass = new GetStudentsInClass(
 7         $studentsRepository,
 8         $classRepository
 9     );
10     
11     $request = new GetStudentsInClassRequest('Class A');
12     $listOfStudents = $getStudentsInClass->execute($request);
13     
14     $this->assertCount(self::STUDENTS_COUNT_IN_CLASS_A, $listOfStudents);
15 }
16 
17 public function prepareStudentsRepository()
18 {
19     return $this->StudentsRepositoryDoubleBuilder()
20         ->loadWithFixtureDataFromFile('../students.yml')
21         ->assertFindByClass(123)
22         ->build();
23 }
24 
25 public function prepareClassRepository()
26 {
27     return $this->classRepositoryDoubleBuilder()
28         ->loadWithFixtureDataFromFile('../classes.yml')
29         ->assertFindByName('Class A')
30         ->build();
31 }

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:

 1 /** @expectException InvalidArgumentException */
 2 public function testShouldNotAllowTooLongStrings()
 3 {
 4     $nif = new NIF(self::TOO_LONG_STRING);
 5 }
 6 
 7 // vs
 8 
 9 public function testShouldNotAllowTooLongStrings()
10 {
11     $this->expectException(InvalidArgumentException::class);
12     
13     $nif = new NIF(self::TOO_LONG_STRING);
14 }

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:

 1 public function testShouldFailIfStudentDoesNotExist()
 2 {
 3     $studentsRepository = $this->prepareStudentRepository();
 4 
 5     $getStudentByName = new GetStudentByName(
 6         $studentsRepository
 7     );
 8     
 9     $request = new GetStudentByNameRequest('Student Name');
10     
11     $this->expectException(StudentDoesNotExistException::class);
12     $student = $getStudentByName->execute($request);
13 }

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:

1 protected function assertEqualsArrays($expected, $actual, $message = null)
2 {
3     sort($expected);
4     sort($actual);
5     $this->assertEquals($expected, $actual, $message);
6 }

Eso me lleva a pensar que algunas triangulaciones en los tests podrían, igualmente, encapsularse en un único método:

1 protected function assertValidCommercial(Commercial $commercial)
2 {
3     $this->assertEquals(CommercialType::fromString('admin'), $commercial->type());
4     $this->assertEquals(null, $commercial->parent());
5     $this->assertEquals([CommercialRole::ROLE_ADMIN], $commercial->getRoles());
6 }

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.

 1 namespace Tests\Dojo\Doubles;
 2 
 3 use Dojo\Doubles\SomeService;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class DummyLogger implements LoggerInterface
 7 {
 8     //....
 9 }
10 
11 class SomeServiceTest extends TestCase
12 {
13     public function testSomeServiceCanBeInstantiated()
14     {
15         $logger = new DummyLooger();
16         $someService = new SomeService($logger);
17         $this->assertInstanceOf(SomeService::class, $someService);
18     }
19 }

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.

 1 use DateTimeImmutable;
 2 
 3 interface ClockServiceInterface
 4 {
 5     public function getCurrentDateTime() : DateTimeImmutable;
 6 }
 7 
 8 class ClockServiceStub implements ClockServiceInterface
 9 {
10     /**
11      * @var DateTimeImmutable
12      */
13     private $date;
14 
15     public function __construct(string $dateString)
16     {
17         $this->date = new DateTimeImmutable($dateString);
18     }
19 
20     public function getCurrentDateTime() : DateTimeImmutable
21     {
22         return $this->date;
23     }
24 }
25 
26 $dateForTesting = new ClockServiceStub('2018-03-12');

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.

 1 interface Mailer
 2 {
 3     public function send(Message $message) : void;
 4 }
 5 
 6 class MailerSpy implements Mailer
 7 {
 8     private $calls = 0;
 9 
10     public function send(Message $message) : void
11     {
12         $this->calls++;
13     }
14 
15     public function getCalls()
16     {
17         return $this->calls;
18     }
19 }
20 
21 class ServiceTest extends TestCase
22 {
23     public function testMailer()
24     {
25         $mailerSpy = new MailerSpy();
26         $sut = new Service($mailerSpy);
27         $sut->execute();
28         $this->assertEquals(2, $mailerSpy->getCalls());
29     }
30 }
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

 1 class ServiceTest extends TestCase
 2 {
 3     public function testMailer()
 4     {
 5         $mailerProphecy = $this->prophesize(Mailer::class);
 6         $mailerProphecy
 7             ->send(Argument::type(Message::class))
 8             ->shouldBeCalled();
 9         $sut = new Service($mailerProphecy->reveal());
10         $sut->execute();
11     }
12 }

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.

 1 class ServiceTest extends TestCase implements Mailer
 2 {
 3     private $mailerCalls = 0;
 4 
 5     public function testMailer()
 6     {
 7         $sut = new Service($this);
 8         $sut->execute();
 9         $this->assertEquals(2, $this->getCalls());
10     }
11 
12     public function send(Message $message) : void
13     {
14         $this->mailerCalls++;
15     }
16 }

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:

 1 class ServiceTest extends TestCase
 2 {
 3     public function testMailer()
 4     {
 5         $mailer = new class implements Mailer {
 6 
 7             private $calls = 0;
 8 
 9             public function send(Message $message) : void
10             {
11                 $this->calls++;
12             }
13 
14             public function getCalls()
15             {
16                 return $this->calls;
17             }
18         };
19 
20         $sut = new Service($mailer);
21         $sut->execute();
22         $this->assertEquals(2, $mailer->getCalls());
23     }
24 }

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:

 1 use Dojo\MailerExample\Application\SendNotificationService;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 
 5 interface Mailer
 6 {
 7     public function send(Message $message): bool;
 8 }
 9 
10 class SuccessfulMailerStub implements Mailer
11 {
12     public function send(Message $message): bool
13     {
14         return true;
15     }
16 }
17 
18 class FailedMailerStub implements Mailer
19 {
20     public function send(Message $message): bool
21     {
22         throw new MailServiceDownException();
23     }
24 }
25 
26 interface Message
27 {
28     public function subject(): string;
29     public function body(): string;
30 }
31 
32 class MessageStub implements Message
33 {
34     public function subject(): string
35     {
36         return 'Subject';
37     }
38     public function body(): string
39     {
40         return 'Body';
41     }
42 }
43 
44 class SendNotificationServiceTest extends TestCase
45 {
46     public function testSendNotificationCanSendAMessage() : void
47     {
48         $message = new MessageStub();
49         $mailer = new SuccessfulMailerStub();
50         
51         $sendNotification = new SendNotificationService($mailer);
52         $this->assertTrue($sendNotification->send($message));
53     }
54 
55     public function testSendNotificationCanNotSendMessage() : void
56     {
57         $this->expectException(NotificationCouldNotBeSent::class);
58         
59         $message = new MessageStub();
60         $mailer = new FailedMailerStub();
61         
62         $sendNotification = new SendNotificationService($mailer);
63         $sendNotification->send($message);
64     }
65 }

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í:

 1 class SendNotificationServiceTest extends TestCase
 2 {
 3     public function testSendNotificationCanSendAMessage() : void
 4     {
 5         $sendNotification = new SendNotificationService(new SuccessfulMailerStub());
 6         $this->assertTrue($sendNotification->send(new MessageStub()));
 7     }
 8 
 9     public function testSendNotificationCanNotSendMessage() : void
10     {
11         $this->expectException(NotficationCouldNotBeSent::class);
12         $sendNotification = new SendNotificationService(new FailedMailerStub());
13         $sendNotification->send(new MessageStub());
14     }
15 }

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:

 1 class SendNotificationServiceTest extends TestCase
 2 {
 3     private $message;
 4 
 5     public function setUp() : void
 6     {
 7         $this->message = new MessageStub();
 8     }
 9 
10     public function testSendNotificationCanSendAMessage() : void
11     {
12         $mailer = new SuccessfulMailerStub();
13         
14         $sendNotification = new SendNotificationService($mailer);
15         $this->assertTrue($sendNotification->send($this->message));
16     }
17 
18     public function testSendNotificationCanNotSendMessage() : void
19     {
20         $this->expectException(NotiicationCouldNotBeSent::class);
21         $mailer = new FailedMailerStub();
22         
23         $sendNotification = new SendNotificationService($mailer);
24         $sendNotification->send($this->message);
25     }
26 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 use PHPUnit\Framework\MockObject\MockObject;
10 use PHPUnit\Framework\TestCase;
11 
12 class CustomerContractsProductTest extends TestCase
13 {
14     public function testShouldFailIfCustomerDoesNoExist(): void
15     {
16         $customerContractsProduct = new CustomerContractsProduct();
17 
18         /** @var IdentityInterface | MockObject $customerId */
19         $customerId = $this->createMock(IdentityInterface::class);
20         /** @var IdentityInterface | MockObject $productId */
21         $productId = $this->createMock(IdentityInterface::class);
22 
23         $this->expectException(ContractCouldNotBeCreatedException::class);
24         $customerContractsProduct->execute($customerId, $productId);
25     }
26 }

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:

1 Failed asserting that exception of type "App\Domain\Exception\ContractCouldNotBeCrea\
2 tedException" is thrown.

Para llegar hasta aquí, habremos creado lo siguiente:

src/Application/CustomerContractsProduct.php

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\IdentityInterface;
 7 
 8 class CustomerContractsProduct
 9 {
10 
11     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
12 tId)
13     {
14     }
15 }

src/Domain/Exception/ContractCouldNotBeCreatedException.php

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain\Exception;
 5 
 6 use Exception;
 7 
 8 class ContractCouldNotBeCreatedException extends Exception
 9 {
10 
11 }

src/Domain/IdentityInterface.php

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 interface IdentityInterface
7 {
8 }

Así que llega el momento de hacer pasar el test, devolviendo la respuesta más obvia: lanzar la excepción:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 7 use App\Domain\IdentityInterface;
 8 
 9 class CustomerContractsProduct
10 {
11 
12     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
13 tId)
14     {
15         throw new ContractCouldNotBeCreatedException('Contract Not Created');
16     }
17 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 use PHPUnit\Framework\MockObject\MockObject;
10 use PHPUnit\Framework\TestCase;
11 
12 class CustomerContractsProductTest extends TestCase
13 {
14     public function testShouldFailIfCustomerDoesNoExist(): void
15     {
16         $customerContractsProduct = new CustomerContractsProduct();
17 
18         /** @var IdentityInterface | MockObject $customerId */
19         $customerId = $this->createMock(IdentityInterface::class);
20         /** @var IdentityInterface | MockObject $productId */
21         $productId = $this->createMock(IdentityInterface::class);
22 
23         $this->expectException(ContractCouldNotBeCreatedException::class);
24         $this->expectExceptionMessage('Customer not found');
25         $customerContractsProduct->execute($customerId, $productId);
26     }
27 }

Y, a continuación, implementamos el nuevo comportamiento demandado:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 7 use App\Domain\IdentityInterface;
 8 
 9 class CustomerContractsProduct
10 {
11 
12     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
13 tId)
14     {
15         throw new ContractCouldNotBeCreatedException('Customer not found');
16     }
17 }

Ahora ya tenemos podemos crear un nuevo test y progresar en el desarrollo:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 use PHPUnit\Framework\MockObject\MockObject;
10 use PHPUnit\Framework\TestCase;
11 
12 class CustomerContractsProductTest extends TestCase
13 {
14     public function testShouldFailIfCustomerDoesNoExist(): void
15     {
16         $customerContractsProduct = new CustomerContractsProduct();
17 
18         /** @var IdentityInterface | MockObject $customerId */
19         $customerId = $this->createMock(IdentityInterface::class);
20         /** @var IdentityInterface | MockObject $productId */
21         $productId = $this->createMock(IdentityInterface::class);
22 
23         $this->expectException(ContractCouldNotBeCreatedException::class);
24         $this->expectExceptionMessage('Customer not found');
25         $customerContractsProduct->execute($customerId, $productId);
26     }
27 
28     public function testShouldFailIfProductDoesNoExist(): void
29     {
30         $customerContractsProduct = new CustomerContractsProduct();
31 
32         /** @var IdentityInterface | MockObject $customerId */
33         $customerId = $this->createMock(IdentityInterface::class);
34         /** @var IdentityInterface | MockObject $productId */
35         $productId = $this->createMock(IdentityInterface::class);
36 
37         $this->expectException(ContractCouldNotBeCreatedException::class);
38         $this->expectExceptionMessage('Product not found');
39         $customerContractsProduct->execute($customerId, $productId);
40     }
41 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 use App\Domain\Customer;
10 use App\Domain\CustomerRepositoryInterface;
11 use PHPUnit\Framework\MockObject\MockObject;
12 use PHPUnit\Framework\TestCase;
13 
14 class CustomerContractsProductTest extends TestCase
15 {
16     public function testShouldFailIfCustomerDoesNoExist(): void
17     {
18         $customerContractsProduct = new CustomerContractsProduct();
19 
20         /** @var IdentityInterface | MockObject $customerId */
21         $customerId = $this->createMock(IdentityInterface::class);
22         /** @var IdentityInterface | MockObject $productId */
23         $productId = $this->createMock(IdentityInterface::class);
24 
25         $this->expectException(ContractCouldNotBeCreatedException::class);
26         $this->expectExceptionMessage('Customer not found');
27         $customerContractsProduct->execute($customerId, $productId);
28     }
29 
30     public function testShouldFailIfProductDoesNoExist(): void
31     {
32         /** @var Customer | MockObject $customerId */
33         $customer = $this->createMock(Customer::class);
34 
35         /** @var CustomerRepositoryInterface | MockObject */
36         $customerRepository = $this->createMock(CustomerRepositoryInterface::class);
37         $customerRepository
38             ->method('getById')
39             ->willReturn($customer);
40 
41         $customerContractsProduct = new CustomerContractsProduct(
42             $customerRepository
43         );
44 
45         /** @var IdentityInterface | MockObject $customerId */
46         $customerId = $this->createMock(IdentityInterface::class);
47         /** @var IdentityInterface | MockObject $productId */
48         $productId = $this->createMock(IdentityInterface::class);
49 
50         $this->expectException(ContractCouldNotBeCreatedException::class);
51         $this->expectExceptionMessage('Product not found');
52         $customerContractsProduct->execute($customerId, $productId);
53     }
54 }

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

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 class Customer
7 {
8 
9 }

Crear la interfaz CustomerRepositoryInterface en ****

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 interface CustomerRepositoryInterface
7 {
8     public function getById(IdentityInterface $customerId): Customer;
9 }

Hasta que el test falla porque el mensaje recibido no es el esperado:

1 Failed asserting that exception message 'Customer not found' contains 'Product not f\
2 ound'.

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 7 use App\Domain\IdentityInterface;
 8 
 9 class CustomerContractsProduct
10 {
11 
12     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
13 tId)
14     {
15         $customer = $this->customerRepository->getById($customerId);
16         
17         throw new ContractCouldNotBeCreatedException('Customer not found');
18     }
19 }

Al ejecutar el test, ya me dice que la propiedad customerRepository no existe.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CustomerRepositoryInterface;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 
10 class CustomerContractsProduct
11 {
12     /** @var CustomerRepositoryInterface */
13     private $customerRepository;
14 
15     public function __construct(CustomerRepositoryInterface $customerRepository)
16     {
17         $this->customerRepository = $customerRepository;
18     }
19 
20     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
21 tId)
22     {
23         $customer = $this->customerRepository->getById($customerId);
24 
25         throw new ContractCouldNotBeCreatedException('Customer not found');
26     }
27 }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 use App\Domain\Customer;
10 use App\Domain\CustomerRepositoryInterface;
11 use PHPUnit\Framework\MockObject\MockObject;
12 use PHPUnit\Framework\TestCase;
13 
14 class CustomerContractsProductTest extends TestCase
15 {
16 
17     /** @var CustomerContractsProduct */
18     private $customerContractsProduct;
19     /** @var CustomerRepositoryInterface | MockObject */
20     private $customerRepository;
21 
22     public function setUp(): void
23     {
24         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
25 lass);
26 
27         $this->customerContractsProduct = new CustomerContractsProduct(
28             $this->customerRepository
29         );
30     }
31 
32     public function testShouldFailIfCustomerDoesNoExist(): void
33     {
34         /** @var IdentityInterface | MockObject $customerId */
35         $customerId = $this->createMock(IdentityInterface::class);
36         /** @var IdentityInterface | MockObject $productId */
37         $productId = $this->createMock(IdentityInterface::class);
38 
39         $this->expectException(ContractCouldNotBeCreatedException::class);
40         $this->expectExceptionMessage('Customer not found');
41         $this->customerContractsProduct->execute($customerId, $productId);
42     }
43 
44     public function testShouldFailIfProductDoesNoExist(): void
45     {
46         /** @var Customer | MockObject $customerId */
47         $customer = $this->createMock(Customer::class);
48 
49         $this->customerRepository
50             ->method('getById')
51             ->willReturn($customer);
52 
53         /** @var IdentityInterface | MockObject $customerId */
54         $customerId = $this->createMock(IdentityInterface::class);
55         /** @var IdentityInterface | MockObject $productId */
56         $productId = $this->createMock(IdentityInterface::class);
57 
58         $this->expectException(ContractCouldNotBeCreatedException::class);
59         $this->expectExceptionMessage('Product not found');
60         $this->customerContractsProduct->execute($customerId, $productId);
61     }
62 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CustomerRepositoryInterface;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\IdentityInterface;
 9 
10 class CustomerContractsProduct
11 {
12     /** @var CustomerRepositoryInterface */
13     private $customerRepository;
14 
15     public function __construct(CustomerRepositoryInterface $customerRepository)
16     {
17         $this->customerRepository = $customerRepository;
18     }
19 
20     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
21 tId)
22     {
23         $customer = $this->customerRepository->getById($customerId);
24 
25         if (null === $customer) {
26             throw new ContractCouldNotBeCreatedException('Customer not found');
27         }
28 
29         throw new ContractCouldNotBeCreatedException('Product not found');
30     }
31 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\Exception\CustomerNotFoundException;
 9 use App\Domain\IdentityInterface;
10 use App\Domain\Customer;
11 use App\Domain\CustomerRepositoryInterface;
12 use PHPUnit\Framework\MockObject\MockObject;
13 use PHPUnit\Framework\TestCase;
14 
15 class CustomerContractsProductTest extends TestCase
16 {
17 
18     /** @var CustomerContractsProduct */
19     private $customerContractsProduct;
20     /** @var CustomerRepositoryInterface | MockObject */
21     private $customerRepository;
22 
23     public function setUp(): void
24     {
25         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
26 lass);
27 
28         $this->customerContractsProduct = new CustomerContractsProduct(
29             $this->customerRepository
30         );
31     }
32 
33     public function testShouldFailIfCustomerDoesNoExist(): void
34     {
35         $this->customerRepository
36             ->method('getById')
37             ->willThrowException(new CustomerNotFoundException());
38 
39         /** @var IdentityInterface | MockObject $customerId */
40         $customerId = $this->createMock(IdentityInterface::class);
41         /** @var IdentityInterface | MockObject $productId */
42         $productId = $this->createMock(IdentityInterface::class);
43 
44         $this->expectException(ContractCouldNotBeCreatedException::class);
45         $this->expectExceptionMessage('Customer not found');
46         $this->customerContractsProduct->execute($customerId, $productId);
47     }
48 
49     public function testShouldFailIfProductDoesNoExist(): void
50     {
51         /** @var Customer | MockObject $customerId */
52         $customer = $this->createMock(Customer::class);
53 
54         $this->customerRepository
55             ->method('getById')
56             ->willReturn($customer);
57 
58         /** @var IdentityInterface | MockObject $customerId */
59         $customerId = $this->createMock(IdentityInterface::class);
60         /** @var IdentityInterface | MockObject $productId */
61         $productId = $this->createMock(IdentityInterface::class);
62 
63         $this->expectException(ContractCouldNotBeCreatedException::class);
64         $this->expectExceptionMessage('Product not found');
65         $this->customerContractsProduct->execute($customerId, $productId);
66     }
67 }

Y para conseguir que pase, modificamos así el código de producción:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CustomerRepositoryInterface;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\Exception\CustomerNotFoundException;
 9 use App\Domain\IdentityInterface;
10 
11 class CustomerContractsProduct
12 {
13     /** @var CustomerRepositoryInterface */
14     private $customerRepository;
15 
16     public function __construct(CustomerRepositoryInterface $customerRepository)
17     {
18         $this->customerRepository = $customerRepository;
19     }
20 
21     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
22 tId)
23     {
24         try {
25             $customer = $this->customerRepository->getById($customerId);
26         } catch (CustomerNotFoundException $exception) {
27             throw new ContractCouldNotBeCreatedException('Customer not found');
28         }
29 
30         throw new ContractCouldNotBeCreatedException('Product not found');
31     }
32 }

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Tests\Unit\Application;
 5 
 6 use App\Application\CustomerContractsProduct;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\Exception\CustomerNotFoundException;
 9 use App\Domain\Exception\ProductNotFoundException;
10 use App\Domain\IdentityInterface;
11 use App\Domain\Customer;
12 use App\Domain\Product;
13 use App\Domain\CustomerRepositoryInterface;
14 use App\Domain\ProductRepositoryInterface;
15 use PHPUnit\Framework\MockObject\MockObject;
16 use PHPUnit\Framework\TestCase;
17 
18 class CustomerContractsProductTest extends TestCase
19 {
20 
21     /** @var CustomerContractsProduct */
22     private $customerContractsProduct;
23     /** @var CustomerRepositoryInterface | MockObject */
24     private $customerRepository;
25     /** @var ProductRepositoryInterface  MockObject */
26     private $productRepository;
27 
28     public function setUp(): void
29     {
30         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
31 lass);
32 
33         $this->productRepository = $this->createMock(ProductRepositoryInterface::cla\
34 ss);
35 
36         $this->customerContractsProduct = new CustomerContractsProduct(
37             $this->customerRepository,
38             $this->productRepository
39         );
40     }
41 
42     public function testShouldFailIfCustomerDoesNoExist(): void
43     {
44         $this->customerRepository
45             ->method('getById')
46             ->willThrowException(new CustomerNotFoundException());
47 
48         /** @var IdentityInterface | MockObject $customerId */
49         $customerId = $this->createMock(IdentityInterface::class);
50         /** @var IdentityInterface | MockObject $productId */
51         $productId = $this->createMock(IdentityInterface::class);
52 
53         $this->expectException(ContractCouldNotBeCreatedException::class);
54         $this->expectExceptionMessage('Customer not found');
55         $this->customerContractsProduct->execute($customerId, $productId);
56     }
57 
58     public function testShouldFailIfProductDoesNoExist(): void
59     {
60         /** @var Customer | MockObject $customerId */
61         $customer = $this->createMock(Customer::class);
62 
63         $this->customerRepository
64             ->method('getById')
65             ->willReturn($customer);
66 
67         $this->productRepository
68             ->method('getById')
69             ->willThrowException(new ProductNotFoundException());
70 
71         /** @var IdentityInterface | MockObject $customerId */
72         $customerId = $this->createMock(IdentityInterface::class);
73         /** @var IdentityInterface | MockObject $productId */
74         $productId = $this->createMock(IdentityInterface::class);
75 
76         $this->expectException(ContractCouldNotBeCreatedException::class);
77         $this->expectExceptionMessage('Product not found');
78         $this->customerContractsProduct->execute($customerId, $productId);
79     }
80 }

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:

1 Failed asserting that exception message 'Product not found' contains 'Customer not e\
2 ligible'.

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.

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain;
 5 
 6 class Product
 7 {
 8 
 9     public function isCustomerEligible(Customer $customer): bool
10     {
11         return false;
12     }
13 }

La interfaz de ProductRepositoryInterface en src/Domain/ProductRepositoryInterface.php.

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 interface ProductRepositoryInterface
7 {
8     public function getById(IdentityInterface $productId): Product;
9 }

Y la excepción ProductNotFoundException en src/Domain/Exception/ProductNotFoundException.php

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain\Exception;
 5 
 6 use Exception;
 7 
 8 class ProductNotFoundException extends Exception
 9 {
10 
11 }

Y, es ahora, cuando introducimos el nuevo test, que fallará:

 1     public function testShouldFailIfCustomerNotEligible(): void
 2     {
 3         /** @var Customer | MockObject $customer */
 4         $customer = $this->createMock(Customer::class);
 5 
 6         $this->customerRepository
 7             ->method('getById')
 8             ->willReturn($customer);
 9 
10         /** @var Product | MockObject $product */
11         $product = $this->createMock(Product::class);
12 
13         $product
14             ->method('isCustomerEligible')
15             ->willReturn(false);
16 
17         $this->productRepository
18             ->method('getById')
19             ->willReturn($product);
20 
21         /** @var IdentityInterface | MockObject $customerId */
22         $customerId = $this->createMock(IdentityInterface::class);
23         /** @var IdentityInterface | MockObject $productId */
24         $productId = $this->createMock(IdentityInterface::class);
25 
26         $this->expectException(ContractCouldNotBeCreatedException::class);
27         $this->expectExceptionMessage('Customer not eligible');
28     }

Para implementar lo que pide el test, resolvemos el problema que controla el test anterior:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CustomerRepositoryInterface;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\Exception\CustomerNotFoundException;
 9 use App\Domain\Exception\ProductNotFoundException;
10 use App\Domain\IdentityInterface;
11 use App\Domain\ProductRepositoryInterface;
12 
13 class CustomerContractsProduct
14 {
15     /** @var CustomerRepositoryInterface */
16     private $customerRepository;
17     /** @var ProductRepositoryInterface */
18     private $productRepository;
19 
20     public function __construct(
21         CustomerRepositoryInterface $customerRepository,
22         ProductRepositoryInterface $productRepository
23     ) {
24         $this->customerRepository = $customerRepository;
25         $this->productRepository = $productRepository;
26     }
27 
28     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
29 tId)
30     {
31         try {
32             $customer = $this->customerRepository->getById($customerId);
33             $product = $this->productRepository->getById($productId);
34         } catch (CustomerNotFoundException $exception) {
35             throw new ContractCouldNotBeCreatedException('Customer not found');
36         } catch (ProductNotFoundException $exception) {
37             throw new ContractCouldNotBeCreatedException('Product not found');
38         }
39 
40         throw new ContractCouldNotBeCreatedException('Customer not eligible');
41     }
42 }

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.

  1 <?php
  2 declare (strict_types=1);
  3 
  4 namespace App\Tests\Unit\Application;
  5 
  6 use App\Application\CustomerContractsProduct;
  7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
  8 use App\Domain\Exception\CustomerNotFoundException;
  9 use App\Domain\Exception\ProductNotFoundException;
 10 use App\Domain\Exception\CanNotCalculatePriceException;
 11 use App\Domain\IdentityInterface;
 12 use App\Domain\Customer;
 13 use App\Domain\Product;
 14 use App\Domain\Money;
 15 use App\Domain\CalculatePriceInterface;
 16 use App\Domain\CustomerRepositoryInterface;
 17 use App\Domain\ProductRepositoryInterface;
 18 use PHPUnit\Framework\MockObject\MockObject;
 19 use PHPUnit\Framework\TestCase;
 20 
 21 class CustomerContractsProductTest extends TestCase
 22 {
 23 
 24     /** @var CustomerContractsProduct */
 25     private $customerContractsProduct;
 26     /** @var CustomerRepositoryInterface | MockObject */
 27     private $customerRepository;
 28     /** @var ProductRepositoryInterface | MockObject */
 29     private $productRepository;
 30     /** @var CalculatePriceInterface | MockObject */
 31     private $calculatePrice;
 32 
 33     public function setUp(): void
 34     {
 35         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
 36 lass);
 37 
 38         $this->productRepository = $this->createMock(ProductRepositoryInterface::cla\
 39 ss);
 40 
 41         $this->calculatePrice = $this->createMock(CalculatePriceInterface::class);
 42 
 43         $this->customerContractsProduct = new CustomerContractsProduct(
 44             $this->customerRepository,
 45             $this->productRepository
 46         );
 47     }
 48 
 49     public function testShouldFailIfCustomerDoesNoExist(): void
 50     {
 51         $this->customerRepository
 52             ->method('getById')
 53             ->willThrowException(new CustomerNotFoundException());
 54 
 55         /** @var IdentityInterface | MockObject $customerId */
 56         $customerId = $this->createMock(IdentityInterface::class);
 57         /** @var IdentityInterface | MockObject $productId */
 58         $productId = $this->createMock(IdentityInterface::class);
 59 
 60         $this->expectException(ContractCouldNotBeCreatedException::class);
 61         $this->expectExceptionMessage('Customer not found');
 62         $this->customerContractsProduct->execute($customerId, $productId);
 63     }
 64 
 65     public function testShouldFailIfProductDoesNoExist(): void
 66     {
 67         /** @var Customer | MockObject $customerId */
 68         $customer = $this->createMock(Customer::class);
 69 
 70         $this->customerRepository
 71             ->method('getById')
 72             ->willReturn($customer);
 73 
 74         $this->productRepository
 75             ->method('getById')
 76             ->willThrowException(new ProductNotFoundException());
 77 
 78         /** @var IdentityInterface | MockObject $customerId */
 79         $customerId = $this->createMock(IdentityInterface::class);
 80         /** @var IdentityInterface | MockObject $productId */
 81         $productId = $this->createMock(IdentityInterface::class);
 82 
 83         $this->expectException(ContractCouldNotBeCreatedException::class);
 84         $this->expectExceptionMessage('Product not found');
 85         $this->customerContractsProduct->execute($customerId, $productId);
 86     }
 87 
 88     public function testShouldFailIfCustomerNotEligible(): void
 89     {
 90         /** @var Customer | MockObject $customer */
 91         $customer = $this->createMock(Customer::class);
 92 
 93         $this->customerRepository
 94             ->method('getById')
 95             ->willReturn($customer);
 96 
 97         /** @var Product | MockObject $product */
 98         $product = $this->createMock(Product::class);
 99 
100         $product
101             ->method('isCustomerEligible')
102             ->willReturn(false);
103 
104         $this->productRepository
105             ->method('getById')
106             ->willReturn($product);
107 
108         /** @var IdentityInterface | MockObject $customerId */
109         $customerId = $this->createMock(IdentityInterface::class);
110         /** @var IdentityInterface | MockObject $productId */
111         $productId = $this->createMock(IdentityInterface::class);
112 
113         $this->expectException(ContractCouldNotBeCreatedException::class);
114         $this->expectExceptionMessage('Customer not eligible');
115         $this->customerContractsProduct->execute($customerId, $productId);
116     }
117 
118 
119     public function testShouldFailIfPriceCanNotBeCalculated(): void
120     {
121         /** @var Customer | MockObject $customer */
122         $customer = $this->createMock(Customer::class);
123 
124         $this->customerRepository
125             ->method('getById')
126             ->willReturn($customer);
127 
128         /** @var Product | MockObject $product */
129         $product = $this->createMock(Product::class);
130 
131         $product
132             ->method('isCustomerEligible')
133             ->willReturn(true);
134 
135         $this->productRepository
136             ->method('getById')
137             ->willReturn($product);
138 
139         $this->calculatePrice
140             ->method('forCustomerAndProduct')
141             ->willThrowException(new CanNotCalculatePriceException());
142 
143         /** @var IdentityInterface | MockObject $customerId */
144         $customerId = $this->createMock(IdentityInterface::class);
145         /** @var IdentityInterface | MockObject $productId */
146         $productId = $this->createMock(IdentityInterface::class);
147 
148         $this->expectException(ContractCouldNotBeCreatedException::class);
149         $this->expectExceptionMessage('Price not calculated');
150         $this->customerContractsProduct->execute($customerId, $productId);
151     }
152 
153 }

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:

1 Failed asserting that exception message 'Customer not eligible' contains 'Price not \
2 calculated'.

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

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain;
 5 
 6 interface CalculatePriceInterface
 7 {
 8     public function forCustomerAndProduct(Customer $customer, Product $product): Mon\
 9 ey;
10 }

Aquí tenemos el value object Money, en src/Domain/Money.php

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 class Money
7 {
8 
9 }

Y la excepción que puede ser lanzada por el servicio src/Domain/Exception/CanNotCalculatePriceException.php:

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain\Exception;
5 
6 class CanNotCalculatePriceException extends \Exception
7 {
8 
9 }

Con esto, estamos listos para implementar algo:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CustomerRepositoryInterface;
 7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 8 use App\Domain\Exception\CustomerNotFoundException;
 9 use App\Domain\Exception\ProductNotFoundException;
10 use App\Domain\IdentityInterface;
11 use App\Domain\ProductRepositoryInterface;
12 
13 class CustomerContractsProduct
14 {
15     /** @var CustomerRepositoryInterface */
16     private $customerRepository;
17     /** @var ProductRepositoryInterface */
18     private $productRepository;
19 
20     public function __construct(
21         CustomerRepositoryInterface $customerRepository,
22         ProductRepositoryInterface $productRepository
23     ) {
24         $this->customerRepository = $customerRepository;
25         $this->productRepository = $productRepository;
26     }
27 
28     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
29 tId)
30     {
31         try {
32             $customer = $this->customerRepository->getById($customerId);
33             $product = $this->productRepository->getById($productId);
34 
35             if (!$product->isCustomerEligible($customer)) {
36                 throw new ContractCouldNotBeCreatedException('Customer not eligible'\
37 );
38             }
39         } catch (CustomerNotFoundException $exception) {
40             throw new ContractCouldNotBeCreatedException('Customer not found');
41         } catch (ProductNotFoundException $exception) {
42             throw new ContractCouldNotBeCreatedException('Product not found');
43         }
44 
45         throw new ContractCouldNotBeCreatedException('Price not calculated');
46     }
47 }

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:

  1 <?php
  2 declare (strict_types=1);
  3 
  4 namespace App\Tests\Unit\Application;
  5 
  6 use App\Application\CustomerContractsProduct;
  7 use App\Domain\Exception\ContractCouldNotBeCreatedException;
  8 use App\Domain\Exception\CustomerNotFoundException;
  9 use App\Domain\Exception\ProductNotFoundException;
 10 use App\Domain\Exception\CanNotCalculatePriceException;
 11 use App\Domain\IdentityInterface;
 12 use App\Domain\Contract;
 13 use App\Domain\Customer;
 14 use App\Domain\Product;
 15 use App\Domain\Money;
 16 use App\Domain\CalculatePriceInterface;
 17 use App\Domain\ContractRepositoryInterface;
 18 use App\Domain\CustomerRepositoryInterface;
 19 use App\Domain\ProductRepositoryInterface;
 20 use PHPUnit\Framework\MockObject\MockObject;
 21 use PHPUnit\Framework\TestCase;
 22 
 23 class CustomerContractsProductTest extends TestCase
 24 {
 25 
 26     /** @var CustomerContractsProduct */
 27     private $customerContractsProduct;
 28     /** @var CustomerRepositoryInterface | MockObject */
 29     private $customerRepository;
 30     /** @var ProductRepositoryInterface | MockObject */
 31     private $productRepository;
 32     /** @var CalculatePriceInterface | MockObject */
 33     private $calculatePrice;
 34     /** @var ContractRepositoryInterface | MockObject */
 35     private $contractRepository;
 36 
 37     public function setUp(): void
 38     {
 39         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
 40 lass);
 41 
 42         $this->productRepository = $this->createMock(ProductRepositoryInterface::cla\
 43 ss);
 44 
 45         $this->calculatePrice = $this->createMock(CalculatePriceInterface::class);
 46 
 47         $this->contractRepository = $this->createMock(ContractRepositoryInterface::c\
 48 lass);
 49 
 50         $this->customerContractsProduct = new CustomerContractsProduct(
 51             $this->customerRepository,
 52             $this->productRepository,
 53             $this->calculatePrice,
 54             $this->contractRepository
 55         );
 56     }
 57 
 58     public function testShouldFailIfCustomerDoesNoExist(): void
 59     {
 60         $this->customerRepository
 61             ->method('getById')
 62             ->willThrowException(new CustomerNotFoundException());
 63 
 64         /** @var IdentityInterface | MockObject $customerId */
 65         $customerId = $this->createMock(IdentityInterface::class);
 66         /** @var IdentityInterface | MockObject $productId */
 67         $productId = $this->createMock(IdentityInterface::class);
 68 
 69         $this->expectException(ContractCouldNotBeCreatedException::class);
 70         $this->expectExceptionMessage('Customer not found');
 71         $this->customerContractsProduct->execute($customerId, $productId);
 72     }
 73 
 74     public function testShouldFailIfProductDoesNoExist(): void
 75     {
 76         /** @var Customer | MockObject $customerId */
 77         $customer = $this->createMock(Customer::class);
 78 
 79         $this->customerRepository
 80             ->method('getById')
 81             ->willReturn($customer);
 82 
 83         $this->productRepository
 84             ->method('getById')
 85             ->willThrowException(new ProductNotFoundException());
 86 
 87         /** @var IdentityInterface | MockObject $customerId */
 88         $customerId = $this->createMock(IdentityInterface::class);
 89         /** @var IdentityInterface | MockObject $productId */
 90         $productId = $this->createMock(IdentityInterface::class);
 91 
 92         $this->expectException(ContractCouldNotBeCreatedException::class);
 93         $this->expectExceptionMessage('Product not found');
 94         $this->customerContractsProduct->execute($customerId, $productId);
 95     }
 96 
 97     public function testShouldFailIfCustomerNotEligible(): void
 98     {
 99         /** @var Customer | MockObject $customer */
100         $customer = $this->createMock(Customer::class);
101 
102         $this->customerRepository
103             ->method('getById')
104             ->willReturn($customer);
105 
106         /** @var Product | MockObject $product */
107         $product = $this->createMock(Product::class);
108 
109         $product
110             ->method('isCustomerEligible')
111             ->willReturn(false);
112 
113         $this->productRepository
114             ->method('getById')
115             ->willReturn($product);
116 
117         /** @var IdentityInterface | MockObject $customerId */
118         $customerId = $this->createMock(IdentityInterface::class);
119         /** @var IdentityInterface | MockObject $productId */
120         $productId = $this->createMock(IdentityInterface::class);
121 
122         $this->expectException(ContractCouldNotBeCreatedException::class);
123         $this->expectExceptionMessage('Customer not eligible');
124         $this->customerContractsProduct->execute($customerId, $productId);
125     }
126 
127     public function testShouldFailIfPriceCanNotBeCalculated(): void
128     {
129         /** @var Customer | MockObject $customer */
130         $customer = $this->createMock(Customer::class);
131 
132         $this->customerRepository
133             ->method('getById')
134             ->willReturn($customer);
135 
136         /** @var Product | MockObject $product */
137         $product = $this->createMock(Product::class);
138 
139         $product
140             ->method('isCustomerEligible')
141             ->willReturn(true);
142 
143         $this->productRepository
144             ->method('getById')
145             ->willReturn($product);
146 
147         $this->calculatePrice
148             ->method('forCustomerAndProduct')
149             ->willThrowException(new CanNotCalculatePriceException());
150 
151         /** @var IdentityInterface | MockObject $customerId */
152         $customerId = $this->createMock(IdentityInterface::class);
153         /** @var IdentityInterface | MockObject $productId */
154         $productId = $this->createMock(IdentityInterface::class);
155 
156         $this->expectException(ContractCouldNotBeCreatedException::class);
157         $this->expectExceptionMessage('Price not calculated');
158         $this->customerContractsProduct->execute($customerId, $productId);
159     }
160 
161     public function testShouldCreateAndStoreTheContract(): void
162     {
163         /** @var Customer | MockObject $customer */
164         $customer = $this->createMock(Customer::class);
165 
166         $this->customerRepository
167             ->method('getById')
168             ->willReturn($customer);
169 
170         /** @var Product | MockObject $product */
171         $product = $this->createMock(Product::class);
172 
173         $product
174             ->method('isCustomerEligible')
175             ->willReturn(true);
176 
177         $this->productRepository
178             ->method('getById')
179             ->willReturn($product);
180 
181         /** @var Money | MockObject $price */
182         $price = $this->createMock(Money::class);
183 
184         $this->calculatePrice
185             ->method('forCustomerAndProduct')
186             ->willReturn($price);
187 
188         $this->contractRepository
189             ->expects($this->once())
190             ->method('save')
191             ->with($this->isInstanceOf(Contract::class));
192 
193         /** @var IdentityInterface | MockObject $customerId */
194         $customerId = $this->createMock(IdentityInterface::class);
195         /** @var IdentityInterface | MockObject $productId */
196         $productId = $this->createMock(IdentityInterface::class);
197 
198         $this->customerContractsProduct->execute($customerId, $productId);
199     }
200 }

Lo primero que nos pedirá es crear la interfaz de ContractRepository src/Domain/ContractRepositoryInterface.php

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 interface ContractRepositoryInterface
7 {
8     public function save(Contract $contract): void;
9 }

El siguiente error puede desconcertarnos un poco:

1 App\Domain\Exception\ContractCouldNotBeCreatedException : Price not calculated

Pero tiene una explicación obvia. La implementación actual es inflexible en este punto y tenemos que cambiarla para poder seguir adelante:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CalculatePriceInterface;
 7 use App\Domain\CustomerRepositoryInterface;
 8 use App\Domain\Exception\CanNotCalculatePriceException;
 9 use App\Domain\Exception\ContractCouldNotBeCreatedException;
10 use App\Domain\Exception\CustomerNotFoundException;
11 use App\Domain\Exception\ProductNotFoundException;
12 use App\Domain\IdentityInterface;
13 use App\Domain\ProductRepositoryInterface;
14 
15 class CustomerContractsProduct
16 {
17     /** @var CustomerRepositoryInterface */
18     private $customerRepository;
19     /** @var ProductRepositoryInterface */
20     private $productRepository;
21     /** @var CalculatePriceInterface */
22     private $calculatePrice;
23 
24     public function __construct(
25         CustomerRepositoryInterface $customerRepository,
26         ProductRepositoryInterface $productRepository,
27         CalculatePriceInterface $calculatePrice
28     ) {
29         $this->customerRepository = $customerRepository;
30         $this->productRepository = $productRepository;
31         $this->calculatePrice = $calculatePrice;
32     }
33 
34     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
35 tId)
36     {
37         try {
38             $customer = $this->customerRepository->getById($customerId);
39             $product = $this->productRepository->getById($productId);
40 
41             if (! $product->isCustomerEligible($customer)) {
42                 throw new ContractCouldNotBeCreatedException('Customer not eligible'\
43 );
44             }
45 
46             $price = $this->calculatePrice->forCustomerAndProduct($customer, $produc\
47 t);
48 
49         } catch (CustomerNotFoundException $exception) {
50             throw new ContractCouldNotBeCreatedException('Customer not found');
51         } catch (ProductNotFoundException $exception) {
52             throw new ContractCouldNotBeCreatedException('Product not found');
53         } catch (CanNotCalculatePriceException $exception) {
54             throw new ContractCouldNotBeCreatedException('Price not calculated');
55         }
56     }
57 }

A continuación, el test fallará porque no se cumple la expectativa de ContractRepository, nadie lo llama:

1 Expectation failed for method name is equal to 'save' when invoked 1 time(s).
2 Method was expected to be called 1 times, actually called 0 times.

Por lo que tenemos que usarlo en la implementación:

 1 <?php
 2 <?php
 3 declare (strict_types=1);
 4 
 5 namespace App\Application;
 6 
 7 use App\Domain\CalculatePriceInterface;
 8 use App\Domain\Contract;
 9 use App\Domain\ContractRepositoryInterface;
10 use App\Domain\CustomerRepositoryInterface;
11 use App\Domain\Exception\CanNotCalculatePriceException;
12 use App\Domain\Exception\ContractCouldNotBeCreatedException;
13 use App\Domain\Exception\CustomerNotFoundException;
14 use App\Domain\Exception\ProductNotFoundException;
15 use App\Domain\IdentityInterface;
16 use App\Domain\ProductRepositoryInterface;
17 
18 class CustomerContractsProduct
19 {
20     /** @var CustomerRepositoryInterface */
21     private $customerRepository;
22     /** @var ProductRepositoryInterface */
23     private $productRepository;
24     /** @var CalculatePriceInterface */
25     private $calculatePrice;
26     /** @var ContractRepositoryInterface */
27     private $contractRepository;
28 
29     public function __construct(
30         CustomerRepositoryInterface $customerRepository,
31         ProductRepositoryInterface $productRepository,
32         CalculatePriceInterface $calculatePrice,
33         ContractRepositoryInterface $contractRepository
34     ) {
35         $this->customerRepository = $customerRepository;
36         $this->productRepository = $productRepository;
37         $this->calculatePrice = $calculatePrice;
38         $this->contractRepository = $contractRepository;
39     }
40 
41     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
42 tId)
43     {
44         try {
45             $customer = $this->customerRepository->getById($customerId);
46             $product = $this->productRepository->getById($productId);
47 
48             if (! $product->isCustomerEligible($customer)) {
49                 throw new ContractCouldNotBeCreatedException('Customer not eligible'\
50 );
51             }
52 
53             $price = $this->calculatePrice->forCustomerAndProduct($customer, $produc\
54 t);
55 
56             $contractId = $this->contractRepository->nextId();
57             $contract = new Contract($contractId, $customer, $product, $price);
58             $this->contractRepository->save($contract);
59         } catch (CustomerNotFoundException $exception) {
60             throw new ContractCouldNotBeCreatedException('Customer not found');
61         } catch (ProductNotFoundException $exception) {
62             throw new ContractCouldNotBeCreatedException('Product not found');
63         } catch (CanNotCalculatePriceException $exception) {
64             throw new ContractCouldNotBeCreatedException('Price not calculated');
65         }
66     }
67 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain;
 5 
 6 class Contract
 7 {
 8     /** @var IdentityInterface */
 9     private $identity;
10     /** @var Customer */
11     private $customer;
12     /** @var Product */
13     private $product;
14     /** @var Money */
15     private $price;
16 
17     public function __construct(IdentityInterface $identity, Customer $customer, Pro\
18 duct $product, Money $price)
19     {
20         $this->identity = $identity;
21         $this->customer = $customer;
22         $this->product = $product;
23         $this->price = $price;
24     }
25 }

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:

1 $contractId = $this->contractRepository->nextId();
2 $contract = new Contract($contractId, $customer, $product, $price);
3 $this->contractRepository->save($contract);

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:

 1 public function testShouldCreateAndStoreTheContract(): void
 2 {
 3     /** @var Customer | MockObject $customer */
 4     $customer = $this->createMock(Customer::class);
 5 
 6     $this->customerRepository
 7         ->method('getById')
 8         ->willReturn($customer);
 9 
10     /** @var Product | MockObject $product */
11     $product = $this->createMock(Product::class);
12 
13     $product
14         ->method('isCustomerEligible')
15         ->willReturn(true);
16 
17     $this->productRepository
18         ->method('getById')
19         ->willReturn($product);
20 
21     /** @var Money | MockObject $price */
22     $price = $this->createMock(Money::class);
23 
24     $this->calculatePrice
25         ->method('forCustomerAndProduct')
26         ->willReturn($price);
27 
28     $this->contractRepository
29         ->expects($this->once())
30         ->method('save')
31         ->with($this->isInstanceOf(Contract::class));
32 
33     /** @var IdentityInterface | MockObject $contractId */
34     $contractId = $this->createMock(IdentityInterface::class);
35 
36     $this->contractRepository
37         ->method('nextId')
38         ->willReturn($contractId);
39 
40     /** @var IdentityInterface | MockObject $customerId */
41     $customerId = $this->createMock(IdentityInterface::class);
42     /** @var IdentityInterface | MockObject $productId */
43     $productId = $this->createMock(IdentityInterface::class);
44 
45     $this->customerContractsProduct->execute($customerId, $productId);
46 }

Y eso nos obligará a añadirlo a la propia interfaz:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain;
 5 
 6 interface ContractRepositoryInterface
 7 {
 8     public function save(Contract $contract): void;
 9 
10     public function nextId(): IdentityInterface;
11 }

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:

  1 <?php
  2 declare (strict_types=1);
  3 
  4 namespace App\Tests\Unit\Application;
  5 
  6 use App\Application\CustomerContractsProduct;
  7 use App\Application\EventBusInterface;
  8 use App\Domain\Exception\ContractCouldNotBeCreatedException;
  9 use App\Domain\Exception\CustomerNotFoundException;
 10 use App\Domain\Exception\ProductNotFoundException;
 11 use App\Domain\Exception\CanNotCalculatePriceException;
 12 use App\Domain\IdentityInterface;
 13 use App\Domain\Contract;
 14 use App\Domain\Customer;
 15 use App\Domain\Product;
 16 use App\Domain\Money;
 17 use App\Domain\CalculatePriceInterface;
 18 use App\Domain\ContractRepositoryInterface;
 19 use App\Domain\CustomerRepositoryInterface;
 20 use App\Domain\ProductRepositoryInterface;
 21 use App\Domain\Event\ContractWasCreated;
 22 use App\Infrastructure\EventBus\EventBusSpy;
 23 use PHPUnit\Framework\MockObject\MockObject;
 24 use PHPUnit\Framework\TestCase;
 25 
 26 class CustomerContractsProductTest extends TestCase
 27 {
 28 
 29     /** @var CustomerContractsProduct */
 30     private $customerContractsProduct;
 31     /** @var CustomerRepositoryInterface | MockObject */
 32     private $customerRepository;
 33     /** @var ProductRepositoryInterface | MockObject */
 34     private $productRepository;
 35     /** @var CalculatePriceInterface | MockObject */
 36     private $calculatePrice;
 37     /** @var ContractRepositoryInterface | MockObject */
 38     private $contractRepository;
 39     /** @var EventBusSpy */
 40     private $eventBus;
 41 
 42     public function setUp(): void
 43     {
 44         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
 45 lass);
 46 
 47         $this->productRepository = $this->createMock(ProductRepositoryInterface::cla\
 48 ss);
 49 
 50         $this->calculatePrice = $this->createMock(CalculatePriceInterface::class);
 51 
 52         $this->contractRepository = $this->createMock(ContractRepositoryInterface::c\
 53 lass);
 54 
 55         $this->eventBus = new EventBusSpy();
 56         
 57         $this->customerContractsProduct = new CustomerContractsProduct(
 58             $this->customerRepository,
 59             $this->productRepository,
 60             $this->calculatePrice,
 61             $this->contractRepository,
 62             $this->eventBus
 63         );
 64     }
 65 
 66     public function testShouldFailIfCustomerDoesNoExist(): void
 67     {
 68         $this->customerRepository
 69             ->method('getById')
 70             ->willThrowException(new CustomerNotFoundException());
 71 
 72         /** @var IdentityInterface | MockObject $customerId */
 73         $customerId = $this->createMock(IdentityInterface::class);
 74         /** @var IdentityInterface | MockObject $productId */
 75         $productId = $this->createMock(IdentityInterface::class);
 76 
 77         $this->expectException(ContractCouldNotBeCreatedException::class);
 78         $this->expectExceptionMessage('Customer not found');
 79         $this->customerContractsProduct->execute($customerId, $productId);
 80     }
 81 
 82     public function testShouldFailIfProductDoesNoExist(): void
 83     {
 84         /** @var Customer | MockObject $customerId */
 85         $customer = $this->createMock(Customer::class);
 86 
 87         $this->customerRepository
 88             ->method('getById')
 89             ->willReturn($customer);
 90 
 91         $this->productRepository
 92             ->method('getById')
 93             ->willThrowException(new ProductNotFoundException());
 94 
 95         /** @var IdentityInterface | MockObject $customerId */
 96         $customerId = $this->createMock(IdentityInterface::class);
 97         /** @var IdentityInterface | MockObject $productId */
 98         $productId = $this->createMock(IdentityInterface::class);
 99 
100         $this->expectException(ContractCouldNotBeCreatedException::class);
101         $this->expectExceptionMessage('Product not found');
102         $this->customerContractsProduct->execute($customerId, $productId);
103     }
104 
105     public function testShouldFailIfCustomerNotEligible(): void
106     {
107         /** @var Customer | MockObject $customer */
108         $customer = $this->createMock(Customer::class);
109 
110         $this->customerRepository
111             ->method('getById')
112             ->willReturn($customer);
113 
114         /** @var Product | MockObject $product */
115         $product = $this->createMock(Product::class);
116 
117         $product
118             ->method('isCustomerEligible')
119             ->willReturn(false);
120 
121         $this->productRepository
122             ->method('getById')
123             ->willReturn($product);
124 
125         /** @var IdentityInterface | MockObject $customerId */
126         $customerId = $this->createMock(IdentityInterface::class);
127         /** @var IdentityInterface | MockObject $productId */
128         $productId = $this->createMock(IdentityInterface::class);
129 
130         $this->expectException(ContractCouldNotBeCreatedException::class);
131         $this->expectExceptionMessage('Customer not eligible');
132         $this->customerContractsProduct->execute($customerId, $productId);
133     }
134 
135     public function testShouldFailIfPriceCanNotBeCalculated(): void
136     {
137         /** @var Customer | MockObject $customer */
138         $customer = $this->createMock(Customer::class);
139 
140         $this->customerRepository
141             ->method('getById')
142             ->willReturn($customer);
143 
144         /** @var Product | MockObject $product */
145         $product = $this->createMock(Product::class);
146 
147         $product
148             ->method('isCustomerEligible')
149             ->willReturn(true);
150 
151         $this->productRepository
152             ->method('getById')
153             ->willReturn($product);
154 
155         $this->calculatePrice
156             ->method('forCustomerAndProduct')
157             ->willThrowException(new CanNotCalculatePriceException());
158 
159         /** @var IdentityInterface | MockObject $customerId */
160         $customerId = $this->createMock(IdentityInterface::class);
161         /** @var IdentityInterface | MockObject $productId */
162         $productId = $this->createMock(IdentityInterface::class);
163 
164         $this->expectException(ContractCouldNotBeCreatedException::class);
165         $this->expectExceptionMessage('Price not calculated');
166         $this->customerContractsProduct->execute($customerId, $productId);
167     }
168 
169     public function testShouldCreateAndStoreTheContract(): void
170     {
171         /** @var Customer | MockObject $customer */
172         $customer = $this->createMock(Customer::class);
173 
174         $this->customerRepository
175             ->method('getById')
176             ->willReturn($customer);
177 
178         /** @var Product | MockObject $product */
179         $product = $this->createMock(Product::class);
180 
181         $product
182             ->method('isCustomerEligible')
183             ->willReturn(true);
184 
185         $this->productRepository
186             ->method('getById')
187             ->willReturn($product);
188 
189         /** @var Money | MockObject $price */
190         $price = $this->createMock(Money::class);
191 
192         $this->calculatePrice
193             ->method('forCustomerAndProduct')
194             ->willReturn($price);
195 
196         $this->contractRepository
197             ->expects($this->once())
198             ->method('save')
199             ->with($this->isInstanceOf(Contract::class));
200 
201         /** @var IdentityInterface | MockObject $contractId */
202         $contractId = $this->createMock(IdentityInterface::class);
203 
204         $this->contractRepository
205             ->method('nextId')
206             ->willReturn($contractId);
207 
208         /** @var IdentityInterface | MockObject $customerId */
209         $customerId = $this->createMock(IdentityInterface::class);
210         /** @var IdentityInterface | MockObject $productId */
211         $productId = $this->createMock(IdentityInterface::class);
212 
213         $this->customerContractsProduct->execute($customerId, $productId);
214     }
215 
216     public function testShouldRaiseContractWasCreatedEvent(): void
217     {
218         /** @var Customer | MockObject $customer */
219         $customer = $this->createMock(Customer::class);
220 
221         $this->customerRepository
222             ->method('getById')
223             ->willReturn($customer);
224 
225         /** @var Product | MockObject $product */
226         $product = $this->createMock(Product::class);
227 
228         $product
229             ->method('isCustomerEligible')
230             ->willReturn(true);
231 
232         $this->productRepository
233             ->method('getById')
234             ->willReturn($product);
235 
236         /** @var Money | MockObject $price */
237         $price = $this->createMock(Money::class);
238 
239         $this->calculatePrice
240             ->method('forCustomerAndProduct')
241             ->willReturn($price);
242 
243         $this->contractRepository
244             ->expects($this->once())
245             ->method('save')
246             ->with($this->isInstanceOf(Contract::class));
247 
248         /** @var IdentityInterface | MockObject $contractId */
249         $contractId = $this->createMock(IdentityInterface::class);
250 
251         $this->contractRepository
252             ->method('nextId')
253             ->willReturn($contractId);
254         
255         /** @var IdentityInterface | MockObject $customerId */
256         $customerId = $this->createMock(IdentityInterface::class);
257         /** @var IdentityInterface | MockObject $productId */
258         $productId = $this->createMock(IdentityInterface::class);
259 
260         $this->customerContractsProduct->execute($customerId, $productId);
261         
262         $this->assertTrue(
263             $this->eventBus->hasReceivedEventOfClass(ContractWasCreated::class)
264         );
265     }
266 }

Ejecutaremos el test para que falle y nos indique cosas:

1 Error : Class 'App\Infrastructure\EventBus\EventBusSpy' not found

Así que creamos la clase EventBusSpy, en src/Infrastructure/EventBus/EventBusSpy.php.

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Infrastructure\EventBus;
5 
6 class EventBusSpy
7 {
8 
9 }

Luego nos pedirá:

1 Error : Call to undefined method App\Infrastructure\EventBus\EventBusSpy::hasReceive\
2 dEventOfClass()

Con lo que crearemos esta primera iteración:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Infrastructure\EventBus;
 5 
 6 class EventBusSpy
 7 {
 8     private $event;
 9 
10     public function hasReceivedEventOfClass(string $eventClass): bool
11     {
12         return is_a($this->event, $eventClass);
13     }
14 }

El siguiente fallo del test, ya requiere implementar algo:

1 Failed asserting that false is true.

Así que vamos a ello:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Application\EventBusInterface;
 7 use App\Domain\CalculatePriceInterface;
 8 use App\Domain\Contract;
 9 use App\Domain\ContractRepositoryInterface;
10 use App\Domain\CustomerRepositoryInterface;
11 use App\Domain\Exception\CanNotCalculatePriceException;
12 use App\Domain\Exception\ContractCouldNotBeCreatedException;
13 use App\Domain\Exception\CustomerNotFoundException;
14 use App\Domain\Exception\ProductNotFoundException;
15 use App\Domain\Event\ContractWasCreated;
16 use App\Domain\IdentityInterface;
17 use App\Domain\ProductRepositoryInterface;
18 
19 class CustomerContractsProduct
20 {
21     /** @var CustomerRepositoryInterface */
22     private $customerRepository;
23     /** @var ProductRepositoryInterface */
24     private $productRepository;
25     /** @var CalculatePriceInterface */
26     private $calculatePrice;
27     /** @var ContractRepositoryInterface */
28     private $contractRepository;
29     /** @var EventBusInterface */
30     private $eventBus;
31 
32     public function __construct(
33         CustomerRepositoryInterface $customerRepository,
34         ProductRepositoryInterface $productRepository,
35         CalculatePriceInterface $calculatePrice,
36         ContractRepositoryInterface $contractRepository,
37         EventBusInterface $eventBus
38     ) {
39         $this->customerRepository = $customerRepository;
40         $this->productRepository = $productRepository;
41         $this->calculatePrice = $calculatePrice;
42         $this->contractRepository = $contractRepository;
43         $this->eventBus = $eventBus;
44     }
45 
46     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
47 tId)
48     {
49         try {
50             $customer = $this->customerRepository->getById($customerId);
51             $product = $this->productRepository->getById($productId);
52 
53             if (! $product->isCustomerEligible($customer)) {
54                 throw new ContractCouldNotBeCreatedException('Customer not eligible'\
55 );
56             }
57 
58             $price = $this->calculatePrice->forCustomerAndProduct($customer, $produc\
59 t);
60 
61             $contractId = $this->contractRepository->nextId();
62             $contract = new Contract($contractId, $customer, $product, $price);
63             $this->contractRepository->save($contract);
64         } catch (CustomerNotFoundException $exception) {
65             throw new ContractCouldNotBeCreatedException('Customer not found');
66         } catch (ProductNotFoundException $exception) {
67             throw new ContractCouldNotBeCreatedException('Product not found');
68         } catch (CanNotCalculatePriceException $exception) {
69             throw new ContractCouldNotBeCreatedException('Price not calculated');
70         }
71 
72         $this->eventBus->publish(new ContractWasCreated($contractId));
73     }
74 }

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:

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Application;
5 
6 interface EventBusInterface
7 {
8 
9 }

Y hacemos que EventBusSpy la implemente:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Infrastructure\EventBus;
 5 
 6 use App\Application\EventBusInterface;
 7 
 8 class EventBusSpy implements EventBusInterface
 9 {
10     private $event;
11 
12     public function hasReceivedEventOfClass(string $eventClass): bool
13     {
14         return is_a($this->event, $eventClass);
15     }
16 }

Al lanzar los tests nos pedirá que creemos el método publish, que hemos usado, pero no definido:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\EventInterface;
 7 
 8 interface EventBusInterface
 9 {
10     public function publish(EventInterface $event): void;
11 }

Y lo implementamos en el Spy:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Infrastructure\EventBus;
 5 
 6 use App\Application\EventBusInterface;
 7 use App\Domain\EventInterface;
 8 
 9 class EventBusSpy implements EventBusInterface
10 {
11     private $event;
12 
13     public function hasReceivedEventOfClass(string $eventClass): bool
14     {
15         return is_a($this->event, $eventClass);
16     }
17 
18     public function publish(EventInterface $event): void
19     {
20         $this->event = $event;
21     }
22 }

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

1 <?php
2 declare (strict_types=1);
3 
4 namespace App\Domain;
5 
6 interface EventInterface
7 {
8 
9 }

src/Domain/Event/ContractWasCreated.php

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Domain\Event;
 5 
 6 use App\Domain\EventInterface;
 7 use App\Domain\IdentityInterface;
 8 
 9 class ContractWasCreated implements EventInterface
10 {
11     /** @var IdentityInterface */
12     private $contractId;
13 
14     public function __construct(IdentityInterface $contractId)
15     {
16         $this->contractId = $contractId;
17     }
18 
19     public function contractId(): IdentityInterface
20     {
21         return $this->contractId;
22     }
23 }

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:

  1 <?php
  2 declare (strict_types=1);
  3 
  4 namespace App\Tests\Unit\Application;
  5 
  6 use App\Application\CustomerContractsProduct;
  7 use App\Domain\CalculatePriceInterface;
  8 use App\Domain\Contract;
  9 use App\Domain\ContractRepositoryInterface;
 10 use App\Domain\Customer;
 11 use App\Domain\CustomerRepositoryInterface;
 12 use App\Domain\Event\ContractWasCreated;
 13 use App\Domain\Exception\CanNotCalculatePriceException;
 14 use App\Domain\Exception\ContractCouldNotBeCreatedException;
 15 use App\Domain\Exception\CustomerNotFoundException;
 16 use App\Domain\Exception\ProductNotFoundException;
 17 use App\Domain\IdentityInterface;
 18 use App\Domain\Money;
 19 use App\Domain\Product;
 20 use App\Domain\ProductRepositoryInterface;
 21 use App\Infrastructure\EventBus\EventBusSpy;
 22 use PHPUnit\Framework\MockObject\MockObject;
 23 use PHPUnit\Framework\TestCase;
 24 use const false;
 25 
 26 class CustomerContractsProductTest extends TestCase
 27 {
 28 
 29     /** @var CustomerContractsProduct */
 30     private $customerContractsProduct;
 31     /** @var CustomerRepositoryInterface | MockObject */
 32     private $customerRepository;
 33     /** @var ProductRepositoryInterface | MockObject */
 34     private $productRepository;
 35     /** @var CalculatePriceInterface | MockObject */
 36     private $calculatePrice;
 37     /** @var ContractRepositoryInterface | MockObject */
 38     private $contractRepository;
 39     /** @var EventBusSpy */
 40     private $eventBus;
 41 
 42     public function setUp(): void
 43     {
 44         $this->customerRepository = $this->createMock(CustomerRepositoryInterface::c\
 45 lass);
 46         $this->productRepository = $this->createMock(ProductRepositoryInterface::cla\
 47 ss);
 48         $this->calculatePrice = $this->createMock(CalculatePriceInterface::class);
 49         $this->contractRepository = $this->createMock(ContractRepositoryInterface::c\
 50 lass);
 51         $this->eventBus = new EventBusSpy();
 52 
 53         $this->customerContractsProduct = new CustomerContractsProduct(
 54             $this->customerRepository,
 55             $this->productRepository,
 56             $this->calculatePrice,
 57             $this->contractRepository,
 58             $this->eventBus
 59         );
 60     }
 61 
 62     public function testShouldFailIfCustomerDoesNoExist(): void
 63     {
 64         $this->customerRepository
 65             ->method('getById')
 66             ->willThrowException(new CustomerNotFoundException());
 67 
 68         $this->expectException(ContractCouldNotBeCreatedException::class);
 69 
 70         $this->executeUseCase();
 71     }
 72 
 73     public function testShouldFailIfProductDoesNoExist(): void
 74     {
 75         $this->prepareCustomerRepositoryWithCustomer();
 76 
 77         $this->productRepository
 78             ->method('getById')
 79             ->willThrowException(new ProductNotFoundException());
 80 
 81         $this->expectException(ContractCouldNotBeCreatedException::class);
 82 
 83         $this->executeUseCase();
 84     }
 85 
 86     public function testShouldFailIfCustomerNotEligible(): void
 87     {
 88         $this->prepareCustomerRepositoryWithCustomer();
 89         $this->prepareProductRepositoryWithProductBeingEligible(false);
 90 
 91         $this->expectException(ContractCouldNotBeCreatedException::class);
 92 
 93         $this->executeUseCase();
 94     }
 95 
 96     public function testShouldFailIfPriceCanNotBeCalculated(): void
 97     {
 98         $this->prepareCustomerRepositoryWithCustomer();
 99         $this->prepareProductRepositoryWithProductBeingEligible(true);
100 
101         $this->calculatePrice
102             ->method('forCustomerAndProduct')
103             ->willThrowException(new CanNotCalculatePriceException());
104 
105         $this->expectException(ContractCouldNotBeCreatedException::class);
106 
107         $this->executeUseCase();
108     }
109 
110     public function testShouldCreateAndStoreTheContract(): void
111     {
112         $this->prepareCustomerRepositoryWithCustomer();
113         $this->prepareProductRepositoryWithProductBeingEligible(true);
114         $this->prepareCalculatePriceService();
115         $this->prepareContractRepositoryToSaveContractWithId();
116 
117         $this->executeUseCase();
118     }
119 
120     public function testShouldRaiseContractWasCreatedEvent(): void
121     {
122         $this->prepareCustomerRepositoryWithCustomer();
123         $this->prepareProductRepositoryWithProductBeingEligible(true);
124         $this->prepareCalculatePriceService();
125         $this->prepareContractRepositoryToSaveContractWithId();
126 
127         $this->executeUseCase();
128 
129         $this->assertTrue(
130             $this->eventBus->hasReceivedEventOfClass(ContractWasCreated::class)
131         );
132     }
133 
134     private function prepareCustomerRepositoryWithCustomer(): void
135     {
136         /** @var Customer | MockObject $customerId */
137         $customer = $this->createMock(Customer::class);
138 
139         $this->customerRepository
140             ->method('getById')
141             ->willReturn($customer);
142     }
143 
144     private function prepareProductRepositoryWithProductBeingEligible($eligible): vo\
145 id
146     {
147         /** @var Product | MockObject $product */
148         $product = $this->createMock(Product::class);
149 
150         $product
151             ->method('isCustomerEligible')
152             ->willReturn($eligible);
153 
154         $this->productRepository
155             ->method('getById')
156             ->willReturn($product);
157     }
158 
159     private function prepareCalculatePriceService(): void
160     {
161         /** @var Money | MockObject $price */
162         $price = $this->createMock(Money::class);
163 
164         $this->calculatePrice
165             ->method('forCustomerAndProduct')
166             ->willReturn($price);
167     }
168 
169     private function prepareContractRepositoryToSaveContractWithId(): void
170     {
171         $this->contractRepository
172             ->expects($this->once())
173             ->method('save')
174             ->with($this->isInstanceOf(Contract::class));
175 
176         /** @var IdentityInterface | MockObject $contractId */
177         $contractId = $this->createMock(IdentityInterface::class);
178 
179         $this->contractRepository
180             ->method('nextId')
181             ->willReturn($contractId);
182     }
183 
184     private function executeUseCase(): void
185     {
186         /** @var IdentityInterface | MockObject $customerId */
187         $customerId = $this->createMock(IdentityInterface::class);
188         /** @var IdentityInterface | MockObject $productId */
189         $productId = $this->createMock(IdentityInterface::class);
190 
191         $this->customerContractsProduct->execute($customerId, $productId);
192     }
193 }

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:

 1 <?php
 2 declare (strict_types=1);
 3 
 4 namespace App\Application;
 5 
 6 use App\Domain\CalculatePriceInterface;
 7 use App\Domain\Contract;
 8 use App\Domain\ContractRepositoryInterface;
 9 use App\Domain\CustomerRepositoryInterface;
10 use App\Domain\Event\ContractWasCreated;
11 use App\Domain\Exception\ContractCouldNotBeCreatedException;
12 use App\Domain\IdentityInterface;
13 use App\Domain\ProductRepositoryInterface;
14 use Exception;
15 
16 class CustomerContractsProduct
17 {
18     /** @var CustomerRepositoryInterface */
19     private $customerRepository;
20     /** @var ProductRepositoryInterface */
21     private $productRepository;
22     /** @var CalculatePriceInterface */
23     private $calculatePrice;
24     /** @var ContractRepositoryInterface */
25     private $contractRepository;
26     /** @var EventBusInterface */
27     private $eventBus;
28 
29     public function __construct(
30         CustomerRepositoryInterface $customerRepository,
31         ProductRepositoryInterface $productRepository,
32         CalculatePriceInterface $calculatePrice,
33         ContractRepositoryInterface $contractRepository,
34         EventBusInterface $eventBus
35     ) {
36         $this->customerRepository = $customerRepository;
37         $this->productRepository = $productRepository;
38         $this->calculatePrice = $calculatePrice;
39         $this->contractRepository = $contractRepository;
40         $this->eventBus = $eventBus;
41     }
42 
43     public function execute(IdentityInterface $customerId, IdentityInterface $produc\
44 tId): void
45     {
46         try {
47             $customer = $this->customerRepository->getById($customerId);
48             $product = $this->productRepository->getById($productId);
49 
50             $this->checkCustomerIsEligible($product, $customer);
51 
52             $price = $this->calculatePrice->forCustomerAndProduct($customer, $produc\
53 t);
54 
55             $contractId = $this->contractRepository->nextId();
56             $contract = new Contract($contractId, $customer, $product, $price);
57             $this->contractRepository->save($contract);
58         } catch (Exception $exception) {
59             throw new ContractCouldNotBeCreatedException(
60                 'Contract could no be created. Reason: ' . $exception->getMessage(),
61                 1,
62                 $exception
63             );
64         }
65 
66         $this->eventBus->publish(new ContractWasCreated($contractId));
67     }
68 
69     private function checkCustomerIsEligible(\App\Domain\Product $product, \App\Doma\
70 in\Customer $customer): void
71     {
72         if (! $product->isCustomerEligible($customer)) {
73             throw new ContractCouldNotBeCreatedException('Customer not eligible');
74         }
75     }
76 }

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:

1 2017-2018/primaria/5/5C/matematicas/5433/2018-03-12-deberes.pdf

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:

 1 namespace Dojo\ClassifyDocument\Application;
 2 
 3 
 4 use DateTime;
 5 
 6 class ClassifyDocumentRequest
 7 {
 8     /**
 9      * @var string
10      */
11     private $studentId;
12     /**
13      * @var string
14      */
15     private $subject;
16     /**
17      * @var string
18      */
19     private $path;
20     /**
21      * @var DateTime
22      */
23     private $dateTime;
24     /**
25      * @var string
26      */
27     private $type;
28 
29     public function __construct(
30         string $studentId,
31         string $subject,
32         string $type,
33         string $path,
34         DateTime $dateTime
35     ) {
36         $this->studentId = $studentId;
37         $this->subject = $subject;
38         $this->type = $type;
39         $this->path = $path;
40         $this->dateTime = $dateTime;
41     }
42 
43     /**
44      * @return string
45      */
46     public function studentId() : string
47     {
48         return $this->studentId;
49     }
50 
51     /**
52      * @return string
53      */
54     public function subject() : string
55     {
56         return $this->subject;
57     }
58 
59     /**
60      * @return string
61      */
62     public function type() : string
63     {
64         return $this->type;
65     }
66 
67     /**
68      * @return string
69      */
70     public function path() : string
71     {
72         return $this->path;
73     }
74 
75     /**
76      * @return DateTime
77      */
78     public function dateTime() : DateTime
79     {
80         return $this->dateTime;
81     }
82 }

El servicio se utilizaría más o menos así:

1 $classifyDocumentRequest = new ClassifyDocumentRequest(
2 	'5433',
3 	'Matemáticas',
4 	'deberes'
5 	'misejercicioschupiguais.pdf',
6 	new DateTime('2018-03-12')
7 );
8 
9 $route = $this->classifyDocument->execute($classifyDocumentRequest);

Al ejecutarlo, debería devolvernos una cadena de este estilo:

1 2017-2018/primaria/5/5C/matematicas/5433/2018-03-12-deberes.pdf

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:

 1 namespace Tests\Dojo\ClassifyDocument\Application;
 2 
 3 use DateTime;
 4 use Dojo\ClassifyDocument\Application\ClassifyDocument;
 5 use Dojo\ClassifyDocument\Application\ClassifyDocumentRequest;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class ClassifyDocumentTest extends TestCase
 9 {
10     public function testVAlidRequestShouldGenerateRoute()
11     {
12         $classifyDocumentRequest = new ClassifyDocumentRequest(
13             '5433',
14             'Matemáticas',
15             'deberes',
16             'misejercicioschupiguais.pdf',
17             new DateTime('2018-03-12')
18         );
19         $classifyDocumentService = new ClassifyDocument();
20         $route = $classifyDocumentService->execute($classifyDocumentRequest);
21         $expected = '2017-2018/primaria/5/5C/matematicas/5433/2018-03-12-deberes.pdf\
22 ';
23         $this->assertEquals($expected, $route);
24     }
25 }

– 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:

1 2017-2018

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:

 1 namespace Tests\Dojo\ClassifyDocument;
 2 
 3 use DateTime;
 4 use Dojo\ClassifyDocument\ClassifyDocument;
 5 use Dojo\ClassifyDocument\ClassifyDocumentRequest;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class ClassifyDocumentTest extends TestCase
 9 {
10 
11     public function testSchoolYearIsTheFirstElementOfTheRoute()
12     {
13         $classifyDocumentRequest = new ClassifyDocumentRequest(
14             '5433',
15             'Matemáticas',
16             'deberes',
17             'misejercicioschupiguais.pdf',
18             new DateTime('2018-03-12')
19         );
20         $classifyDocumentService = new ClassifyDocument();
21         $route = $classifyDocumentService->execute($classifyDocumentRequest);
22         $this->assertEquals('2017-2018', $route);
23     }
24 }

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.

 1 namespace Dojo\ClassifyDocument;
 2 
 3 
 4 class ClassifyDocument
 5 {
 6 
 7     /**
 8      * ClassifyDocument constructor.
 9      */
10     public function __construct()
11     {
12     }
13 
14     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
15 ng
16     {
17     }
18 }

Y, después, una implementación obvia para hacer que el test pase:

 1 namespace Dojo\ClassifyDocument;
 2 
 3 
 4 class ClassifyDocument
 5 {
 6 
 7     /**
 8      * ClassifyDocument constructor.
 9      */
10     public function __construct()
11     {
12     }
13 
14     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
15 ng
16     {
17     	return '2017-2018';
18     }
19 }

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:

 1     public function testSchoolYearIsTheFirstElementOfTheRouteFirstQuarteIsTheSameYea\
 2 r()
 3     {
 4         $classifyDocumentRequest = new ClassifyDocumentRequest(
 5             '5433',
 6             'Matemáticas',
 7             'deberes',
 8             'misejercicioschupiguais.pdf',
 9             new DateTime('2018-10-12')
10         );
11         $classifyDocumentService = new ClassifyDocument();
12         $route = $classifyDocumentService->execute($classifyDocumentRequest);
13         $this->assertEquals('2018-2019', $route);
14     }

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:

1 namespace Dojo\ClassifyDocument;
2 
3 
4 use DateTime;
5 
6 interface CalculateSchoolYear
7 {
8     public function forDate(DateTime $dateTime) : string;
9 }

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:

 1     public function testSchoolYearForFirstQuarterIsTheSameYear()
 2     {
 3         $classifyDocumentRequest = new ClassifyDocumentRequest(
 4             '5433',
 5             'Matemáticas',
 6             'deberes',
 7             'misejercicioschupiguais.pdf',
 8             new DateTime('2018-10-12')
 9         );
10         $calculateSchoolYear = $this->prophesize(CalculateSchoolYear::class);
11         $calculateSchoolYear->forDate(new DateTime('2018-10-12'))->willReturn('2018-\
12 2019');
13 
14         $classifyDocumentService = new ClassifyDocument($calculateSchoolYear->reveal\
15 ());
16         $route = $classifyDocumentService->execute($classifyDocumentRequest);
17         $this->assertEquals('2018-2019', $route);
18     }

Como este test falla, podemos empezar a implementar lo necesario:

 1 namespace Dojo\ClassifyDocument;
 2 
 3 
 4 class ClassifyDocument
 5 {
 6     /**
 7      * @var CalculateSchoolYear
 8      */
 9     private $calculateSchoolYear;
10 
11     /**
12      * ClassifyDocument constructor.
13      */
14     public function __construct(CalculateSchoolYear $calculateSchoolYear)
15     {
16         $this->calculateSchoolYear = $calculateSchoolYear;
17     }
18 
19     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
20 ng
21     {
22         $date = $classifyDocumentRequest->dateTime();
23         $schoolYear = $this->calculateSchoolYear->forDate($date);
24         return $schoolYear;
25     }
26 }

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.

 1 namespace Tests\Dojo\ClassifyDocument;
 2 
 3 use DateTime;
 4 use Dojo\ClassifyDocument\ClassifyDocument;
 5 use Dojo\ClassifyDocument\ClassifyDocumentRequest;
 6 use Dojo\ClassifyDocument\CalculateSchoolYear;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class ClassifyDocumentTest extends TestCase
10 {
11 
12     public function testSchoolYearIsTheFirstElementOfTheRoute()
13     {
14         $classifyDocumentRequest = new ClassifyDocumentRequest(
15             '5433',
16             'Matemáticas',
17             'deberes',
18             'misejercicioschupiguais.pdf',
19             new DateTime('2018-03-12')
20         );
21         $calculateSchoolYear = $this->prophesize(CalculateSchoolYear::class);
22         $calculateSchoolYear->forDate(new DateTime('2018-03-12'))->willReturn('2017-\
23 2018');
24 
25         $classifyDocumentService = new ClassifyDocument($calculateSchoolYear->reveal\
26 ());
27         $route = $classifyDocumentService->execute($classifyDocumentRequest);
28         $this->assertEquals('2017-2018', $route);
29     }
30 
31     public function testSchoolYearForFirstQuarterIsTheSameYear()
32     {
33         $classifyDocumentRequest = new ClassifyDocumentRequest(
34             '5433',
35             'Matemáticas',
36             'deberes',
37             'misejercicioschupiguais.pdf',
38             new DateTime('2018-10-12')
39         );
40         $calculateSchoolYear = $this->prophesize(CalculateSchoolYear::class);
41         $calculateSchoolYear->forDate(new DateTime('2018-10-12'))->willReturn('2018-\
42 2019');
43 
44         $classifyDocumentService = new ClassifyDocument($calculateSchoolYear->reveal\
45 ());
46         $route = $classifyDocumentService->execute($classifyDocumentRequest);
47         $this->assertEquals('2018-2019', $route);
48     }
49 }

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.

 1 namespace Tests\Dojo\ClassifyDocument;
 2 
 3 use DateTime;
 4 use Dojo\ClassifyDocument\ClassifyDocument;
 5 use Dojo\ClassifyDocument\ClassifyDocumentRequest;
 6 use Dojo\ClassifyDocument\CalculateSchoolYear;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class ClassifyDocumentTest extends TestCase
10 {
11     private $classifyDocumentService;
12     private $calculateSchoolYear;
13 
14     private const DEFAULT_STUDENT_ID = '5433';
15     private const DEFAULT_SUBJECT = 'Matemáticas';
16     private const DEFAULT_TYPE = 'deberes';
17     private const DEFAULT_FILE = 'misejercicioschupiguais.pdf';
18     private const DEFAULT_UPLOAD_DATE = '2018-03-12';
19     
20     private const DEFAULT_SCHOOL_YEAR = '2017-2018';
21 
22     public function setUp()
23     {
24         $this->calculateSchoolYear = $this->prophesize(CalculateSchoolYear::class);
25         $this->calculateSchoolYear->forDate(new DateTime(self::DEFAULT_UPLOAD_DATE))\
26 ->willReturn(self::DEFAULT_SCHOOL_YEAR);
27         $this->classifyDocumentService = new ClassifyDocument($this->calculateSchool\
28 Year->reveal());
29     }
30 
31     public function testSchoolYearIsTheFirstElementOfTheRoute()
32     {
33         $classifyDocumentRequest = new ClassifyDocumentRequest(
34             self::DEFAULT_STUDENT_ID,
35             self::DEFAULT_SUBJECT,
36             self::DEFAULT_TYPE,
37             self::DEFAULT_FILE,
38             new DateTime(self::DEFAULT_UPLOAD_DATE)
39         );
40         
41         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
42         $this->assertEquals(self::DEFAULT_SCHOOL_YEAR, $route);
43     }
44 
45     public function testSchoolYearForFirstQuarterIsTheSameYear()
46     {
47         $uploadDate = '2018-10-12';
48         $schoolYear = '2018-2019';
49 
50         $classifyDocumentRequest = new ClassifyDocumentRequest(
51             self::DEFAULT_STUDENT_ID,
52             self::DEFAULT_SUBJECT,
53             self::DEFAULT_TYPE,
54             self::DEFAULT_FILE,
55             new DateTime($uploadDate)
56         );
57 
58         $this->calculateSchoolYear->forDate(new DateTime($uploadDate))->willReturn($\
59 schoolYear);
60 
61         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
62         $this->assertEquals($schoolYear, $route);
63     }
64 }

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):

 1 namespace Dojo\ClassifyDocument;
 2 
 3 class Student
 4 {
 5     /**
 6      * @var string
 7      */
 8     private $id;
 9     /**
10      * @var string
11      */
12     private $name;
13     /**
14      * @var string
15      */
16     private $level;
17     /**
18      * @var string
19      */
20     private $stage;
21     /**
22      * @var string
23      */
24     private $group;
25 
26     public function __construct(string $id, string $name, string $level, string $sta\
27 ge, string $group)
28     {
29         $this->id = $id;
30         $this->name = $name;
31         $this->level = $level;
32         $this->stage = $stage;
33         $this->group = $group;
34     }
35 
36     /**
37      * @return string
38      */
39     public function id() : string
40     {
41         return $this->id;
42     }
43 
44     /**
45      * @return string
46      */
47     public function name() : string
48     {
49         return $this->name;
50     }
51 
52     /**
53      * @return string
54      */
55     public function level() : string
56     {
57         return $this->level;
58     }
59 
60     /**
61      * @return string
62      */
63     public function stage() : string
64     {
65         return $this->stage;
66     }
67 
68     /**
69      * @return string
70      */
71     public function group() : string
72     {
73         return $this->group;
74     }
75 }

Además, contamos con un repositorio de Student que tiene esta interfaz, la cual me servirá para generar un nuevo stub:

1 <?php
2 namespace Dojo\ClassifyDocument;
3 
4 
5 interface StudentRepository
6 {
7     public function byId(string $id): Student;
8 }

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í:

 1     public function testStageIstheSecondFolderLevel()
 2     {
 3         $expectedStage = 'primaria';
 4 
 5         $classifyDocumentRequest = new ClassifyDocumentRequest(
 6             self::DEFAULT_STUDENT_ID,
 7             self::DEFAULT_SUBJECT,
 8             self::DEFAULT_TYPE,
 9             self::DEFAULT_FILE,
10             new DateTime(self::DEFAULT_UPLOAD_DATE)
11         );
12 
13         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
14         
15         [, $stage] = explode('/', $route);
16         $this->assertEquals($expectedStage, $stage);
17     }

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.

 1 class ClassifyDocument
 2 {
 3     /**
 4      * @var CalculateSchoolYear
 5      */
 6     private $calculateSchoolYear;
 7 
 8     /**
 9      * ClassifyDocument constructor.
10      */
11     public function __construct(CalculateSchoolYear $calculateSchoolYear)
12     {
13         $this->calculateSchoolYear = $calculateSchoolYear;
14     }
15 
16     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
17 ng
18     {
19         $date = $classifyDocumentRequest->dateTime();
20         $schoolYear = $this->calculateSchoolYear->forDate($date);
21         return $schoolYear.'/primaria';
22     }
23 }

Ahí lo tenemos: nuestro test actual pasa, pero rompemos los anteriores. Así que voy a arreglarlos:

 1 public function testSchoolYearIsTheFirstElementOfTheRoute()
 2     {
 3         $classifyDocumentRequest = new ClassifyDocumentRequest(
 4             self::DEFAULT_STUDENT_ID,
 5             self::DEFAULT_SUBJECT,
 6             self::DEFAULT_TYPE,
 7             self::DEFAULT_FILE,
 8             new DateTime(self::DEFAULT_UPLOAD_DATE)
 9         );
10 
11         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
12         
13         [$schoolYear] = explode('/', $route);
14         $this->assertEquals(self::DEFAULT_SCHOOL_YEAR, $schoolYear);
15     }
16 
17     public function testSchoolYearForFirstQuarterIsTheSameYear()
18     {
19         $uploadDate = '2018-10-12';
20         $schoolYear = '2018-2019';
21 
22         $classifyDocumentRequest = new ClassifyDocumentRequest(
23             self::DEFAULT_STUDENT_ID,
24             self::DEFAULT_SUBJECT,
25             self::DEFAULT_TYPE,
26             self::DEFAULT_FILE,
27             new DateTime($uploadDate)
28         );
29 
30         $this->calculateSchoolYear->forDate(new DateTime($uploadDate))->willReturn($\
31 schoolYear);
32 
33         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
34         
35         [$schoolYear] = explode('/', $route);
36         $this->assertEquals($schoolYear, $schoolYear);
37     }

¿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.

1     [$schoolYear] = explode('/', $route);
2     [, $stage] = explode('/', $route);

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:

 1     public function testStageIstheSecondFolderLevelAndMayVary()
 2     {
 3         $expectedStage = 'secundaria';
 4         $studentId = 6745;
 5 
 6         $classifyDocumentRequest = new ClassifyDocumentRequest(
 7             $studentId,
 8             self::DEFAULT_SUBJECT,
 9             self::DEFAULT_TYPE,
10             self::DEFAULT_FILE,
11             new DateTime(self::DEFAULT_UPLOAD_DATE)
12         );
13 
14         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
15 
16         [, $stage] = explode('/', $route);
17         $this->assertEquals($expectedStage, $stage);
18     }

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.

 1     public function setUp()
 2     {
 3         $this->calculateSchoolYear = $this->prophesize(
 4             CalculateSchoolYear::class
 5         );
 6         $this->calculateSchoolYear
 7             ->forDate(new DateTime(self::DEFAULT_UPLOAD_DATE))
 8             ->willReturn(self::DEFAULT_SCHOOL_YEAR);
 9 
10         $this->studentRepository = $this->prophesize(
11             StudentRepository::class
12         );
13 
14         $this->studentRepository->byId(self::DEFAULT_STUDENT_ID)
15             ->willReturn(new Student(
16                 self::DEFAULT_STUDENT_ID,
17                 'Pepito',
18                 '5',
19                 'primaria',
20                 '5C'
21             ));
22         $this->classifyDocumentService = new ClassifyDocument(
23             $this->calculateSchoolYear->reveal(),
24             $this->studentRepository->reveal()
25         );
26     }

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:

 1     public function testStageIstheSecondFolderLevelAndMayVary()
 2     {
 3         $expectedStage = 'secundaria';
 4         $studentId = 6745;
 5 
 6         $this->studentRepository->byId($studentId)
 7             ->willReturn(new Student(
 8                 $studentId,
 9                 'Pepito',
10                 '4',
11                 $expectedStage,
12                 '4C'
13             ));
14         
15         $classifyDocumentRequest = new ClassifyDocumentRequest(
16             $studentId,
17             self::DEFAULT_SUBJECT,
18             self::DEFAULT_TYPE,
19             self::DEFAULT_FILE,
20             new DateTime(self::DEFAULT_UPLOAD_DATE)
21         );
22 
23         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
24 
25         [, $stage] = explode('/', $route);
26         $this->assertEquals($expectedStage, $stage);
27     }

El test sigue fallando porque realmente no hemos implementado nada todavía, lo que no debería darnos muchos problemas:

 1 class ClassifyDocument
 2 {
 3     /**
 4      * @var CalculateSchoolYear
 5      */
 6     private $calculateSchoolYear;
 7     /**
 8      * @var StudentRepository
 9      */
10     private $studentRepository;
11 
12     /**
13      * ClassifyDocument constructor.
14      */
15     public function __construct(
16         CalculateSchoolYear $calculateSchoolYear,
17         StudentRepository $studentRepository
18     ) {
19         $this->calculateSchoolYear = $calculateSchoolYear;
20         $this->studentRepository = $studentRepository;
21     }
22 
23     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
24 ng
25     {
26         $date = $classifyDocumentRequest->dateTime();
27         $schoolYear = $this->calculateSchoolYear->forDate($date);
28 
29         $student = $this->studentRepository->byId(
30             $classifyDocumentRequest->studentId()
31         );
32         return $schoolYear.'/'.$student->stage();
33     }
34 }

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:

 1     public function testlevelIstheThirdFolderLevel()
 2     {
 3         $classifyDocumentRequest = new ClassifyDocumentRequest(
 4             $studentId,
 5             self::DEFAULT_SUBJECT,
 6             self::DEFAULT_TYPE,
 7             self::DEFAULT_FILE,
 8             new DateTime(self::DEFAULT_UPLOAD_DATE)
 9         );
10 
11         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
12 
13         [, , $level] = explode('/', $route);
14         $this->assertEquals('5', $level);
15     }

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:

 1 class ClassifyDocument
 2 {
 3     /**
 4      * @var CalculateSchoolYear
 5      */
 6     private $calculateSchoolYear;
 7     /**
 8      * @var StudentRepository
 9      */
10     private $studentRepository;
11 
12     /**
13      * ClassifyDocument constructor.
14      */
15     public function __construct(
16         CalculateSchoolYear $calculateSchoolYear,
17         StudentRepository $studentRepository
18     ) {
19         $this->calculateSchoolYear = $calculateSchoolYear;
20         $this->studentRepository = $studentRepository;
21     }
22 
23     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
24 ng
25     {
26         $date = $classifyDocumentRequest->dateTime();
27         $schoolYear = $this->calculateSchoolYear->forDate($date);
28 
29         $student = $this->studentRepository->byId(
30             $classifyDocumentRequest->studentId()
31         );
32         return $schoolYear.'/'.$student->stage().'/'.$student->level();
33     }
34 }

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:

 1 namespace Dojo\ClassifyDocument;
 2 
 3 class ClassifyDocument
 4 {
 5     /**
 6      * @var CalculateSchoolYear
 7      */
 8     private $calculateSchoolYear;
 9     /**
10      * @var StudentRepository
11      */
12     private $studentRepository;
13 
14     /**
15      * ClassifyDocument constructor.
16      */
17     public function __construct(
18         CalculateSchoolYear $calculateSchoolYear,
19         StudentRepository $studentRepository
20     ) {
21         $this->calculateSchoolYear = $calculateSchoolYear;
22         $this->studentRepository = $studentRepository;
23     }
24 
25     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
26 ng
27     {
28         $date = $classifyDocumentRequest->dateTime();
29         $schoolYear = $this->calculateSchoolYear->forDate($date);
30 
31         $student = $this->studentRepository->byId(
32             $classifyDocumentRequest->studentId()
33         );
34 
35         $route = [
36             $schoolYear,
37             $student->stage(),
38             $student->level()
39         ];
40         return implode(DIRECTORY_SEPARATOR, $route);
41     }
42 }

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:

 1     public function testGroupIstheFourthFolderLevel()
 2     {
 3         $classifyDocumentRequest = new ClassifyDocumentRequest(
 4             self::DEFAULT_STUDENT_ID,
 5             self::DEFAULT_SUBJECT,
 6             self::DEFAULT_TYPE,
 7             self::DEFAULT_FILE,
 8             new DateTime(self::DEFAULT_UPLOAD_DATE)
 9         );
10 
11         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
12 
13         [, , , $group] = explode('/', $route);
14         $this->assertEquals('5C', $group);
15     }

Test en rojo: a implementar se ha dicho, pero ahora ya es muy fácil:

 1 namespace Dojo\ClassifyDocument;
 2 
 3 
 4 class ClassifyDocument
 5 {
 6     /**
 7      * @var CalculateSchoolYear
 8      */
 9     private $calculateSchoolYear;
10     /**
11      * @var StudentRepository
12      */
13     private $studentRepository;
14 
15     /**
16      * ClassifyDocument constructor.
17      */
18     public function __construct(
19         CalculateSchoolYear $calculateSchoolYear,
20         StudentRepository $studentRepository
21     ) {
22         $this->calculateSchoolYear = $calculateSchoolYear;
23         $this->studentRepository = $studentRepository;
24     }
25 
26     public function execute(ClassifyDocumentRequest $classifyDocumentRequest) : stri\
27 ng
28     {
29         $date = $classifyDocumentRequest->dateTime();
30         $schoolYear = $this->calculateSchoolYear->forDate($date);
31 
32         $student = $this->studentRepository->byId(
33             $classifyDocumentRequest->studentId()
34         );
35 
36         $route = [
37             $schoolYear,
38             $student->stage(),
39             $student->level(),
40             $student->group()
41         ];
42         return implode(DIRECTORY_SEPARATOR, $route);
43     }
44 }

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í:

 1 namespace Tests\Dojo\ClassifyDocument\Application;
 2 
 3 use DateTime;
 4 use Dojo\ClassifyDocument\Application\ClassifyDocument;
 5 use Dojo\ClassifyDocument\Application\ClassifyDocumentRequest;
 6 use Dojo\ClassifyDocument\Application\CalculateSchoolYear;
 7 use Dojo\ClassifyDocument\Domain\Student;
 8 use Dojo\ClassifyDocument\Domain\StudentRepository;
 9 use PHPUnit\Framework\TestCase;
10 
11 class ClassifyDocumentAcceptanceTest extends TestCase
12 {
13     private const DEFAULT_STUDENT_ID = '5433';
14     private const DEFAULT_SUBJECT = 'Matemáticas';
15     private const DEFAULT_TYPE = 'deberes';
16     private const DEFAULT_FILE = 'misejercicioschupiguais.pdf';
17     private const DEFAULT_UPLOAD_DATE = '2018-03-12';
18 
19     private const DEFAULT_SCHOOL_YEAR = '2017-2018';
20 
21     public function setUp()
22     {
23         $this->calculateSchoolYear = $this->prophesize(
24             CalculateSchoolYear::class
25         );
26         $this->calculateSchoolYear
27             ->forDate(new DateTime(self::DEFAULT_UPLOAD_DATE))
28             ->willReturn(self::DEFAULT_SCHOOL_YEAR);
29 
30         $this->studentRepository = $this->prophesize(
31             StudentRepository::class
32         );
33 
34         $this->studentRepository->byId(self::DEFAULT_STUDENT_ID)
35             ->willReturn(new Student(
36                 self::DEFAULT_STUDENT_ID,
37                 'Pepito',
38                 '5',
39                 'primaria',
40                 '5C'
41             ));
42         $this->classifyDocumentService = new ClassifyDocument(
43             $this->calculateSchoolYear->reveal(),
44             $this->studentRepository->reveal()
45         );
46     }
47 
48     public function testValidRequestShouldGenerateRoute()
49     {
50         $classifyDocumentRequest = new ClassifyDocumentRequest(
51             self::DEFAULT_STUDENT_ID,
52             self::DEFAULT_SUBJECT,
53             self::DEFAULT_TYPE,
54             self::DEFAULT_FILE,
55             new DateTime(self::DEFAULT_UPLOAD_DATE)
56         );
57         $route = $this->classifyDocumentService->execute($classifyDocumentRequest);
58         $expected = '2017-2018/primaria/5/5C/matemáticas/5433/2018-03-12-deberes.pdf\
59 ';
60         $this->assertEquals($expected, $route);
61     }
62 }

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:

1 bin/phpunit --generate-configuration

Este comando es interactivo y nos pedirá confirmar algunos valores según nuestro proyecto:

1 Bootstrap script (relative to path shown above; default: vendor/autoload.php): 
2 Tests directory (relative to path shown above; default: tests): 
3 Source directory (relative to path shown above; default: src): 

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:

 1 <?xml version="1.0" encoding="UTF-8"?>
 2 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 3          xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
 4          bootstrap="vendor/autoload.php"
 5          forceCoversAnnotation="false"
 6          beStrictAboutCoversAnnotation="true"
 7          beStrictAboutOutputDuringTests="true"
 8          beStrictAboutTodoAnnotatedTests="true"
 9          verbose="true">
10     <testsuite name="default">
11         <directory suffix="Test.php">tests</directory>
12     </testsuite>
13     <filter>
14         <whitelist processUncoveredFilesFromWhitelist="true">
15             <directory suffix=".php">src</directory>
16         </whitelist>
17     </filter>
18 </phpunit>

Con esto, podremos ejecutar phpunit con el informe de coverage que más nos convenga:

1 bin/phpunit --coverage-html ./coverage

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
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
true false true
false true true
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

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:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 use TalkingBit\Readable\PasswordGenerator;
 5 
 6 class PasswordGeneratorTest extends TestCase
 7 {
 8 
 9     public function testItGeneratesAStringPassword(): void
10     {
11         $generator = new PasswordGenerator();
12         $password = $generator->generate();
13         $this->assertInternalType('string', $password);
14     }
15 }

Esto nos permite crear una primera implementación simple para poder empezar:

1 class PasswordGenerator
2 {
3 
4     public function generate(): string
5     {
6         return "";
7     }
8 }

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:

1     public function testItGeneratesAStringWithAMinimumLengthOfSixCharacters()
2     {
3         $generator = new PasswordGenerator();
4         $password = $generator->generate();
5         $this->assertGreaterThanOrEqual(6, strlen($password));
6     }

De nuevo, podemos hacer una implementación mínima e inflexible:

 1 namespace TalkingBit\Readable;
 2 
 3 class PasswordGenerator
 4 {
 5 
 6     public function generate(): string
 7     {
 8         return "abcdef";
 9     }
10 }

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:

1     public function testItGeneratesAStringWithAMinimumLength()
2     {
3         $generator = new PasswordGenerator();
4         $password = $generator->generate();
5         $this->assertGreaterThanOrEqual(6, strlen($password));
6     }

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.

1 class PasswordGenerator
2 {
3     public const MINIMUM_LENGTH = 6;
4 
5     public function generate(): string
6     {
7         return "abcdef";
8     }
9 }

Y ahora podríamos cambiar el test conforme a lo anterior:

1     public function testItGeneratesAStringWithAMinimumLength()
2     {
3         $generator = new PasswordGenerator();
4         $password = $generator->generate();
5         $this->assertGreaterThanOrEqual(PasswordGenerator::MINIMUM_LENGTH, strlen($p\
6 assword));
7     }

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.

1 namespace TalkingBit\Readable;
2 
3 interface RandomSymbolGenerator
4 {
5     public function generate();
6 }

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.

 1     public function testItGeneratesAPasswordUsingSymbolsPickedfromRandomGeneartor()
 2     {
 3         $randomProphecy = $this->prophesize(RandomSymbolGenerator::class);
 4         $randomProphecy->generate()->willReturn('1', '2', '3', '4', '5', '6');
 5         $generator = new PasswordGenerator($randomProphecy->reveal());
 6         
 7         $password = $generator->generate();
 8         
 9         $this->assertEquals('123456', $password);
10     }

Como es obvio, el test no pasará y nos toca implementar algo:

 1 namespace TalkingBit\Readable;
 2 
 3 class PasswordGenerator
 4 {
 5     public const MINIMUM_LENGTH = 6;
 6     
 7     /** @var RandomSymbolGenerator */
 8     private $RandomSymbolGenerator;
 9 
10     public function __construct(RandomSymbolGenerator $RandomSymbolGenerator)
11     {
12         $this->RandomSymbolGenerator = $RandomSymbolGenerator;
13     }
14 
15     public function generate(): string
16     {
17         $password = '';
18         for ($item = 0; $item < self::MINIMUM_LENGTH; $item++) {
19             $password .= $this->RandomSymbolGenerator->generate();
20         }
21         return $password;
22     }
23 }

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.

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use TalkingBit\Readable\RandomSyllableGenerator;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class RandomSyllableGeneratorTest extends TestCase
 7 {
 8 
 9     public function testSyllableHasOneVocal()
10     {
11         $generator = new RandomSyllableGenerator();
12         $syllable = $generator->generate();
13         $this->assertRegExp('/[aeiou]/', $syllable);
14     }
15 }

Comencemos por una implementación mínima para pasar el test:

 1 namespace TalkingBit\Readable;
 2 
 3 class RandomSyllableGenerator implements RandomSymbolGenerator
 4 {
 5 
 6     public function generate(): string
 7     {
 8         return 'a';
 9     }
10 }

El siguiente requisito es que si hay más de una vocal, deben formar diptongo:

Esto es algo más largo de expresar:

1    public function testWhenTwoVowelsTheyMustFormDiphthong()
2     {
3         $generator = new RandomSyllableGenerator();
4         $syllable = $generator->generate();
5         $this->assertRegExp('/ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo/', $syllable\
6 );
7     }
8 }

Por tanto, empezamos a implementar y llegamos a esta primera solución preliminar, que consiste en escoger una entre varias opciones de cada tipo:

 1 namespace TalkingBit\Readable;
 2 
 3 class RandomSyllableGenerator implements RandomSymbolGenerator
 4 {
 5     private const VOWELS = ['a', 'e', 'i', 'o', 'u'];
 6     private const DIPHTHONGS = [
 7         'ai',
 8         'au',
 9         'ei',
10         'eu',
11         'ia',
12         'ie',
13         'iu',
14         'oi',
15         'ou',
16         'ua',
17         'ue',
18         'ui',
19         'uo'
20     ];
21     public function generate(): string
22     {
23         return self::DIPHTHONGS[1];
24     }
25 }

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.

 1 namespace TalkingBit\Readable;
 2 
 3 class RandomSyllableGenerator implements RandomSymbolGenerator
 4 {
 5     private const VOWEL_GROUP = [
 6         'a', 'e', 'i', 'o', 'u',
 7         'ai', 'au',
 8         'ei', 'eu',
 9         'ia', 'ie', 'io', 'iu',
10         'oi', 'ou',
11         'ua', 'ue', 'ui', 'uo'
12     ];
13     public function generate(): string
14     {
15         return self::VOWEL_GROUP[1];
16     }
17 }

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.

1 namespace TalkingBit\Readable;
2 
3 interface RandomnessEngine
4 {
5     public function pickIntegerBetween(int $min, int $max): int;
6 }

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:

1     public function testWhenTwoVowelsTheyMustFormDiphthong()
2     {
3         $engineProphecy = $this->prophesize(RandomnessEngine::class);
4         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(18);
5         $generator = new RandomSyllableGenerator($engineProphecy->reveal());
6         $syllable = $generator->generate();
7         $this->assertRegExp('/ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo/', $syllable\
8 );
9     }

De momento, seguirá fallando porque no hemos implementado nada. Así que vamos a hacerlo pasar:

 1 namespace TalkingBit\Readable;
 2 
 3 class RandomSyllableGenerator implements RandomSymbolGenerator
 4 {
 5     private const VOWEL_GROUP = [
 6         'a', 'e', 'i', 'o', 'u',
 7         'ai', 'au',
 8         'ei', 'eu',
 9         'ia', 'ie', 'io', 'iu',
10         'oi', 'ou',
11         'ua', 'ue', 'ui', 'uo'
12     ];
13     
14     /** @var RandomnessEngine */
15     private $RandomnessEngine;
16 
17     public function __construct(RandomnessEngine $RandomnessEngine)
18     {
19         $this->RandomnessEngine = $RandomnessEngine;
20     }
21 
22     public function generate(): string
23     {
24         $pick = $this->RandomnessEngine->pickIntegerBetween(0, count(self::VOWEL_GRO\
25 UP) - 1);
26 
27         return self::VOWEL_GROUP[$pick];
28     }
29 }

El segundo test ahora pasa, pero la nueva implementación nos obliga a cambiar el primero, que quedaría así:

1     public function testSyllableHasOneVowel()
2     {
3         $engineProphecy = $this->prophesize(RandomnessEngine::class);
4         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
5         $generator = new RandomSyllableGenerator($engineProphecy->reveal());
6         $syllable = $generator->generate();
7         $this->assertRegExp('/[aeiou]/', $syllable);
8     }

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:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 use TalkingBit\Readable\RandomnessEngine;
 5 use TalkingBit\Readable\RandomSyllableGenerator;
 6 
 7 class RandomSyllableGeneratorTest extends TestCase
 8 {
 9 
10     const VOWEL_GROUP_PATTERN = '/[aeiou]|ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo/\
11 ';
12 
13     public function testASyllableHasOneVowelGroup()
14     {
15         $engineProphecy = $this->prophesize(RandomnessEngine::class);
16         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(4);
17         $generator = new RandomSyllableGenerator($engineProphecy->reveal());
18         $this->assertValidVowelGroup($generator->generate());
19     }
20     
21     public function assertValidVowelGroup(string $syllable): void
22     {
23         $this->assertRegExp(self::VOWEL_GROUP_PATTERN, $syllable);
24     }
25 }
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:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 use TalkingBit\Readable\RandomnessEngine;
 5 use TalkingBit\Readable\RandomSyllableGenerator;
 6 
 7 class RandomSyllableGeneratorTest extends TestCase
 8 {
 9 
10     const VOWEL_GROUP_PATTERN = '[aeiou]|ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo';
11     const CONSONANT_GROUP_PATTERN = '[^aeiou]|bl|br|ch|cl|cr|fl|fr|ll|pr|pl|tr';
12 
13     public function testASyllableHasOneVowelGroup()
14     {
15         $engineProphecy = $this->prophesize(RandomnessEngine::class);
16         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(4);
17         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
18         $generator = new RandomSyllableGenerator($engineProphecy->reveal());
19         $this->assertValidVowelGroup($generator->generate());
20     }
21 
22     public function testASyllableCanStartWithOneConsonantGroup()
23     {
24         $engineProphecy = $this->prophesize(RandomnessEngine::class);
25         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
26         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
27         $generator = new RandomSyllableGenerator($engineProphecy->reveal());
28         $this->assertStartsWithConsonantGroup($generator->generate());
29     }
30 
31     public function assertValidVowelGroup(string $syllable): void
32     {
33         $this->assertRegExp(sprintf('/%s/', self::VOWEL_GROUP_PATTERN), $syllable);
34     }
35 
36     private function assertStartsWithConsonantGroup(string $syllable): void
37     {
38         $pattern = sprintf('/^(%s)?/', self::CONSONANT_GROUP_PATTERN, self::VOWEL_GR\
39 OUP_PATTERN);
40         $this->assertRegExp($pattern, $syllable);
41     }
42 }

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.

 1 namespace TalkingBit\Readable;
 2 
 3 class RandomSyllableGenerator implements RandomSymbolGenerator
 4 {
 5     private const VOWEL_GROUP = [
 6         'a',
 7         'e',
 8         'i',
 9         'o',
10         'u',
11         'ai',
12         'au',
13         'ei',
14         'eu',
15         'ia',
16         'ie',
17         'io',
18         'iu',
19         'oi',
20         'ou',
21         'ua',
22         'ue',
23         'ui',
24         'uo'
25     ];
26 
27     private const CONSONANT_GROUP = [
28         'b',
29         'c',
30         'd',
31         'f',
32         'g',
33         'h',
34         'j',
35         'k',
36         'l',
37         'm',
38         'n',
39         'ñ',
40         'p',
41         'q',
42         'r',
43         's',
44         't',
45         'v',
46         'w',
47         'x',
48         'y',
49         'z',
50         'bl',
51         'br',
52         'ch',
53         'cl',
54         'cr',
55         'fl',
56         'fr',
57         'll',
58         'pr',
59         'pl',
60         'tr',
61         ''
62     ];
63 
64     /** @var RandomnessEngine */
65     private $RandomnessEngine;
66 
67     public function __construct(RandomnessEngine $RandomnessEngine)
68     {
69         $this->RandomnessEngine = $RandomnessEngine;
70     }
71 
72     public function generate(): string
73     {
74         return $this->pickAConsonant() . $this->pickAVowel();
75     }
76 
77     private function pickAVowel(): string
78     {
79         $pick = $this->RandomnessEngine->pickIntegerBetween(0, count(self::VOWEL_GRO\
80 UP) - 1);
81 
82         return self::VOWEL_GROUP[$pick];
83     }
84     
85     private function pickAConsonant(): string
86     {
87         $pick = $this->RandomnessEngine->pickIntegerBetween(0, count(self::CONSONANT\
88 _GROUP) - 1);
89 
90         return self::CONSONANT_GROUP[$pick];
91     }
92 }

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:

 1     public function testASyllableCanEndWithAConsonant()
 2     {
 3         $engineProphecy = $this->prophesize(RandomnessEngine::class);
 4         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
 5         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
 6         $engineProphecy->pickIntegerBetween(0, 5)->willReturn(0);        
 7         $generator = new RandomSyllableSymbolGenerator($engineProphecy->reveal());
 8         
 9         $this->assertRegExp('/[nlsrd]?$/', $generator->generate());
10     }

Y esta la implementación que lo cumpla:

  1 namespace TalkingBit\Readable;
  2 
  3 class RandomSyllableSymbolGenerator implements RandomSymbolGenerator
  4 {
  5     private const VOWEL_GROUP = [
  6         'a',
  7         'e',
  8         'i',
  9         'o',
 10         'u',
 11         'ai',
 12         'au',
 13         'ei',
 14         'eu',
 15         'ia',
 16         'ie',
 17         'io',
 18         'iu',
 19         'oi',
 20         'ou',
 21         'ua',
 22         'ue',
 23         'ui',
 24         'uo'
 25     ];
 26 
 27     private const CONSONANT_GROUP = [
 28         'b',
 29         'c',
 30         'd',
 31         'f',
 32         'g',
 33         'h',
 34         'j',
 35         'k',
 36         'l',
 37         'm',
 38         'n',
 39         'ñ',
 40         'p',
 41         'q',
 42         'r',
 43         's',
 44         't',
 45         'v',
 46         'w',
 47         'x',
 48         'y',
 49         'z',
 50         'bl',
 51         'br',
 52         'ch',
 53         'cl',
 54         'cr',
 55         'fl',
 56         'fr',
 57         'll',
 58         'pr',
 59         'pl',
 60         'tr',
 61         ''
 62     ];
 63 
 64     private const ENDING_CONSONANT = ['n', 'l', 's', 'r', 'd', ''];
 65 
 66     /** @var RandomnessEngine */
 67     private $randomEngine;
 68 
 69     public function __construct(RandomnessEngine $randomEngine)
 70     {
 71         $this->randomEngine = $randomEngine;
 72     }
 73 
 74     public function generate(): string
 75     {
 76         return $this->pickAConsonant()
 77             . $this->pickAVowel()
 78             . $this->pickEndingConsonant();
 79     }
 80 
 81     private function pickAVowel(): string
 82     {
 83         $pick = $this->randomEngine->pickIntegerBetween(0, count(self::VOWEL_GROUP) \
 84 - 1);
 85 
 86         return self::VOWEL_GROUP[$pick];
 87     }
 88 
 89     private function pickAConsonant(): string
 90     {
 91         $pick = $this->randomEngine->pickIntegerBetween(0, count(self::CONSONANT_GRO\
 92 UP) - 1);
 93 
 94         return self::CONSONANT_GROUP[$pick];
 95     }
 96 
 97     private function pickEndingConsonant(): string
 98     {
 99         $pick = $this->randomEngine->pickIntegerBetween(0, count(self::ENDING_CONSON\
100 ANT) -1);
101 
102         return self::ENDING_CONSONANT[$pick];
103     }
104 }

Ahora que tenemos todos los tests pasando en verde voy a refactorizar los tests, dado que hay unas repeticiones bastante manifiestas:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 use TalkingBit\Readable\RandomnessEngine;
 5 use TalkingBit\Readable\RandomSyllableSymbolGenerator;
 6 
 7 class RandomSyllableGeneratorTest extends TestCase
 8 {
 9 
10     const VOWEL_GROUP_PATTERN = '[aeiou]|ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo';
11     const CONSONANT_GROUP_PATTERN = '[^aeiou]|bl|br|ch|cl|cr|fl|fr|ll|pr|pl|tr';
12 
13     public function testASyllableHasOneVowelGroup()
14     {
15         $engineProphecy = $this->prophesize(RandomnessEngine::class);
16         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
17         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
18         $engineProphecy->pickIntegerBetween(0, 5)->willReturn(0);
19         $generator = new RandomSyllableSymbolGenerator($engineProphecy->reveal());
20         $this->assertValidVowelGroup($generator->generate());
21     }
22 
23     public function testASyllableCanStartWithOneConsonantGroup()
24     {
25         $engineProphecy = $this->prophesize(RandomnessEngine::class);
26         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
27         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
28         $engineProphecy->pickIntegerBetween(0, 5)->willReturn(0);
29         $generator = new RandomSyllableSymbolGenerator($engineProphecy->reveal());
30         $this->assertStartsWithConsonantGroup($generator->generate());
31     }
32 
33     public function testASyllableCanEndWithAConsonant()
34     {
35         $engineProphecy = $this->prophesize(RandomnessEngine::class);
36         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
37         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
38         $engineProphecy->pickIntegerBetween(0, 5)->willReturn(0);
39         $generator = new RandomSyllableSymbolGenerator($engineProphecy->reveal());
40         $this->assertRegExp('/[nlsrd]$/', $generator->generate());
41     }
42 
43     public function assertValidVowelGroup(string $syllable): void
44     {
45         $this->assertRegExp(sprintf('/%s/', self::VOWEL_GROUP_PATTERN), $syllable);
46     }
47 
48     private function assertStartsWithConsonantGroup(string $syllable): void
49     {
50         $pattern = sprintf('/^(%s)%s/', self::CONSONANT_GROUP_PATTERN, self::VOWEL_G\
51 ROUP_PATTERN);
52         $this->assertRegExp($pattern, $syllable);
53     }
54 }

De forma que quedaría más o menos así:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use PHPUnit\Framework\TestCase;
 4 use TalkingBit\Readable\RandomnessEngine;
 5 use TalkingBit\Readable\RandomSyllableSymbolGenerator;
 6 
 7 class RandomSyllableGeneratorTest extends TestCase
 8 {
 9 
10     const VOWEL_GROUP_PATTERN = '[aeiou]|ai|au|ei|eu|ia|ie|io|iu|oi|ou|ua|ue|ui|uo';
11     const CONSONANT_GROUP_PATTERN = '[^aeiou]|bl|br|ch|cl|cr|fl|fr|ll|pr|pl|tr';
12     const ENDING_CONSONANT = '[nlsrd]';
13 
14     private $randomSyllableSymbolGenerator;
15 
16     public function setUp()
17     {
18         $engineProphecy = $this->prophesize(RandomnessEngine::class);
19         $engineProphecy->pickIntegerBetween(0, 18)->willReturn(0);
20         $engineProphecy->pickIntegerBetween(0, 33)->willReturn(0);
21         $engineProphecy->pickIntegerBetween(0, 5)->willReturn(0);
22         $this->randomSyllableSymbolGenerator = new RandomSyllableSymbolGenerator($en\
23 gineProphecy->reveal());
24     }
25 
26     public function testASyllableHasOneVowelGroup()
27     {
28         $syllable = $this->randomSyllableSymbolGenerator->generate();
29         $this->assertValidVowelGroup($syllable);
30     }
31 
32     public function testASyllableCanStartWithOneConsonantGroup()
33     {
34         $syllable = $this->randomSyllableSymbolGenerator->generate();
35         $this->assertStartsWithConsonantGroup($syllable);
36     }
37 
38     public function testASyllableCanEndWithAConsonant()
39     {
40         $syllable = $this->randomSyllableSymbolGenerator->generate();
41         $this->assertEndsWithConsonant($syllable);
42     }
43 
44     private function assertValidVowelGroup(string $syllable): void
45     {
46         $this->assertRegExp(sprintf('/%s/', self::VOWEL_GROUP_PATTERN), $syllable);
47     }
48 
49     private function assertStartsWithConsonantGroup(string $syllable): void
50     {
51         $pattern = sprintf('/^(%s)?/', self::CONSONANT_GROUP_PATTERN, self::VOWEL_GR\
52 OUP_PATTERN);
53         $this->assertRegExp($pattern, $syllable);
54     }
55 
56 
57     private function assertEndsWithConsonant(string $syllable): void
58     {
59         $pattern = sprintf('/(%s)?$/', self::ENDING_CONSONANT);
60         $this->assertRegExp($pattern, $syllable);
61     }
62 }

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:

 1 namespace Tests\TalkingBit\Readable;
 2 
 3 use TalkingBit\Readable\SystemRandomnessEngine;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class SystemRandomnessEngineTest extends TestCase
 7 {
 8 
 9     public function testGeneratesANumberEqualOrGreaterThanAMinimum()
10     {
11         $randomEngine = new SystemRandomnessEngine();
12         $this->assertGreaterThanOrEqual(0, $randomEngine->pickIntegerBetween(0, 0));
13     }
14 }

Ejecutamos el test para verlo fallar y escribir el código necesario para que pase.

 1 namespace TalkingBit\Readable;
 2 
 3 class SystemRandomnessEngine implements RandomnessEngine
 4 {
 5 
 6     public function pickIntegerBetween(int $minimum, int $maximum): int
 7     {
 8         return 10000;
 9     }
10 }

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:

1     public function testGeneratesANumberEqualOrLowerThanAMaximum()
2     {
3         $randomEngine = new SystemRandomnessEngine();
4         $this->assertLessThanOrEqual(10, $randomEngine->pickIntegerBetween(10, 10));
5     }

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:

 1 namespace TalkingBit\Readable;
 2 
 3 class SystemRandomnessEngine implements RandomnessEngine
 4 {
 5 
 6     public function pickIntegerBetween(int $minimum, int $maximum): int
 7     {
 8         return random_int($minimum, $maximum);
 9     }
10 }

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:

 1 //playground.php
 2 
 3 namespace TalkingBit\Readable;
 4 
 5 require_once '../vendor/autoload.php';
 6 
 7 $passwordGenerator = new PasswordGenerator(
 8     new RandomSyllableSymbolGenerator(
 9         new SystemRandomnessEngine()
10     )
11 );
12 
13 for ($count = 0; $count <= 10; $count++) {
14     print $passwordGenerator->generate() . PHP_EOL;
15 }

Que genera lo siguiente:

 1 reulculzienmoidzienwiol
 2 buesjeinfrainfloteiltrau
 3 algofeusyeirguiscras
 4 buadbaurdollausbradluos
 5 jilbleinmiedpruirmouquan
 6 poidheisbeischuishilfli
 7 jiaxialpaibiasguonfid
 8 mioyainualyiodbradxaul
 9 cluezierfrurreislluoslas
10 meinproisriadsaudblaileil
11 hiadbrauspriulgiadfoinxias

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:

 1 namespace TalkingBit\Readable;
 2 
 3 class PasswordGenerator
 4 {
 5     public const MINIMUM_LENGTH = 6;
 6     /**
 7      * @var RandomSymbolGenerator
 8      */
 9     private $randomGenerator;
10 
11     public function __construct(RandomSymbolGenerator $randomGenerator)
12     {
13         $this->randomGenerator = $randomGenerator;
14     }
15 
16     public function generate(): string
17     {
18         $password = '';
19         while (strlen($password) < self::MINIMUM_LENGTH) {
20             $password .= $this->randomGenerator->generate();
21         }
22 
23         return $password;
24     }
25 }

¿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:

 1 tieprois
 2 noulxius
 3 suonpruol
 4 veudhos
 5 zimuan
 6 faufreus
 7 kadtaud
 8 toidblor
 9 fruedñeur
10 geiñiu
11 xiehel

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.

1 namespace TalkingBit\Readable;
2 
3 interface Generator
4 {
5     public function generate(): string;
6 }

Ahora ya podemos empezar con nuestro Hackerize. Pero primero, un test:

 1 namespace Tests\TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Decorator\Hackerize;
 4 use PHPUnit\Framework\TestCase;
 5 use TalkingBit\Readable\Generator;
 6 
 7 class HackerizeTest extends TestCase
 8 {
 9 
10     public function testConvertsAto4()
11     {
12         $generatorProphecy = $this->prophesize(Generator::class);
13         $generatorProphecy->generate()->willReturn('a');
14         $hackerize = new Hackerize($generatorProphecy->reveal());
15         $this->assertEquals('4', $hackerize->generate());
16     }
17 }

Empezamos con este test bastante sencillo, y creamos una implementación mínima:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class Hackerize implements Generator
 6 {
 7 
 8     public function generate(): string
 9     {
10         return '4';
11     }
12 }

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:

1     public function testConvertsSeveralChars()
2     {
3         $generatorProphecy = $this->prophesize(Generator::class);
4         $generatorProphecy->generate()->willReturn('ae');
5         $hackerize = new Hackerize($generatorProphecy->reveal());
6         $this->assertEquals('43', $hackerize->generate());
7     }

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:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class Hackerize implements Generator
 6 {
 7     private const CHARS = ['a', 'e'];
 8     private const SUBSTITUTIONS = ['4', '3'];
 9     /**
10      * @var Generator
11      */
12     private $generator;
13 
14     public function __construct(Generator $generator)
15     {
16         $this->generator = $generator;
17     }
18 
19     public function generate(): string
20     {
21         $password = $this->generator->generate();
22 
23         return str_replace(self::CHARS, self::SUBSTITUTIONS, $password);
24     }
25 }

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:

1     public function testConvertsAto4CaseInsensitive()
2     {
3         $generatorProphecy = $this->prophesize(Generator::class);
4         $generatorProphecy->generate()->willReturn('A');
5         $hackerize = new Hackerize($generatorProphecy->reveal());
6         $this->assertEquals('4', $hackerize->generate());
7     }

Y, oye, que basta un cambio mínimo para lograrlo str_ireplace en lugar de str_replace:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class Hackerize implements Generator
 6 {
 7     private const CHARS = ['a', 'e'];
 8     private const SUBSTITUTIONS = ['4', '3'];
 9     /**
10      * @var Generator
11      */
12     private $generator;
13 
14     public function __construct(Generator $generator)
15     {
16         $this->generator = $generator;
17     }
18 
19     public function generate(): string
20     {
21         $password = $this->generator->generate();
22 
23         return str_ireplace(self::CHARS, self::SUBSTITUTIONS, $password);
24     }
25 }

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:

 1 namespace Tests\TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Decorator\Hackerize;
 4 use PHPUnit\Framework\TestCase;
 5 use TalkingBit\Readable\Generator;
 6 
 7 class HackerizeTest extends TestCase
 8 {
 9     /** @dataProvider examplesProvider */
10     public function testHackerizeAPassword($password, $hackerized)
11     {
12         $generatorProphecy = $this->prophesize(Generator::class);
13         $generatorProphecy->generate()->willReturn($password);
14         $hackerize = new Hackerize($generatorProphecy->reveal());
15         $this->assertEquals($hackerized, $hackerize->generate());
16     }
17 
18     public function examplesProvider()
19     {
20         return [
21             'Single A' => ['aA', '44'],
22             'Single E' => ['eE', '33'],
23             'Single S' => ['sS', '$$'],
24             'Single I' => ['iI', '!!'],
25             'Single O' => ['oO', '00'],
26             'Password' => ['Hackerized', 'H4ck3r!z3d']
27         ];
28     }
29 }

La implementación final será:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class Hackerize implements Generator
 6 {
 7     private const CHARS = ['a', 'e', 'i', 'o', 's'];
 8     private const SUBSTITUTIONS = ['4', '3', '!', '0', '$'];
 9     /**
10      * @var Generator
11      */
12     private $generator;
13 
14     public function __construct(Generator $generator)
15     {
16         $this->generator = $generator;
17     }
18 
19     public function generate(): string
20     {
21         $password = $this->generator->generate();
22 
23         return str_ireplace(self::CHARS, self::SUBSTITUTIONS, $password);
24     }
25 }

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á:

 1 namespace Tests\TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Decorator\RandomUpperize;
 4 use PHPUnit\Framework\TestCase;
 5 use TalkingBit\Readable\Generator;
 6 
 7 class RandomUpperizeTest extends TestCase
 8 {
 9     public function testUpperizeAPassword()
10     {
11         $generatorProphecy = $this->prophesize(Generator::class);
12         $generatorProphecy->generate()->willReturn('m');
13         $upperize = new RandomUpperize($generatorProphecy->reveal());
14         $this->assertEquals('M', $upperize->generate());
15     }
16 }

Momento de empezar a implementar:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class RandomUpperize implements Generator
 6 {
 7 
 8     /** @var Generator */
 9     private $generator;
10 
11     public function __construct(Generator $generator)
12     {
13         $this->generator = $generator;
14     }
15 
16     public function generate(): string
17     {
18         return 'M';
19     }
20 }

Para forzar un cambio de implementación, podemos intentar convertir otra contraseña:

1     public function testUpperizeTPassword()
2     {
3         $generatorProphecy = $this->prophesize(Generator::class);
4         $generatorProphecy->generate()->willReturn('t');
5         $upperize = new RandomUpperize($generatorProphecy->reveal());
6         $this->assertEquals('T', $upperize->generate());
7     }

Y podríamos seguir hasta cansarnos, así que una implementación general sencilla podría ser la siguiente, de momento:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 
 5 class RandomUpperize implements Generator
 6 {
 7 
 8     /** @var Generator */
 9     private $generator;
10 
11     public function __construct(Generator $generator)
12     {
13         $this->generator = $generator;
14     }
15 
16     public function generate(): string
17     {
18         return mb_strtoupper($this->generator->generate());
19     }
20 }

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.

 1     public function testRandomlyUpperizeOnePassword()
 2     {
 3         $generatorProphecy = $this->prophesize(Generator::class);
 4         $generatorProphecy->generate()->willReturn('password');
 5         $randomnessProphecy = $this->prophesize(RandomnessEngine::class);
 6         $randomnessProphecy->pickIntegerBetween(Argument::cetera())->willReturn(0);
 7         $upperize = new RandomUpperize($generatorProphecy->reveal(), $randomnessProp\
 8 hecy->reveal());
 9         $this->assertEquals(1, strlen(preg_replace('/[a-z]/', '', $upperize->generat\
10 e())));
11     }

Como test es un poco feo, pero hace lo que necesitamos.

La implementación quedaría así:

 1 namespace TalkingBit\Readable\Decorator;
 2 
 3 use TalkingBit\Readable\Generator;
 4 use TalkingBit\Readable\RandomnessEngine;
 5 
 6 class RandomUpperize implements Generator
 7 {
 8 
 9     /** @var Generator */
10     private $generator;
11     /**
12      * @var RandomnessEngine
13      */
14     private $randomnessEngine;
15 
16     public function __construct(Generator $generator, RandomnessEngine $randomnessEn\
17 gine)
18     {
19         $this->generator = $generator;
20         $this->randomnessEngine = $randomnessEngine;
21     }
22 
23     public function generate(): string
24     {
25         $password = $this->generator->generate();
26         $thisChar = $this->randomnessEngine->pickIntegerBetween(0, strlen($password)\
27 -1);
28         $password[$thisChar] = mb_strtoupper($password[$thisChar]);
29         return $password;
30     }
31 }

Veamos cómo usarlo

Vamos a ver ahora cómo montar nuestro generador de contraseñas con todas estas piezas:

 1 namespace TalkingBit\Readable;
 2 
 3 use TalkingBit\Readable\Decorator\Hackerize;
 4 
 5 require_once '../vendor/autoload.php';
 6 
 7 $passwordGenerator = new PasswordGenerator(
 8     new RandomSyllableSymbolGenerator(
 9         new SystemRandomnessEngine()
10     )
11 );
12 
13 $passwordGenerator = new Hackerize($passwordGenerator);
14 
15 for ($count = 0; $count <= 10; $count++) {
16     print $passwordGenerator->generate() . PHP_EOL;
17 }

Que da como resultado:

 1 c4ntr!0n
 2 ch3!ng!0n
 3 br4!z3u
 4 blu0dpl4!d
 5 u3nq0!l
 6 cr0udg4un
 7 h!3dyu4
 8 c3ul4d
 9 z!4dcr!u$
10 ru3$cl0d
11 mu!dyu3$

¿Y si le añadimos mayúsculas?

 1 namespace TalkingBit\Readable;
 2 
 3 use TalkingBit\Readable\Decorator\RandomUpperize;
 4 
 5 require_once '../vendor/autoload.php';
 6 
 7 $passwordGenerator = new PasswordGenerator(
 8     new RandomSyllableSymbolGenerator(
 9         new SystemRandomnessEngine()
10     )
11 );
12 
13 $passwordGenerator = new RandomUpperize($passwordGenerator, new SystemRandomnessEngi\
14 ne());
15 
16 for ($count = 0; $count <= 10; $count++) {
17     print $passwordGenerator->generate() . PHP_EOL;
18 }

Pues sale esto:

 1 kaDfrais
 2 siadMuin
 3 blIossun
 4 Chaunmier
 5 prierpuAr
 6 flouquoS
 7 plaurloiL
 8 crausqAur
 9 ñaunfrIod
10 gausTriar
11 zuanBlues

Y, finalmente, combinando ambos decoradores:

 1 namespace TalkingBit\Readable;
 2 
 3 use TalkingBit\Readable\Decorator\Hackerize;
 4 use TalkingBit\Readable\Decorator\RandomUpperize;
 5 
 6 require_once '../vendor/autoload.php';
 7 
 8 $passwordGenerator = new PasswordGenerator(
 9     new RandomSyllableSymbolGenerator(
10         new SystemRandomnessEngine()
11     )
12 );
13 
14 $passwordGenerator = new RandomUpperize($passwordGenerator, new SystemRandomnessEngi\
15 ne());
16 $passwordGenerator = new Hackerize($passwordGenerator);
17 
18 for ($count = 0; $count <= 10; $count++) {
19     print $passwordGenerator->generate() . PHP_EOL;
20 }

Con este resultado, que legible, lo que se dice legible, tampoco lo es mucho:

 1 chu0$q4!d
 2 H!4$fl3l
 3 v0Dwu0
 4 fl!Urqu0d
 5 ll0u$fr3!$
 6 p3!nfr3!N
 7 x3rll3r
 8 du!lbl4!n
 9 ku3lu3d
10 br3lH4!
11 $4dLlu0l

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:

1 $store = [];
2 $store[] = new MyClass();
3 $store[] = new AnotherClass();
4 $store[] = 'random string';

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í:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 
10     public function testShouldInitialize()
11     {
12         $this->instanceOf(Collection::class, new Collection());
13     }
14 }

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.

1 <?php
2 namespace Fi\Collections;
3 
4 class Collection
5 {
6 }

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.

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, new Collection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = new Collection();
17         $this->assertEquals(0, $sut->count());
18     }
19 }

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:

 1 <?php
 2 namespace Fi\Collections;
 3 
 4 class Collection
 5 {
 6     public function count()
 7     {
 8         return 0;
 9     }
10 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, new Collection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = new Collection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = new Collection();
23         $sut->append(new class{});
24         $this->assertEquals(1, $sut->count());
25     }
26 }

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:

 1 <?php
 2 namespace Fi\Collections;
 3 
 4 class Collection
 5 {
 6     private $elements;
 7 
 8     public function count()
 9     {
10         return count($this->elements);
11     }
12 
13     public function append($element)
14     {
15         $this->elements[] = $element;
16     }
17 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, new Collection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = new Collection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = new Collection();
23         $sut->append(new class{});
24         $this->assertEquals(1, $sut->count());
25     }
26 
27     public function testShouldBeAbleToAppendTwoElements()
28     {
29         $sut = new Collection();
30         $sut->append(new class{});
31         $sut->append(new class{});
32         $this->assertEquals(2, $sut->count());
33     }
34 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 	/* The other tests ... */
10 	
11     public function testShouldNotStoreObjectOfIncorrectType()
12     {
13         $sut = new Collection();
14         $this->expectException(\OutOfBoundsException::class);
15         $sut->append(new class{});
16     }
17 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 	/* The other tests ... */
10 	
11     public function testShouldInitializeWithAType()
12     {
13         $sut = new Collection(get_class($this));
14         $this->assertInstanceOf(Collection::class, $sut);
15     }
16 
17     public function testShouldNotStoreObjectOfIncorrectType()
18     {
19         $sut = new Collection();
20         $this->expectException(\OutOfBoundsException::class);
21         $sut->append(new class{});
22     }
23 }

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.

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 	/* The other tests ... */
10 	
11     public function testShouldInitializeWithAType()
12     {
13         $sut = new Collection(get_class($this));
14         $this->assertInstanceOf(Collection::class, $sut);
15     }
16 
17     public function testShouldNotStoreObjectOfIncorrectType()
18     {
19         $sut = new Collection(get_class($this));
20         $this->expectException(\OutOfBoundsException::class);
21         $sut->append(new class{});
22     }
23 }

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.

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     private $elements;
 8     /**
 9      * @var string
10      */
11     private $type;
12 
13     public function __construct(string $type = null)
14     {
15         $this->type = $type;
16     }
17 
18     public function count()
19     {
20         return count($this->elements);
21     }
22 
23     public function append($element)
24     {
25         $this->elements[] = $element;
26     }
27 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, $this->getCollection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = $this->getCollection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = $this->getCollection();
23         $sut->append(new class{});
24         $this->assertEquals(1, $sut->count());
25     }
26 
27     public function testShouldBeAbleToAppendTwoElements()
28     {
29         $sut = $this->getCollection();
30         $sut->append(new class{});
31         $sut->append(new class{});
32         $this->assertEquals(2, $sut->count());
33     }
34 	
35     public function testShouldInitializeWithAType()
36     {
37         $sut = $this->getTypedCollection();
38         $this->assertInstanceOf(Collection::class, $sut);
39     }
40 
41     public function testShouldNotStoreObjectOfIncorrectType()
42     {
43         $sut = $this->getTypedCollection();
44         $this->expectException(\OutOfBoundsException::class);
45         $sut->append(new class{});
46     }
47 
48     protected function getCollection(): Collection
49     {
50         $sut = new Collection();
51         return $sut;
52     }
53 	
54     protected function getTypedCollection(): Collection
55     {
56         $sut = new Collection(get_class($this));
57         return $sut;
58     }
59 }

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.

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     private $elements;
 8     /**
 9      * @var string
10      */
11     private $type;
12 
13     public function __construct(string $type = null)
14     {
15         $this->type = $type;
16     }
17 
18     public function count()
19     {
20         return count($this->elements);
21     }
22 
23     public function append($element)
24     {
25         if (get_class($element) !== $this->type) {
26             throw new \UnexpectedValueException('Invalid Type');
27         }
28         $this->elements[] = $element;
29     }
30 }

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í:

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     private $elements;
 8     /**
 9      * @var string
10      */
11     private $type;
12 
13     public function __construct(string $type = null)
14     {
15         $this->type = $type;
16     }
17 
18     public function count()
19     {
20         return count($this->elements);
21     }
22 
23     public function append($element)
24     {
25         if (!is_null($this->type) && get_class($element) !== $this->type) {
26             throw new \OutOfBoundsException('Invalid Type');
27         }
28         $this->elements[] = $element;
29     }
30 }

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 	/* The other tests */
10 
11     public function testShouldNotStoreObjectOfIncorrectType()
12     {
13         $sut = $this->getTypedCollection();
14         $this->expectException(\OutOfBoundsException::class);
15         $sut->append(new class{});
16     }
17 	
18     public function testShouldBeAbleToStoreSubClasses()
19     {
20         $sut = $this->getTypedCollection();
21         $sut->append(new class extends CollectionTest {});
22         $this->assertEquals(1, $sut->count());
23     }
24 
25     protected function getCollection(): Collection
26     {
27         $sut = new Collection();
28         return $sut;
29     }
30 	
31     protected function getTypedCollection(): Collection
32     {
33         $sut = new Collection(get_class($this));
34         return $sut;
35     }
36 }

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:

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     private $elements;
 8     /**
 9      * @var string
10      */
11     private $type;
12 
13     public function __construct(string $type = null)
14     {
15         $this->type = $type;
16     }
17 
18     public function count()
19     {
20         return count($this->elements);
21     }
22 
23     public function append($element)
24     {
25         if (!is_null($this->type) && !is_a($element, $this->type)) {
26             throw new \UnexpectedValueException('Invalid Type');
27         }
28         $this->elements[] = $element;
29     }
30 }

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.

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     /**
 8      * @var array
 9      */
10     private $elements;
11     /**
12      * @var string
13      */
14     private $type;
15 
16     public function __construct(string $type = null)
17     {
18         $this->type = $type;
19     }
20 
21     public function count()
22     {
23         return count($this->elements);
24     }
25 
26     public function append($element)
27     {
28         $this->guardAgainstInvalidType($element);
29         $this->elements[] = $element;
30     }
31 
32     protected function guardAgainstInvalidType($element): void
33     {
34         if (!is_null($this->type) && !is_a($element, $this->type)) {
35             throw new \UnexpectedValueException('Invalid Type');
36         }
37     }
38 }

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.

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, $this->getCollection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = $this->getCollection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = $this->getCollection();
23         $sut->append(new class {});
24         $this->assertEquals(1, $sut->count());
25     }
26 
27     public function testShouldBeAbleToAppendTwoElements()
28     {
29         $sut = $this->getCollection();
30         $sut->append(new class {});
31         $sut->append(new class {});
32         $this->assertEquals(2, $sut->count());
33     }
34 
35     public function testShouldInitializeWithAType()
36     {
37         $sut = $this->getTypedCollection();
38         $this->assertInstanceOf(Collection::class, $sut);
39     }
40 
41     public function testShouldNotStoreObjectOfIncorrectType()
42     {
43         $sut = $this->getTypedCollection();
44         $this->expectException(\UnexpectedValueException::class);
45         $sut->append(new class {});
46     }
47 
48     public function testShouldBeAbleToStoreSubClasses()
49     {
50         $sut = $this->getTypedCollection();
51         $sut->append(new class extends CollectionTest {});
52         $this->assertEquals(1, $sut->count());
53     }
54 
55     private function getCollection(): Collection
56     {
57         return $this->getTypedCollection();
58     }
59 
60     private function getTypedCollection(): Collection
61     {
62         return new Collection(get_class($this));
63     }
64 }

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.

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, $this->getCollection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = $this->getCollection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = $this->getCollection();
23         $sut->append($this);
24         $this->assertEquals(1, $sut->count());
25     }
26 
27     public function testShouldBeAbleToAppendTwoElements()
28     {
29         $sut = $this->getCollection();
30         $sut->append($this);
31         $sut->append($this);
32         $this->assertEquals(2, $sut->count());
33     }
34 
35     public function testShouldInitializeWithAType()
36     {
37         $sut = $this->getTypedCollection();
38         $this->assertInstanceOf(Collection::class, $sut);
39     }
40 
41     public function testShouldNotStoreObjectOfIncorrectType()
42     {
43         $sut = $this->getTypedCollection();
44         $this->expectException(\UnexpectedValueException::class);
45         $sut->append(new class {});
46     }
47 
48     public function testShouldBeAbleToStoreSubClasses()
49     {
50         $sut = $this->getTypedCollection();
51         $sut->append(new class extends CollectionTest {});
52         $this->assertEquals(1, $sut->count());
53     }
54 
55     private function getCollection(): Collection
56     {
57         return $this->getTypedCollection();
58     }
59 
60     private function getTypedCollection(): Collection
61     {
62         return new Collection(get_class($this));
63     }
64 }

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í.

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9     public function testShouldInitialize()
10     {
11         $this->assertInstanceOf(Collection::class, $this->getCollection());
12     }
13 
14     public function testShouldBeConstructedEmpty()
15     {
16         $sut = $this->getCollection();
17         $this->assertEquals(0, $sut->count());
18     }
19 
20     public function testShouldBeAbleToAppendOneElement()
21     {
22         $sut = $this->getCollection();
23         $sut->append($this);
24         $this->assertEquals(1, $sut->count());
25     }
26 
27     public function testShouldBeAbleToAppendTwoElements()
28     {
29         $sut = $this->getCollection();
30         $sut->append($this);
31         $sut->append($this);
32         $this->assertEquals(2, $sut->count());
33     }
34 
35     public function testShouldInitializeWithAType()
36     {
37         $sut = $this->getCollection();
38         $this->assertInstanceOf(Collection::class, $sut);
39     }
40 
41     public function testShouldNotStoreObjectOfIncorrectType()
42     {
43         $sut = $this->getCollection();
44         $this->expectException(\UnexpectedValueException::class);
45         $sut->append(new class {});
46     }
47 
48     public function testShouldBeAbleToStoreSubClasses()
49     {
50         $sut = $this->getCollection();
51         $sut->append(new class extends CollectionTest {});
52         $this->assertEquals(1, $sut->count());
53     }
54 
55     private function getCollection(): Collection
56     {
57         return new Collection(get_class($this));
58     }
59 }

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.

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     /**
 8      * @var array
 9      */
10     private $elements;
11     /**
12      * @var string
13      */
14     private $type;
15 
16     public function __construct(string $type)
17     {
18         $this->type = $type;
19     }
20 
21     public function count()
22     {
23         return count($this->elements);
24     }
25 
26     public function append($element)
27     {
28         $this->guardAgainstInvalidType($element);
29         $this->elements[] = $element;
30     }
31 
32     protected function guardAgainstInvalidType($element): void
33     {
34         if (!is_a($element, $this->type)) {
35             throw new \UnexpectedValueException('Invalid Type');
36         }
37     }
38 }

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í:

1 $collection = Collection::of(Myclass::class);

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:

 1 <?php
 2 namespace Test\Collections;
 3 
 4 use Fi\Collections\Collection;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class CollectionTest extends TestCase
 8 {
 9 
10     public function testShouldInitialize()
11     {
12         $this->assertInstanceOf(Collection::class, $this->getCollection());
13     }
14 
15     public function testShouldBeConstructedEmpty()
16     {
17         $sut = $this->getCollection();
18         $this->assertEquals(0, $sut->count());
19     }
20 
21     public function testShouldBeAbleToAppendOneElement()
22     {
23         $sut = $this->getCollection();
24         $sut->append($this);
25         $this->assertEquals(1, $sut->count());
26     }
27 
28     public function testShouldBeAbleToAppendTwoElements()
29     {
30         $sut = $this->getCollection();
31         $sut->append($this);
32         $sut->append($this);
33         $this->assertEquals(2, $sut->count());
34     }
35 
36     public function testShouldInitializeWithAType()
37     {
38         $sut = $this->getCollection();
39         $this->assertInstanceOf(Collection::class, $sut);
40     }
41 
42     public function testShouldNotStoreObjectOfIncorrectType()
43     {
44         $sut = $this->getCollection();
45         $this->expectException(\UnexpectedValueException::class);
46         $sut->append(new class {});
47     }
48 
49     public function testShouldBeAbleToStoreSubClasses()
50     {
51         $sut = $this->getCollection();
52         $sut->append(new class extends CollectionTest {});
53         $this->assertEquals(1, $sut->count());
54     }
55 
56     private function getCollection(): Collection
57     {
58         return Collection::of(get_class($this));
59     }
60 }

Al fin y al cabo, sólo hay que hacer unas pequeñas modificaciones para volver a verde:

 1 <?php
 2 
 3 namespace Fi\Collections;
 4 
 5 class Collection
 6 {
 7     /**
 8      * @var array
 9      */
10     private $elements;
11     /**
12      * @var string
13      */
14     private $type;
15 
16     private function __construct(string $type)
17     {
18         $this->type = $type;
19     }
20 
21     public static function of(string $type)
22     {
23         return new self($type);
24     }
25 
26     public function count()
27     {
28         return count($this->elements);
29     }
30 
31     public function append($element)
32     {
33         $this->guardAgainstInvalidType($element);
34         $this->elements[] = $element;
35     }
36 
37     protected function guardAgainstInvalidType($element): void
38     {
39         if (!is_a($element, $this->type)) {
40             throw new \UnexpectedValueException('Invalid Type');
41         }
42     }
43 }

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):

1     public function testEachShouldDoNothingOnEmptyCollection()
2     {
3         $sut = $this->getCollection();
4         $log = '';
5         $sut->each(function() use (&$log) {
6             $log .= '*';
7         });
8         $this->assertEquals('', $log);
9     }

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.

1     public function each(Callable $function)
2     {
3     }

Ahora el test pasa. Estamos en verde, hay que pensar otro test, ahora con, al menos, un elemento.

 1     public function testEachShouldIterateOnOneElement()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $log = '';
 6         $sut->each(function() use (&$log) {
 7             $log .= '*';
 8         });
 9         $this->assertEquals('*', $log);
10     }

Este test, como era de esperar, falla. Así que implementemos lo mínimo posible: ejecutar una vez la función.

1     public function each(Callable $function)
2     {
3         $function();
4     }

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:

1     public function each(Callable $function)
2     {
3         if ($this->count() > 0) {
4             $function();
5         }
6     }

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:

 1     public function testEachShouldIterateTwoElements()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $log = '';
 7         $sut->each(function() use (&$log) {
 8             $log .= '*';
 9         });
10         $this->assertEquals('**', $log);
11     }

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:

1     public function each(Callable $function)
2     {
3         foreach ($this->elements as $element) {
4             $function();
5         }
6     }

El nuevo test ha pasado, pero se ha roto el test de la colección vacía. Tenemos que tratar este caso límite.

1     public function each(Callable $function)
2     {
3         if (!$this->count()) {
4             return;
5         }
6         foreach ($this->elements as $element) {
7             $function();
8         }
9     }

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.

 1     public function testEachShouldPassEveryElementToCallable()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $log = '';
 7         $sut->each(function(CollectionTest $element) use (&$log) {
 8             $log .= '*';
 9         });
10         $this->assertEquals('**', $log);
11     }

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:

1     public function each(Callable $function)
2     {
3         if (!$this->count()) {
4             return;
5         }
6         foreach ($this->elements as $element) {
7             $function($element);
8         }
9     }

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:

1     public function each(Callable $function)
2     {
3         if (!$this->count()) {
4             return;
5         }
6         array_map($function, $this->elements);
7     }

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étodo each y devolverá un Collection 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.

 1     public function testEachShouldAllowPipelining()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $log = '';
 6         $result = $sut->each(function(CollectionTest $element) use (&$log) {
 7             $log .= '*';
 8         });
 9         $this->assertInstanceOf(Collection::class, $result);
10     }

El test no pasa, pero la implementación es sencilla:

1     public function each(Callable $function)
2     {
3         if (!$this->count()) {
4             return;
5         }
6         array_map($function, $this->elements);
7         
8         return $this;
9     }

Y nos ponemos en verde enseguida.

Pero, ¿y si la colección está vacía?

1     public function testEachShouldAllowPipeliningOnEmptyCollection()
2     {
3         $sut = $this->getCollection();
4         $log = '';
5         $result = $sut->each(function(CollectionTest $element) use (&$log) {
6             $log .= '*';
7         });
8         $this->assertInstanceOf(Collection::class, $result);
9     }

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.

1     public function each(Callable $function)
2     {
3         if (!$this->count()) {
4             return $this;
5         }
6         array_map($function, $this->elements);
7         
8         return $this;
9     }

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:

1     public function testMapShouldAllowPipelineOnEmptyCollection)
2     {
3         $sut = $this->getCollection();
4         $result = $sut->map(function(CollectionTest $element) {
5             return $element;
6         });
7         $this->assertInstanceOf(Collection::class, $result);
8     }

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.

1     public function map(Callable $function) : Collection
2     {
3         return $this;
4     }

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:

1     public function testMapShouldReturnAnotherCollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->map(function(CollectionTest $element) {
5             return $element;
6         });
7         $this->assertNotSame($sut, $result);
8     }

El test falla porque devolvemos la misma colección, vamos a ver cómo solucionar eso de momento:

1     public function map(Callable $function) : Collection
2     {
3         return clone $this;
4     }

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:

1     public function testShouldMapSingleElement()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->map(function(CollectionTest $element) {
6             return $element;
7         });
8         $this->assertEquals(1, $result->count());
9     }

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:

1 class MappedObject {}

Y cambiamos el test:

1     public function testShouldMapSingleElement()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->map(function(CollectionTest $element) {
6             return new MappedObject();
7         });
8         $this->assertAttributeEquals(MappedObject::class, 'type', $result);
9     }

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.

1     public function map(Callable $function) : Collection
2     {
3         return self::of(MappedObject::class);
4     }

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:

 1     public function testShouldMapSingleElement()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $result = $sut->map(function(CollectionTest $element) {
 6             return new MappedObject();
 7         });
 8         $this->assertAttributeEquals(MappedObject::class, 'type', $result);
 9         $this->assertEquals(1, $result->count());
10     }

Ahora ya tenemos mejor información. La implementación mínima es la siguiente:

1     public function map(Callable $function) : Collection
2     {
3         $mapped = self::of(MappedObject::class);
4         $mapped->append(new MappedObject());
5         return $mapped;
6     }

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:

1     public function testMapShouldReturnNewEmptyCollectionOnEmptyCollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->map(function(CollectionTest $element) {
5             return $element;
6         });
7         $this->assertInstanceOf(Collection::class, $result);
8         $this->assertEquals(0, $result->count());
9     }

Creamos una implementación que contemple este caso límite:

1     public function map(Callable $function) : Collection
2     {
3         if (!$this->count()) {
4             return clone $this;
5         }
6         $mapped = self::of(MappedObject::class);
7         $mapped->append(new MappedObject());
8         return $mapped;
9     }

Nos vamos acercando, estamos de nuevo en verde. Necesitamos un test más que nos fuerce a implementar una solución más general:

 1     public function testShouldMapTwoElements()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $result = $sut->map(function(CollectionTest $element) {
 7             return new MappedObject();
 8         });
 9         $this->assertAttributeEquals(MappedObject::class, 'type', $result);
10         $this->assertEquals(2, $result->count());
11     }

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:

 1     public function map(Callable $function) : Collection
 2     {
 3         if (!$this->count()) {
 4             return clone $this;
 5         }
 6         $firstMapping = $function(reset($this->elements));
 7         $mapped = self::of(get_class($firstMapping));
 8         $mapped->append($firstMapping);
 9         while ($object = next($this->elements)) {
10             $mapped->append($function($object));
11         }
12         return $mapped;
13     }

Y como el código es poco inteligible, vamos a refactorizar un poco aprovechando que estamos en verde:

 1     public function map(Callable $function) : Collection
 2     {
 3         if (!$this->count()) {
 4             return clone $this;
 5         }
 6         $mapped = $this->instanceCollection($function);
 7         while ($object = next($this->elements)) {
 8             $mapped->append($function($object));
 9         }
10         return $mapped;
11     }
12 
13     protected function instanceCollection(Callable $function): Collection
14     {
15         $firstMapping = $function(reset($this->elements));
16         $mapped = self::of(get_class($firstMapping));
17         $mapped->append($firstMapping);
18         return $mapped;
19     }

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:

1     public function testFilterShouldReturnACollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->filter(function(CollectionTest $element) {
5             return false;
6         });
7         $this->assertInstanceOf(Collection::class, $result);
8     }

El test fallará puesto que no existe el método filter y volverá a fallar al proponer una implementación vacía.

1     public function filter(Callable $function)
2     {
3     }

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.

1     public function filter(Callable $function)
2     {
3         return $this;
4     }

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:

1     public function testFilterShouldReturnNewCollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->filter(function(CollectionTest $element) {
5             return false;
6         });
7         $this->assertNotSame($sut, $result);
8     }

Bien. El test falla, así que toca implementar algo.

1     public function filter(Callable $function)
2     {
3         return Collection::of(\stdClass::class);
4     }

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.

1     public function testFilterShouldReturnCollectionOfSameType()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->filter(function(CollectionTest $element) {
5             return false;
6         });
7         $this->assertAttributeEquals(CollectionTest::class, 'type', $result);
8     }

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:

1     public function filter(Callable $function)
2     {
3         return Collection::of($this->type);
4     }

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:

1     public function testFilterShouldIncludeElementIfCallableReturnsTrue()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->filter(function(CollectionTest $element) {
6             return true;
7         });
8         $this->assertEquals(1, $result->count());
9     }

Este test sí falla y, por tanto, nos obliga a implementar algo.

1     public function filter(Callable $function)
2     {
3         $filtered = Collection::of($this->type);
4         $filtered->append(reset($this->elements));
5         return $filtered;
6     }

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.

1     public function filter(Callable $function)
2     {
3         $filtered = Collection::of($this->type);
4         if (!$this->count()) {
5             return $filtered;
6         }
7         $filtered->append(reset($this->elements));
8         return $filtered;
9     }

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:

1     public function testFilterShouldNotIncludeElementIfCallableReturnsFalse()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->filter(function(CollectionTest $element) {
6             return false;
7         });
8         $this->assertEquals(0, $result->count());
9     }

Obligándonos a hacer una implementación mínima del filtrado para que el test pase.

 1     public function filter(Callable $function)
 2     {
 3         $filtered = Collection::of($this->type);
 4         if (!$this->count()) {
 5             return $filtered;
 6         }
 7         if ($function(reset($this->elements))) {
 8             $filtered->append(reset($this->elements));
 9         }
10         return $filtered;
11     }

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.

 1     public function testFilterShouldIterateOverAllElements()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $result = $sut->filter(function(CollectionTest $element) {
 7             return true;
 8         });
 9         $this->assertEquals(2, $result->count());
10     }

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.

 1     public function filter(Callable $function): Collection
 2     {
 3         $filtered = Collection::of($this->type);
 4         if (!$this->count()) {
 5             return $filtered;
 6         }
 7         foreach ($this->elements as $element) {
 8             if ($function($element)) {
 9                 $filtered->append($element);
10             }
11         }
12         return $filtered;
13     }

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á.

 1     public function testFilterShouldIterateOverAllElements()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append(clone $this);
 6         $result = $sut->filter(function(CollectionTest $element) {
 7             return true;
 8         });
 9         $this->assertEquals($sut, $result);
10     }

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:

1     public function testFilterShouldReturnACollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->filter(function(CollectionTest $element) {
5             return false;
6         });
7         $this->assertInstanceOf(Collection::class, $result);
8     }

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:

1     public function filter(Callable $function) : Collection
2     {
3     }

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:

1     public function filter(Callable $function)
2     {
3 		//...
4 		if ($someCondition) {
5 			return new \stdClass;
6 		}
7 		return Collection::of(\stdClass::class);
8     }

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.

1     public function filter(Callable $function) : Collection
2     {
3 		//...
4 		if ($someCondition) {
5 			return new \stdClass;
6 		}
7 		return Collection::of(\stdClass::class);
8     }

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:

 1     public function map(Callable $function) : Collection
 2     {
 3         if (!$this->count()) {
 4             return clone $this;
 5         }
 6 
 7         $first = $function(reset($this->elements));
 8         $mapped = Collection::of(get_class($first));
 9         $mapped->append($first);
10 
11         while ($object = next($this->elements)) {
12             $mapped->append($function($object));
13         }
14 
15         return $mapped;
16     }

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:

1     public function testGetByShouldThrowExceptionWhenCollectionIsEmpty()
2     {
3         $sut = $this->getCollection();
4         $this->expectException(\UnderflowException::class);
5         $sut->getBy(function (CollectionTest $element) {
6             return true;
7         });
8     }

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:

1     public function getBy(Callable $function)
2     {
3     }

Fallo del test y nueva implementación mínima para pasar el test:

1     public function getBy(Callable $function)
2     {
3         throw new \UnderflowException('Collection is empty');
4     }

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.

1     public function testGetByShouldThrowExceptionIfElementIsNotFound()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $this->expectException(\OutOfBoundsException::class);
6         $sut->getBy(function (CollectionTest $element) {
7             return false;
8         });
9     }

La implementación supone controlar el caso especial de colección vacía:

1     public function getBy(Callable $function)
2     {
3         if (!$this->count()) {
4             throw new \UnderflowException('Collection is empty');
5         }
6         throw new \OutOfBoundsException('Element not found');
7     }

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:

1     public function testGetByShouldReturnFoundElement()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->getBy(function (CollectionTest $element) {
6             return true;
7         });
8         $this->assertSame($this, $result);
9     }

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.

 1     public function getBy(Callable $function)
 2     {
 3         if (!$this->count()) {
 4             throw new \UnderflowException('Collection is empty');
 5         }
 6         if ($function(reset($this->elements))) {
 7             return reset($this->elements);
 8         }
 9         throw new \OutOfBoundsException('Element not found');
10     }

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.

 1     public function testGetByShouldSelectTheRightElement()
 2     {
 3         $sut = $this->getCollection();
 4         $target = clone $this;
 5         $target->target = true;
 6         $sut->append($this);
 7         $sut->append($target);
 8         $result = $sut->getBy(function (CollectionTest $element) {
 9             return $element->isTarget();
10         });
11         $this->assertSame($target, $result);
12     }
13 
14     public function isTarget()
15     {
16         return isset($this->target);
17     }

Para que el test pase, tengo que asegurarme de que examino todos los elementos de la lista:

 1     public function getBy(Callable $function)
 2     {
 3         if (!$this->count()) {
 4             throw new \UnderflowException('Collection is empty');
 5         }
 6         foreach ($this->elements as $element) {
 7             if ($function($element)) {
 8                 return $element;
 9             }
10         }
11         throw new \OutOfBoundsException('Element not found');
12     }

¿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.

1     public function testReduceSouldReturnInitialValueForEmptyCollection()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
5            return $accumulator + 1;
6         }, 0);
7         $this->assertEquals(0, $result);
8     }

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.

1     public function reduce(Callable $function, $initial)
2     {
3         return 0;
4     }

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.

1     public function testReduceShouldAcceptAnyTypeForInitialValue()
2     {
3         $sut = $this->getCollection();
4         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
5             return $accumulator + 1;
6         }, "");
7         $this->assertEquals("", $result);
8     }

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.

1     public function reduce(Callable $function, $initial)
2     {
3         return $initial;
4     }

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:

1    public function testReduceShouldApplyCallableToOneElement()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
6             return $accumulator + 1;
7         }, 0);
8         $this->assertEquals(1, $result);
9     }

Como el test falla, implementemos algo para que pase:

1     public function reduce(Callable $function, $initial)
2     {
3         return $function(reset($this->elements), $initial);
4     }

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.

1     public function reduce(Callable $function, $initial)
2     {
3         if (!$this->count()) {
4             return $initial;
5         }
6         return $function(reset($this->elements), $initial);
7     }

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.

 1     public function testReduceShouldApplyFunctionToSeveralElements()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
 7             return $accumulator + 1;
 8         }, 0);
 9         $this->assertEquals(2, $result);
10     }

La razón es que no estamos iterando:

 1     public function reduce(Callable $function, $initial)
 2     {
 3         if (!$this->count()) {
 4             return $initial;
 5         }
 6         foreach ($this->elements as $element) {
 7             $initial = $function($element, $initial);
 8         }
 9         return $initial;
10     }

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:

1     public function testCollectShouldReturnInstanceOfCollection()
2     {
3         $sut = Collection::collect([]);
4         $this->assertAttributeEquals(\stdClass::class, 'type', $sut);
5     }

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:

1     public static function collect(array $array)
2     {
3         return Collection::of(\stdClass::class);
4     }

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.

1     public function testCollectShouldUseFirstElementToDecideCollectionType()
2     {
3         $sut = Collection::collect([new \stdClass()]);
4         $this->assertAttributeEquals(\stdClass::class, 'type', $sut);
5     }

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.

1     public static function collect(array $elements)
2     {
3         $type = get_class($elements[0]);
4         return Collection::of($type);
5     }

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:

1     public function testShouldFailWithExceptionCollectingEmptyArray()
2     {
3         $this->expectException(\InvalidArgumentException::class);
4         Collection::collect([]);
5     }

Hay que implementar para volver a verde:

1     public static function collect(array $elements)
2     {
3         if (!count($elements)) {
4             throw new \InvalidArgumentException('Can\'t collect an empty array');
5         }
6         $type = get_class($elements[0]);
7         return Collection::of($type);
8     }

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:

1     public function testShouldPopulateCollectionWithUniqueElementInArray()
2     {
3         $sut = Collection::collect([
4             $this
5         ]);
6         $this->assertEquals(1, $sut->count());
7     }

Y una implementación mínima sería la siguiente:

 1     public static function collect(array $elements)
 2     {
 3         if (!count($elements)) {
 4             throw new \InvalidArgumentException('Can\'t collect an empty array');
 5         }
 6         $type = get_class($elements[0]);
 7         $collection = Collection::of($type);
 8         $collection->append(reset($elements));
 9         return $collection;
10     }

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.

1     public function testShouldPopulateCollectionWithSeveralElementsInArray()
2     {
3         $sut = Collection::collect([
4             $this,
5             $this
6         ]);
7         $this->assertEquals(2, $sut->count());
8     }

Para pasar el test, ya podríamos implementar el método general:

 1     public static function collect(array $elements)
 2     {
 3         if (!count($elements)) {
 4             throw new \InvalidArgumentException('Can\'t collect an empty array');
 5         }
 6         $type = get_class($elements[0]);
 7         $collection = Collection::of($type);
 8         foreach ($elements as $element) {
 9             $collection->append($element);
10         }
11         return $collection;
12     }

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.

1     public function testShouldFailWithExceptionIfWrongTypeElementFound()
2     {
3         $this->expectException(\UnexpectedValueException::class);
4         Collection::collect([
5             $this,
6             new \stdClass()
7         ]);
8     }

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.

 1     public static function collect(array $elements)
 2     {
 3         if (!count($elements)) {
 4             throw new \InvalidArgumentException('Can\'t collect an empty array');
 5         }
 6         $collection = Collection::of(get_class($elements[0]));
 7         return array_map(function ($element) use ($collection) {
 8             $collection->append($element);
 9         }, $elements);
10     }

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.

1     public function testShouldMapEmptyCollectionToEmptyArray()
2     {
3         $sut = $this->getCollection();
4         $this->assertEquals([], $sut->toArray());
5     }a

Como suele pasar con estos tests iniciales, no existe el método y nos pide una implementación mínima, que es bastante obvia.

1     public function toArray()
2     {
3         return [];
4     }

Para que sea útil, el método debe trabajar con Collections que tengan algún elemento.

1     public function testShouldReturnArrayFromCollection()
2     {
3         $sample = [$this];
4         $sut = Collection::collect($sample);
5         $this->assertEquals($sample, $sut->toArray());
6     }

La siguiente implementación obvia romperá nuestro test anterior sobre la colección vacía:

1     public function toArray()
2     {
3         return $this->elements;
4     }

Así que hay que contemplar el caso límite, cosa que no nos debería sorprender:

1     public function toArray() : array
2     {
3         if (!$this->elements) {
4             return [];
5         }
6         return $this->elements;
7     }

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:

1 	$collectionArray = $collection->reduce(function(Persistible $element, $accumulator)\
2  {
3 		$accumulator[] = $element->toArray();
4 	}, array());

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:

1     public function testShouldBeAbleToMapCollectionToArray()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $this->assertEquals(['mapped'], $sut->toArray(function(CollectionTest $eleme\
6 nt) {
7             return 'mapped';
8         }));
9     }

Como no hemos implementado ningún mapeo, el test no pasa.

La forma de hacerlo pasar es sencilla:

1     public function toArray() : array
2     {
3         if (!$this->elements) {
4             return [];
5         }
6         return ['mapped'];
7     }

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:

 1     public function toArray(Callable $function = null) : array
 2     {
 3         if (!$this->elements) {
 4             return [];
 5         }
 6         if (!$function) {
 7             return $this->elements;
 8         }
 9         return ['mapped'];
10     }

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:

 1     public function testShouldBeAbleToMapCollectionWithTwoElementsToArray()
 2     {
 3         $sut = $this->getCollection();
 4         $sut->append($this);
 5         $sut->append($this);
 6         $this->assertEquals(['mapped', 'mapped'], $sut->toArray(function(CollectionT\
 7 est $element) {
 8             return 'mapped';
 9         }));
10     }

Falla. Implementemos una solución:

 1     public function toArray(Callable $function = null) : array
 2     {
 3         if (!$this->elements) {
 4             return [];
 5         }
 6         if (!$function) {
 7             return $this->elements;
 8         }
 9         return [
10             'mapped',
11             'mapped'
12         ];
13     }

¿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:

 1     public function toArray(Callable $function = null) : array
 2     {
 3         if (!$this->elements) {
 4             return [];
 5         }
 6         if (!$function) {
 7             return $this->elements;
 8         }
 9         $map = [];
10         foreach ($this->elements as $element) {
11             $map[] = $function($element);
12         }
13         return $map;
14     }

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:

 1     public function toArray(Callable $function = null) : array
 2     {
 3         if (!$this->elements) {
 4             return [];
 5         }
 6         if (!$function) {
 7             return $this->elements;
 8         }
 9         return array_map($function, $this->elements);
10     }

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:

1     public function testShouldGetTheTypeOfCollection()
2     {
3         $sut = Collection::of(CollectionTest::class);
4         $this->assertEquals(CollectionTest::class, $sut->getType());
5     }

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:

1     public function getType()
2     {
3         return $this->type;
4     }

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:

1 	if ($collection->count() === 0) { // Collection is empty }

Hagamos un test que falle:

1     public function testShouldTellIfCollectionIsEmpty()
2     {
3         $sut = $this->getCollection();
4         $this->assertTrue($sut->isEmpty());
5     }

Obviamente nos pide implementar y devolver true:

1     public function isEmpty() : bool
2     {
3         return true;
4     }

Pero si la colección tiene elementos, debería devolver false.

1     public function testShouldTellIfCollectionIsNotEmpty()
2     {
3         $sut = $this->getCollection();
4         $sut->append($this);
5         $this->assertFalse($sut->isEmpty());
6     }

Y la implementación necesaria es sencilla:

1     public function isEmpty() : bool
2     {
3         return !$this->elements;
4     }

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:

  1 <?php
  2 
  3 namespace Fi\Collections;
  4 
  5 class Collection
  6 {
  7     /**
  8      * @var array
  9      */
 10     private $elements;
 11     /**
 12      * @var string
 13      */
 14     private $type;
 15 
 16     private function __construct(string $type)
 17     {
 18         $this->type = $type;
 19     }
 20 
 21     public static function of(string $type) : Collection
 22     {
 23         return new self($type);
 24     }
 25 
 26     public static function collect(array $elements)
 27     {
 28         if (!count($elements)) {
 29             throw new \InvalidArgumentException('Can\'t collect an empty array');
 30         }
 31 
 32         $collection = Collection::of(get_class($elements[0]));
 33 
 34         array_map(function ($element) use ($collection) {
 35             $collection->append($element);
 36         }, $elements);
 37 
 38         return $collection;
 39     }
 40 
 41     public function count()
 42     {
 43         return count($this->elements);
 44     }
 45 
 46     public function append($element) : void
 47     {
 48         $this->guardAgainstInvalidType($element);
 49         $this->elements[] = $element;
 50     }
 51 
 52     protected function guardAgainstInvalidType($element) : void
 53     {
 54         if (!$this->isSupportedType($element)) {
 55             throw new \UnexpectedValueException('Invalid Type');
 56         }
 57     }
 58 
 59     public function each(Callable $function) : Collection
 60     {
 61         if ($this->isEmpty()) {
 62             return $this;
 63         }
 64 
 65         array_map($function, $this->elements);
 66 
 67         return $this;
 68     }
 69 
 70     public function map(Callable $function) : Collection
 71     {
 72         if ($this->isEmpty()) {
 73             return clone $this;
 74         }
 75 
 76         $first = $function(reset($this->elements));
 77         $mapped = Collection::of(get_class($first));
 78         $mapped->append($first);
 79 
 80         while ($object = next($this->elements)) {
 81             $mapped->append($function($object));
 82         }
 83 
 84         return $mapped;
 85     }
 86 
 87     public function filter(Callable $function) : Collection
 88     {
 89         $filtered = Collection::of($this->getType());
 90 
 91         if ($this->isEmpty()) {
 92             return $filtered;
 93         }
 94 
 95         foreach ($this->elements as $element) {
 96             if ($function($element)) {
 97                 $filtered->append($element);
 98             }
 99         }
100 
101         return $filtered;
102     }
103 
104     public function getBy(Callable $function)
105     {
106         if ($this->isEmpty()) {
107             throw new \UnderflowException('Collection is empty');
108         }
109         foreach ($this->elements as $element) {
110             if ($function($element)) {
111                 return $element;
112             }
113         }
114         throw new \OutOfBoundsException('Element not found');
115     }
116 
117     public function reduce(Callable $function, $initial)
118     {
119         if ($this->isEmpty()) {
120             return $initial;
121         }
122 
123         foreach ($this->elements as $element) {
124             $initial = $function($element, $initial);
125         }
126 
127         return $initial;
128     }
129 
130     public function toArray(Callable $function = null) : array
131     {
132         if ($this->isEmpty()) {
133             return [];
134         }
135         if (!$function) {
136             return $this->elements;
137         }
138 
139         return array_map($function, $this->elements);
140     }
141 
142     public function getType() : string
143     {
144         return $this->type;
145     }
146 
147     public function isEmpty() : bool
148     {
149         return !$this->count();
150     }
151 
152     protected function isSupportedType($element) : bool
153     {
154         return is_a($element, $this->getType());
155     }
156 }

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í:

  1 <?php
  2 
  3 namespace Test\Collections;
  4 
  5 use Fi\Collections\Collection;
  6 use PHPUnit\Framework\TestCase;
  7 
  8 class CollectionTest extends TestCase
  9 {
 10     public function testShouldInitialize()
 11     {
 12         $this->assertInstanceOf(Collection::class, $this->getCollection());
 13     }
 14 
 15     private function getCollection() : Collection
 16     {
 17         return Collection::of(get_class($this));
 18     }
 19 
 20     public function testShouldBeConstructedEmpty()
 21     {
 22         $sut = $this->getCollection();
 23         $this->assertEquals(0, $sut->count());
 24     }
 25 
 26     public function testShouldBeAbleToAppendOneElement()
 27     {
 28         $sut = $this->getCollection();
 29         $sut->append($this);
 30         $this->assertEquals(1, $sut->count());
 31     }
 32 
 33     public function testShouldBeAbleToAppendTwoElements()
 34     {
 35         $sut = $this->getCollection();
 36         $sut->append($this);
 37         $sut->append($this);
 38         $this->assertEquals(2, $sut->count());
 39     }
 40 
 41     public function testShouldInitializeWithAType()
 42     {
 43         $sut = Collection::of(CollectionTest::class);
 44         $this->assertInstanceOf(Collection::class, $sut);
 45     }
 46 
 47     public function testShouldNotStoreObjectOfIncorrectType()
 48     {
 49         $sut = $this->getCollection();
 50         $this->expectException(\UnexpectedValueException::class);
 51         $sut->append(new class
 52         {
 53         });
 54     }
 55 
 56     public function testShouldBeAbleToStoreSubClasses()
 57     {
 58         $sut = $this->getCollection();
 59         $sut->append(new class extends CollectionTest
 60         {
 61         });
 62         $this->assertEquals(1, $sut->count());
 63     }
 64 
 65     public function testEachShouldNotActOnEmptyCollection()
 66     {
 67         $sut = $this->getCollection();
 68         $log = '';
 69         $sut->each(function () use (&$log) {
 70             $log .= '*';
 71         });
 72         $this->assertEquals('', $log);
 73     }
 74 
 75     public function testEachShouldIterateOverOneElement()
 76     {
 77         $sut = $this->getCollection();
 78         $sut->append($this);
 79         $log = '';
 80         $sut->each(function () use (&$log) {
 81             $log .= '*';
 82         });
 83         $this->assertEquals('*', $log);
 84     }
 85 
 86     public function testEachShouldIterateOverSeveralElements()
 87     {
 88         $sut = $this->getCollection();
 89         $sut->append($this);
 90         $sut->append($this);
 91         $log = '';
 92         $sut->each(function () use (&$log) {
 93             $log .= '*';
 94         });
 95         $this->assertEquals('**', $log);
 96     }
 97 
 98     public function testEachShouldPassEveryElementToCallable()
 99     {
100         $sut = $this->getCollection();
101         $sut->append($this);
102         $sut->append($this);
103         $log = '';
104         $sut->each(function (CollectionTest $element) use (&$log) {
105             $log .= '*';
106         });
107         $this->assertEquals('**', $log);
108     }
109 
110     public function testEachShouldAllowPipelining()
111     {
112         $sut = $this->getCollection();
113         $sut->append($this);
114         $log = '';
115         $result = $sut->each(function (CollectionTest $element) use (&$log) {
116             $log .= '*';
117         });
118         $this->assertInstanceOf(Collection::class, $result);
119     }
120 
121     public function testEachShouldAllowPipeliningOnEmptyCollection()
122     {
123         $sut = $this->getCollection();
124         $log = '';
125         $result = $sut->each(function (CollectionTest $element) use (&$log) {
126             $log .= '*';
127         });
128         $this->assertInstanceOf(Collection::class, $result);
129     }
130 
131     public function testMapShouldAllowPipeliningOnEmptyCollection()
132     {
133         $sut = $this->getCollection();
134         $result = $sut->map(function (CollectionTest $element) {
135             return $element;
136         });
137         $this->assertInstanceOf(Collection::class, $result);
138     }
139 
140     public function testMapShouldReturnEmptyCollectionWhenEmptyCollection()
141     {
142         $sut = $this->getCollection();
143         $result = $sut->map(function (CollectionTest $element) {
144             return $element;
145         });
146         $this->assertInstanceOf(Collection::class, $result);
147         $this->assertEquals(0, $result->count());
148     }
149 
150     public function testMapShoulReturnAnotherCollection()
151     {
152         $sut = $this->getCollection();
153         $result = $sut->map(function (CollectionTest $element) {
154             return $element;
155         });
156         $this->assertNotSame($sut, $result);
157     }
158 
159     public function testMapShouldMapOneElement()
160     {
161         $sut = $this->getCollection();
162         $sut->append($this);
163         $result = $sut->map(function (CollectionTest $element) {
164             return new MappedObject();
165         });
166         $this->assertEquals(MappedObject::class, $result->getType());
167         $this->assertEquals(1, $result->count());
168     }
169 
170     public function testMapShouldMapSeveralElements()
171     {
172         $sut = $this->getCollection();
173         $sut->append($this);
174         $sut->append($this);
175         $result = $sut->map(function (CollectionTest $element) {
176             return new MappedObject();
177         });
178         $this->assertEquals(MappedObject::class, $result->getType());
179         $this->assertEquals(2, $result->count());
180     }
181 
182     public function testFilterShouldReturnCollection()
183     {
184         $sut = $this->getCollection();
185         $result = $sut->filter(function (CollectionTest $element) {
186             return false;
187         });
188         $this->assertInstanceOf(Collection::class, $result);
189     }
190 
191     public function testFilterShouldReturnAnotherCollection()
192     {
193         $sut = $this->getCollection();
194         $result = $sut->filter(function (CollectionTest $element) {
195             return false;
196         });
197         $this->assertNotSame($sut, $result);
198     }
199 
200     public function testFilterShouldReturnAnotherCollectionWithTheSameType()
201     {
202         $sut = $this->getCollection();
203         $result = $sut->filter(function (CollectionTest $element) {
204             return false;
205         });
206         $this->assertEquals(CollectionTest::class, $result->getType());
207     }
208 
209     public function testFilterShouldIncludeElementWhenCallableReturnTrue()
210     {
211         $sut = $this->getCollection();
212         $sut->append($this);
213         $result = $sut->filter(function (CollectionTest $element) {
214             return true;
215         });
216         $this->assertEquals(1, $result->count());
217     }
218 
219     public function testFilterShouldNotIncludeElementWhenCallableReturnFalse()
220     {
221         $sut = $this->getCollection();
222         $sut->append($this);
223         $result = $sut->filter(function (CollectionTest $element) {
224             return false;
225         });
226         $this->assertEquals(0, $result->count());
227     }
228 
229     public function testFilterShouldIterateOverAllElements()
230     {
231         $sut = $this->getCollection();
232         $sut->append($this);
233         $sut->append(clone $this);
234         $result = $sut->filter(function (CollectionTest $element) {
235             return true;
236         });
237         $this->assertEquals($sut, $result);
238     }
239 
240     public function testGetByShouldFailWithExceptionWhenEmptyCollection()
241     {
242         $sut = $this->getCollection();
243         $this->expectException(\UnderflowException::class);
244         $sut->getBy(function (CollectionTest $element) {
245             return true;
246         });
247     }
248 
249     public function testGetByShouldFailWithExceptionWhenElementNotFound()
250     {
251         $sut = $this->getCollection();
252         $sut->append($this);
253         $this->expectException(\OutOfBoundsException::class);
254         $sut->getBy(function (CollectionTest $element) {
255             return false;
256         });
257     }
258 
259     public function testGetByShouldReturnFoundElement()
260     {
261         $sut = $this->getCollection();
262         $sut->append($this);
263         $result = $sut->getBy(function (CollectionTest $element) {
264             return true;
265         });
266         $this->assertSame($this, $result);
267     }
268 
269     public function testGetByShouldReturnTheRightElement()
270     {
271         $sut = $this->getCollection();
272         $target = clone $this;
273         $target->target = true;
274         $sut->append($this);
275         $sut->append($target);
276         $result = $sut->getBy(function (CollectionTest $element) {
277             return $element->isTarget();
278         });
279         $this->assertSame($target, $result);
280     }
281 
282     public function testReduceShouldReturnInitialValueWhenCollectionIsEmpty()
283     {
284         $sut = $this->getCollection();
285         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
286            return $accumulator + 1;
287         }, 0);
288         $this->assertEquals(0, $result);
289     }
290 
291     public function testReduceInitialValueShouldAcceptAnyType()
292     {
293         $sut = $this->getCollection();
294         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
295             return $accumulator + 1;
296         }, "");
297         $this->assertEquals("", $result);
298     }
299 
300     public function testReduceShouldApplyCallableToOneElement()
301     {
302         $sut = $this->getCollection();
303         $sut->append($this);
304         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
305             return $accumulator + 1;
306         }, 0);
307         $this->assertEquals(1, $result);
308     }
309 
310     public function testReduceShouldApplyCallableToSeveralElements()
311     {
312         $sut = $this->getCollection();
313         $sut->append($this);
314         $sut->append($this);
315         $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
316             return $accumulator + 1;
317         }, 0);
318         $this->assertEquals(2, $result);
319     }
320 
321     public function testCollectShouldReturnInstanceOfCollection()
322     {
323         $sut = Collection::collect([
324             $this
325         ]);
326         $this->assertEquals(CollectionTest::class, $sut->getType());
327     }
328 
329     public function testCollectShouldFailWithExceptionIfEmptyArray()
330     {
331         $this->expectException(\InvalidArgumentException::class);
332         Collection::collect([]);
333     }
334 
335     public function testCollectShouldPopulateCollectionWithOneElement()
336     {
337         $sut = Collection::collect([
338             $this
339         ]);
340         $this->assertEquals(1, $sut->count());
341     }
342 
343     public function testCollectShouldPopulateCollectionWithSeveralElements()
344     {
345         $sut = Collection::collect([
346             $this,
347             $this
348         ]);
349         $this->assertEquals(2, $sut->count());
350     }
351 
352     public function testShouldFailWithExceptionsWhenInvalidTypeFound()
353     {
354         $this->expectException(\UnexpectedValueException::class);
355         Collection::collect([
356             $this,
357             new \stdClass()
358         ]);
359     }
360 
361     public function testShouldMapEmptyCollectionToEmptyArray()
362     {
363         $sut = $this->getCollection();
364         $this->assertEquals([], $sut->toArray());
365     }
366 
367     public function testShouldReturnCollectionAsArray()
368     {
369         $sample = [$this];
370         $sut = Collection::collect($sample);
371         $this->assertEquals($sample, $sut->toArray());
372     }
373 
374     public function testShouldMapOneElementCollectionToArray()
375     {
376         $sut = $this->getCollection();
377         $sut->append($this);
378         $this->assertEquals(['mapped'], $sut->toArray(function(CollectionTest $eleme\
379 nt) {
380             return 'mapped';
381         }));
382     }
383 
384     public function testShouldMapSeveralElementsCollectionToArray()
385     {
386         $sut = $this->getCollection();
387         $sut->append($this);
388         $sut->append($this);
389         $this->assertEquals(['mapped', 'mapped'], $sut->toArray(function(CollectionT\
390 est $element) {
391             return 'mapped';
392         }));
393     }
394 
395     public function testShouldTellCollectionType()
396     {
397         $sut = Collection::of(CollectionTest::class);
398         $this->assertEquals(CollectionTest::class, $sut->getType());
399     }
400 
401     public function testShouldTellIfCollectionIsEmpty()
402     {
403         $sut = $this->getCollection();
404         $this->assertTrue($sut->isEmpty());
405     }
406 
407     public function testShouldTellIfCollectionIsNotEmpty()
408     {
409         $sut = $this->getCollection();
410         $sut->append($this);
411         $this->assertFalse($sut->isEmpty());
412     }
413 
414     public function isTarget()
415     {
416         return isset($this->target);
417     }
418 }
419 
420 class MappedObject
421 {
422 
423 }

Apéndices

  1. PHPUnit Instrucciones básicas para instalar y configurar phpunit en un proyecto.
  2. 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.
  3. 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.
  4. 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:

1 mkdir dojo
2 cd dojo

Dentro del proyecto asumimos la convención de tener las las carpetas src y tests

1 mkdir src
2 mkdir tests

Si no lo hemos hecho antes, iniciamos el proyecto mediante composer init y como primera dependencia requerimos phpunit.

1 composer init
2 # Fill in with the data needed
3 composer require --dev phpunit/phpunit

Por último, configuraremos los namespaces del proyecto en composer.json, que quedará más o menos así:

 1 {
 2   "name": "talkingbit/dojo",
 3   "description": "A simple space to practice testing",
 4   "minimum-stability": "dev",
 5   "license": "MIT",
 6   "type": "library",
 7   "config": {
 8     "bin-dir": "bin"
 9   },
10   "authors": [
11     {
12       "name": "Fran Iglesias",
13       "email": "franiglesiad@mac.com"
14     }
15   ],
16   "require-dev": {
17     "phpunit/phpunit": "^7.4@dev"
18   },
19   "autoload": {
20     "psr-4": {
21       "TalkingBit\\Dojo\\": "src/"
22     }
23   },
24   "autoload-dev": {
25     "psr-4": {
26       "Tests\\TalkingBit\\Dojo\\": "tests/"
27     }
28   }
29 }

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.

1 composer install

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:

1 bin/phpunit --generate-configuration

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:

 1 <?xml version="1.0" encoding="UTF-8"?>
 2 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 3          xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/7.4/phpunit.xsd"
 4          bootstrap="vendor/autoload.php"
 5          forceCoversAnnotation="false"
 6          beStrictAboutCoversAnnotation="true"
 7          beStrictAboutOutputDuringTests="true"
 8          beStrictAboutTodoAnnotatedTests="true"
 9          verbose="true">
10     <testsuites>
11         <testsuite name="default">
12             <directory suffix="Test.php">tests</directory>
13         </testsuite>
14     </testsuites>
15 
16     <filter>
17         <whitelist processUncoveredFilesFromWhitelist="true">
18             <directory suffix=".php">src</directory>
19         </whitelist>
20     </filter>
21 </phpunit>

Si es necesario, añadimos el control de versiones:

1 git init

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:

1 mkdir dojo
2 cd dojo

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.

1 mkdir src
2 mkdir spec

Si planeas usar plantillas de código (ver más abajo) debes crear la carpeta .phpspec en la raíz del proyecto.

1 mkdir .phpspec

Si no lo hemos hecho antes, iniciamos el proyecto mediante composer init y como primera dependencia requerimos phpspec.

1 composer init
2 # Fill in with the data needed
3 composer require --dev phpspec/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.

 1 {
 2   "name": "talkingbit/dojo",
 3   "description": "A simple space to practice testing",
 4   "minimum-stability": "dev",
 5   "license": "MIT",
 6   "type": "library",
 7   "config": {
 8     "bin-dir": "bin"
 9   },
10   "authors": [
11     {
12       "name": "Fran Iglesias",
13       "email": "franiglesiad@mac.com"
14     }
15   ],
16   "require-dev": {
17     "phpspec/phpspec": "5.0.x-dev"
18   },
19   "autoload": {
20     "psr-4": {
21       "TalkingBit\\Dojo\\": "src/"
22     }
23   },
24   "autoload-dev": {
25     "psr-0": {
26       "": "src/"
27     },
28     "psr-4": {
29       "Tests\\TalkingBit\\Dojo\\": "tests/"
30     }
31   }
32 }

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).

1 suites:
2     example_suite:
3         namespace: TalkingBit\Dojo
4         psr4_prefix: TalkingBit\Dojo
5         spec_prefix: spec
6         src_path: '%paths.config%/src'
7         spec_path: '%paths.config%'
  • 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.

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

1 <?php
2 declare (strict_types = 1);%namespace_block%
3 
4 class %name%
5 {
6 }

specification.tpl

 1 <?php
 2 
 3 namespace %namespace%;
 4 
 5 use %subject%;
 6 use PhpSpec\ObjectBehavior;
 7 use Prophecy\Argument;
 8 
 9 class %name% extends ObjectBehavior
10 {
11     public function it_is_initializable()
12     {
13         $this->shouldHaveType(%subject_class%::class);
14     }
15 }

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:

1 public function testShouldCalculateThePriceWithDiscount()
2 {
3     $price = new Price(100);
4     $discountedPrice = $price->minusDiscountPercent(15);
5     $this->assertEquals(85, $discountedPrice);
6 }

En phpspec, lo equivalente sería escribir el siguiente ejemplo:

1 public function it_should_calculate_the_price_with_discount()
2 {
3     $this->beConstructedWith(100);
4     $this->$price->minusDiscountPercent(15)->shouldBe(85);
5 }

Veamos las diferencias una a una:

En phpspec

  • Los TestCase se llaman Specification.
  • Los tests se llaman ejemplos y se nombrar comenzando por it_ o its_.
  • 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í:

1 bin/phpspec describe Dojo/Domain/Customer/Customer

Una forma alternativa que usa la sintaxis de los namespace es:

1 bin/phpspec describe 'Dojo\Domain\Customer\Customer'

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:

1 Specification for Dojo\Domain\Customer\Customer created in /Users/frankie/Sites/hlz/\
2 spec/Domain/Customer/CustomerSpec.php.

Y la Spec, creada en la ruta indicada, tendrá esta pinta:

spec/Domain/Customer/CustomerSpec.php

 1 <?php
 2 
 3 namespace spec\Dojo\Domain\Customer;
 4 
 5 use Dojo\Domain\Customer\Customer;
 6 use PhpSpec\ObjectBehavior;
 7 use Prophecy\Argument;
 8 
 9 class CustomerSpec extends ObjectBehavior
10 {
11     function it_is_initializable()
12     {
13         $this->shouldHaveType(Customer::class);
14     }
15 }

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 clase Customer 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 por it o its.
  • 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.

1 bin/phpspec run

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):

1 bin/phpspec run 'Dojo\Domain\Customer\Customer'

O bien, la ruta completa al archivo de la Spec:

1 bin/phpspec run spec/Dojo/Domain/Customer/CustomerSpec.php

O bien, indicando una ruta en la que esté incluida nuestra Spec:

1 bin/phpspec run spec/Dojo/Domain/

En cualquier caso, el test fallará y nos devolverá el siguiente resultado (los detalles pueden cambiar un poco dependiendo de la forma de invocarla):

 1 Dojo/Domain/Customer/Customer                                                   
 2   11  - it is initializable
 3       class Dojo\Domain\Customer\Customer does not exist.
 4 
 5                                       100%                                       1
 6 1 specs
 7 1 example (1 broken)
 8 6ms
 9 
10                                                                                 
11   Do you want me to create `Dojo\Domain\Customer\Customer` for you?             
12                                                                          [Y/n] 

Fíjate que al final se ofrece a crear la clase descrita por ti, para lo cual bastará con responder y.

1 Class Dojo\Domain\Customer\Customer created in /Users/frankie/Sites/hlz/src/Domain/C\
2 ustomer/Customer.php.
3 
4                                       100%                                       1
5 1 specs
6 1 example (1 passed)
7 7ms

¿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

1 <?php
2 declare (strict_types = 1);
3 
4 namespace Dojo\Domain\Customer;
5 
6 class Customer
7 {
8 }

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:

1 composer require "codeception/codeception" --dev

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:

1 vendor/bin/codecept

Opcionalmente, puedes hacer un enlace simbólico o alias para poder invocarlo desde bin, ejecutando este comando desde la raíz del proyecto.

1 ln --symbolic ../vendor/bin/codecept  bin/codecept

Si no se ha generado automáticamente, puedes preparar el proyecto para usar codeception lanzando:

1 vendor/bin/codecept bootstrap

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace App\Controller;
 5 
 6 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 7 use Symfony\Component\HttpFoundation\JsonResponse;
 8 use Symfony\Component\HttpFoundation\Response;
 9 
10 class LuckyController extends AbstractController
11 {
12     public function number(): Response
13     {
14         $number = random_int(0, 100);
15 
16         $response = new JsonResponse(
17             ['number' => $number],
18             Response::HTTP_OK
19         );
20 
21         return $response;
22     }
23 }

Primero, generamos una suite:

1 bin/codecept generate:suite api

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:

1 actor: ApiTester
2 modules:
3     enabled:
4         - REST:
5               url: http://172.22.0.4
6               depends: PhpBrowser
7               part: Json
8         - \Helper\Api

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:

1 bin/codecept generate:cest api Lucky

Que nos creará lo siguiente en tests/api/LuckyCest.php:

 1 <?php 
 2 
 3 class LuckyCest
 4 {
 5     public function _before(ApiTester $I)
 6     {
 7     }
 8 
 9     // tests
10     public function tryToTest(ApiTester $I)
11     {
12     }
13 }

A partir de esta plantilla, escribimos nuestro primer test:

 1 <?php
 2 
 3 use Codeception\Util\HttpCode;
 4 
 5 class LuckyCest
 6 {
 7     public function _before(ApiTester $I)
 8     {
 9     }
10 
11     // tests
12     public function shouldGetARandomNumber(ApiTester $I)
13     {
14         $I->sendGET('/lucky/number');
15         $I->seeResponseCodeIs(HttpCode::OK);
16         $I->seeResponseMatchesJsonType([
17                'number' => 'integer'
18         ]);
19     }
20 }

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:

1 bin/codecept run api

Que nos da como resultado:

 1 Codeception PHP Testing Framework v2.5.3
 2 Powered by PHPUnit 7.5.3 by Sebastian Bergmann and contributors.
 3 Running with seed:
 4 
 5 
 6 Api Tests (1) ----------------------------------------------------------------------\
 7 ---------------------------------------------
 8 Testing api
 9  LuckyCest: Should get a random number (0.59s)
10 ------------------------------------------------------------------------------------\
11 ---------------------------------------------
12 
13 
14 Time: 2.03 seconds, Memory: 12.00MB
15 
16 OK (1 test, 2 assertions)

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace App\Tests\E2E;
 5 
 6 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 7 
 8 class LuckyControllerTest extends WebTestCase
 9 {
10     public function testShouldGetARandomNumber(): void
11     {
12         $client = self::createClient();
13         $client->request('GET', '/lucky/number');
14         $contents = $client->getResponse()->getContent();
15         $data = json_decode($contents);
16         $this->assertIsInt($data->number);
17     }
18 }

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:

1 composer install

Uso básico de docker

Para levantar los contenedores tienes que ejecutar en la raíz del proyecto:

1 docker-compose up -d

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í:

1 docker-compose exec php-fpm bash

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.

1 upload_max_filesize = 300M
2 post_max_size = 308M
3 xdebug.remote_enable = 1

También necesitamos añadir un server_name en el archivo de configuración de nginx, que está en phpdocker/nginx/nginx.conf:

 1 server {
 2     listen 80 default;
 3     server_name  localhost;
 4     client_max_body_size 308M;
 5 
 6     access_log /var/log/nginx/application.access.log;
 7 
 8 
 9     root /application/public;
10     index index.php;
11 
12     if (!-e $request_filename) {
13         rewrite ^.*$ /index.php last;
14     }
15 
16     location ~ \.php$ {
17         fastcgi_pass php-fpm:9000;
18         fastcgi_index index.php;
19         fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
20         fastcgi_param PHP_VALUE "error_log=/var/log/nginx/application_php_errors.log\
21 ";
22         fastcgi_buffers 16 16k;
23         fastcgi_buffer_size 32k;
24         include fastcgi_params;
25     }
26     
27 }

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:

1 docker-compose up -d

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.

1 /application/bin/.phpunit/phpunit-6.5/phpunit

En una instalación común, la ruta suele ser:

1 /application/bin/phpunit

También es recomendable indicar el archivo de configuración por defecto, que será:

1 /application/phpunit.xml

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:

1 docker-compose restart php-fpm

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.