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