Tabla de contenidos
- Introducción
- Desarrollar una cultura de testing
- La dualidad del testing
- 1. Guía para testear con dobles
- 1.1. Dummies
- 1.2. Stubs
- 1.3. Mocks y Spies
- 1.4. Fakes
- Patrones y heurísticas para trabajar con dobles en los tests
- 2.1. Patrones de uso de dobles de test
- 2.2. Anti-patrones
- 2.3. Heurísticas para testear con dobles
- 2.4. Testing expresivo
- 2.5. La performance de los métodos para crear test doubles
- Notas
Introducción
Gracias por interesarte por este libro.
Desarrollar una cultura de testing
Supongamos que te planteas introducir testing en un proyecto que no lo tiene o que no está suficientemente testeado.
Una de las cosas que me llaman la atención es que el testing no es una práctica tan extendida como, quizá, debería. No voy a entrar en las razones, pero me he puesto a pensar en cómo un equipo de desarrollo podría introducir o mejorar su testing de una manera sencilla y efectiva.
Para ello, me he inspirado en el modo en que lo hemos hecho en el equipo de Backend PHP de Holaluz y cómo hemos incrementado nuestra cobertura, número de tests, calidad de los mismos y protección de nuestro código en el último año, sin que ello supusiera un sobre esfuerzo o un proyecto ad-hoc.
Análisis de situación
La primera tarea en la que nos centraremos es conocer la situación real del testing de un proyecto. Puede que no haya ni un sólo test. O puede que ya haya un cierto nivel de testeo. Y, en este caso, la calidad del mismo puede oscilar entre dudosa y aceptable.
Si no hay tests, el trabajo está claro: decidirse por un framework de testing que nos ayude en la tarea, instalarlo e investigar por dónde empezar.
Si ya existen tests, lo suyo es ejecutarlos y ver qué pasa. Podrían ocurrir varias cosas:
- Los tests funcionan bien, se ejecutan y pasan. Es un buen punto de partida.
- Los tests se ejecutan, pero algunos no pasan. En ese caso toca intentar arreglarlos o, si por algún motivo esto resulta complicado, eliminarlos y quedarse con los que pasan.
- Los tests ni siquiera se pueden ejecutar o si lo hacen fallan todos. Esto es síntoma de que los intentos de test que se hicieron en ese código son seguramente bastante antiguos y se abandonaron. Es muy posible que tengas que eliminar todo y empezar de cero.
En cuanto al framework de test, posiblemente lo mejor es empezar con phpunit que no deja de ser la herramienta de testing estándar en el mundo PHP, pero no descartaría tampoco codeception, al menos para ciertas áreas.
Cultura de testing
Paralelamente, conviene plantearse varias cosas como equipo:
- Por una parte, tomar una postura común ante el estado del testing en el proyecto y las líneas de actuación que se van a seguir.
- Por otra, evaluar las posibles necesidades de formación ya que el rodaje de cada persona puede ser muy diferente. Si no hay experiencia previa de testing, necesitaremos dedicar algunas sesiones de formación.
El objetivo será favorecer una cultura orientada a la calidad del software que se concrete en que toda subida a producción vaya respaldada con tests.
Análisis de cobertura
Si ya existe una cierta base de tests lo conveniente ahora es ejecutarlos con una medida de la cobertura.
El índice de cobertura de los tests es una métrica fácil de obtener, pero también es fácil de interpretar mal. Nosotros la utilizaremos para empezar a entender el estado del proyecto. Nos indica el porcentaje de líneas del código que son ejecutadas por los tests.
Un valor alto nos permite tener una confianza alta en que el comportamiento del código es el que dicen los tests. Eso no nos libra de posibles errores ya que puede haber casos no contemplados o simplemente podríamos haber implementado mal las reglas de negocio por falta de información, pero nos permite hablar de lo que hace la aplicación con un margen alto de seguridad.
Un valor bajo, por el contrario, no nos permite tener esa certeza. La parte no ejecutada de los tests es el lugar en el que se esconden los bugs y los errores que pueden dar lugar a problemas con nuestra aplicación.
Nos interesa obtener un índice global, así como en algunas grandes divisiones del software. Por ejemplo: en las capas de dominio, aplicación e infraestructura. O bien en otras divisiones que tengan sentido para el proyecto.
Primeros objetivos
Si no estamos en ese nivel, nuestro primer objetivo sería conseguir un índice de cobertura del 50-60% en todas las divisiones que estemos considerando.
Si estamos en ese nivel o por encima, la confianza en el código será alta, aunque todavía podemos mejorarla.
En cualquier caso, el análisis de cobertura es una buena herramienta para identificar las ramas del flujo de ejecución que no están bajo test y que, por tanto, necesitan más atención.
Cómo priorizar
Cuando la cobertura de tests es baja puede invadirnos una cierta sensación de agobio ante la tarea que nos espera. ¿Por dónde empezar a trabajar y cómo? ¿Debería tener tareas o historias que sean sólo técnicas para poder incrementar el nivel de testing?
La cuestión es priorizar y la mejor forma de hacerlo es alineándonos con negocio.
Así, la primera prioridad será aumentar la cobertura en la capa de dominio, o la equivalente en tu código. La razón es bastante obvia: el dominio es aquello a lo que nos dedicamos, lo que define nuestro negocio y es lógico asegurarnos de que funciona bien.
Además, la capa de dominio si está bien ejecutada no tendría dependencias, lo que la convierte en más fácil de testear. Esto genera una excelente relación entre el coste de testear y el beneficio que vamos a obtener.
A continuación, la prioridad será la capa de aplicación, en la que se definen los casos de uso y los servicios de la aplicación. En esta capa tendremos que usar muchos dobles para verificar cómo los casos de uso orquestan la interacción de los servicios y los elementos del dominio.
La parte menos prioritaria sería la de infraestructura. Pero no hay que confundir menos prioridad con ignorarla por completo.
A este respecto, he visto muchas veces la idea de que no se testea la capa de infraestructura y esto es un error. Obviamente no testeamos los vendors, es decir el código que no es nuestro pero que usamos para poder implementar esa capa. Lo que sí debemos testear son los adaptadores que sí son código nuestro.
Se puede decir que priorizaremos siguiendo la regla de dependencia.
Prioridades técnicas
En el aspecto técnico podemos optar por dos enfoques: priorizar los tests unitarios o bien priorizar tests end to end, que verifiquen la aplicación desde sus puntos de entrada. Veamos razones para cada enfoque:
Los tests unitarios nos permiten trabajar de manera rápida y enfocada. Además, nos ayudarán a encontrar problemas de diseño relativamente fáciles de arreglar porque se encuentran circunscritos a unidades relativamente pequeñas.
En cuanto a la cobertura, los tests unitarios nos hacen avanzar lentamente en cuanto al índice global, puesto que cada test ejecuta pocas líneas. Sin embargo, sí que hacen que la cobertura sea más sólida en tanto que cubrimos más caminos de ejecución y éstos se recorren más veces.
Los tests end-to-end, que muchas veces se llaman funcionales 1, nos permitirán avanzar muy rápidamente en cuanto a cobertura. Son más difíciles de montar porque necesitarás fixtures y también serán más lentos por la misma razón. Por otro lado, testear de este modo se contradice un poco con la propuesta de priorización que hacíamos más arriba.
Una de las ventajas de este enfoque es que puede funcionar mejor en aquellas bases de código que no están bien organizadas, ayudándonos a tender una red de seguridad para intervenir en el código.
Organización del trabajo
La primera recomendación puede parecer contradictoria, pero no debería haber tareas o “historias de usuario” dedicadas al testing2. Los motivos son varios y los principales, para mí, serían:
- Las historias de testing no aportan valor de negocio per se. El testing es fundamental en una auténtica metodología ágil y, de hecho, un código bien testeado es mucho más valioso para negocio que uno que no tiene tests. A la larga, el código testeado hace que los equipos desarrollen nuevas features mucho más rápidamente y con menos probabilidad de errores.
- Una historia de testing en general es muy difícil de delimitar en objetivos y en el tiempo. Si ya es complicado estimar una historia de usuario, imagínate estimar una de testing. Una medida de cobertura no nos sirve de gran cosa tampoco.
Pero si estamos de acuerdo con esto, entonces: ¿cuándo testeamos?
En las Historias de Usuario
Lo primero es introducir el testing como parte de la Defintion of done de una Historia de Usuario. Es decir, una entrega se hace con testing o no se hace. E igualmente las subtareas técnicas no deberían incluir la palabra test o refactor en ellas.
Esto, además, nos ayuda a definir el ámbito del testing. Idealmente, el testing debería permitirnos afirmar justamente que la entrega que hacemos es lo que se nos pedía en la historia. Como regla práctica, podríamos decir que todo código que toquemos durante la realización de la tarea debería estar cubierto por tests.
Y es aquí donde aplicamos las prioridades que mencionamos más arriba.
En los Bugs
Los bugs nos proporcionan una buena oportunidad para incrementar el nivel de testing de nuestra aplicación mediante TDD, prestando atención a casos particulares no cubiertos anteriormente.
La mejor forma de empezar es escribir uno o más tests que, al fallar, reproduzcan el bug. Nuestro trabajo, entonces, será hacer pasar esos tests, con lo que resolveremos el fallo y demostraremos además que está solucionado.
El bonus point es que estos tests nos proporcionan mejor cobertura de casos.
Cuando dedicarse sólo al testing
Solo si por circunstancias particulares disponemos de más tiempo, entonces quizá podamos invertir algo del mismo en trabajar directamente haciendo nuevos tests de partes del código que hayan podido quedar peor tratadas o para refactorizar o reescribir tests que necesiten un poco de cariño para ser más expresivos o más eficientes.
Beneficios
Una de las consecuencias de trabajar de esta forma es que seguramente nos encontraremos con partes de código ya bien testeadas. El hecho de trabajar sobre un mismo área de código una y otra vez nos dice que esa parte es importante para el negocio pues es donde se añaden, afinan o eliminan las características requeridas.
Volver reiteradamente sobre estas partes nos permite mejorar la calidad tanto del código como de los tests y avanzar mucho más rápidamente.
Cuando es difícil testear
Por lo general, si una parte de nuestro código es difícil de testear, nos está indicando un problema de diseño. Habitualmente se tratará de clases con demasiadas responsabilidades y que deberíamos separar, que tienen dependencias ocultas o que tienen dependencias de implementaciones concretas que necesitan ser invertidas.
Solucionar estos problemas para poder testear mejor también ayudará a incrementar la calidad de nuestro código en general y nos facilitará el desarrollo futuro.
¿Cuánto tiempo se necesita para alcanzar un buen nivel?
No es una cuestión de tiempo. Lo más importante es el compromiso del equipo y tener un enfoque y objetivos realistas. Se trata de desarrollar una cultura de calidad.
Lo ideal, como señalamos antes, es incluir el testing y la mejora del código en la definición de terminada de las tareas, de modo que el testing forme parte de la rutina de trabajo. De este modo se avanza con una velocidad más o menos constante y se pueden conseguir resultados positivos observables en dos o tres meses con poco esfuerzo y, a partir de ahí, seguir creciendo de forma sostenida.
Fijar un plazo resulta contraproducente. Yo diría que siempre será imposible de cumplir, porque los requisitos y las necesidades irán cambiando.
La dualidad del testing
Para ser una disciplina dedicada a que el software se comporte correctamente, lo cierto es que cuando hablamos de testing usamos el lenguaje con muy poca precisión.
Y eso suele significar que el mapa conceptual del testing está desorganizado, con los conceptos poco articulados y vacíos importantes entre ellos.
Sin ir más lejos, la propia palabra testing en realidad designaría dos disciplinas. Complementarias y superpuestas, sí, pero diferentes.
Separación de intereses
Por lo general asociamos testing con Control de Calidad (Quality Assurance o QA). Pero en realidad, tenemos dos usos de los tests que sirven a propósitos diferentes, en diferentes momentos del proceso de desarrollo y que son manejados por diferentes profesionales.
Por una parte tenemos el testing como definición de cuál debe ser el comportamiento del software. Es decir, el test como especificación. Y, como tal, es una herramienta de desarrollo.
Por otra parte está el testing como control de calidad del software, es decir, la forma en que verificamos que no tiene defectos y funciona correctamente. Eso incluye que la especificación se cumple, pero también se refiere a más cosas y a más formas de comprobarlas.
El testing como especificación y el testing como control de calidad, son dos cuestiones diferentes pero complementarias y que se solapan.
Por ejemplo. Cuando decimos que un programa, una clase o un método, debe tener cierto comportamiento, pensamos en que debe resolver ciertos casos de prueba según se ha especificado.
Esta especificación se puede definir en forma de test, que al ser escrito en un lenguaje de programación, constituye una definición formal y operativa de ese comportamiento.
Una vez que tenemos el test, es posible escribir un programa que lo haga pasar. Y si se cumple el test, estamos en condiciones de afirmar que se ha satisfecho la especificación y, en consecuencia, es que se ha implementado el comportamiento deseado.
Pero este test no garantiza que el software haya sido creado sin defectos ya que, o bien no se ha definido una especificación correcta o bien no se han tenido en cuenta una serie de circunstancias que podrían afectar durante su ejecución aunque la especificación se cumpla.
Por ejemplo, imagina un programa que calcula la factura del consumo de agua, introduciendo lecturas del contador. Ahora, supón que no se ha tenido en cuenta la posibilidad de que se introduzcan valores negativos pues se espera que los contadores no los van a proporcionar. Esto es, podría cumplir con la especificación y, sin embargo, tener defectos que aparecerán si algún día un problema en el contador hace que este entregue valores negativos.
Estos defectos son los que se intentan controlar con los tests de QA, junto con el hecho de que el software cumpla las especificaciones.
Según esto, podríamos pensar que QA engloba de algún modo los tests de desarrollo, pero no es exactamente así. Es más bien una intersección que permite que los tests de desarrollo, o parte de ellos, se puedan también usar como tests de QA. De hecho, un test de desarrollo se convierte automáticamente en un test de regresión, que es un test de QA, una vez que hemos establecido que se cumple por parte del software.
Si sigues una metodología TDD escribirás muchos tests que para QA sobrarán porque son redundantes. De hecho, en el proceso de desarrollo TDD los irías eliminando en las fases de refactor y al terminar.
Por otro lado, si sigues una metodología BDD (Behavior Driven Development) esta distinción puede ser aún más acusada, porque los tests BDD definen las features de un software desde una perspectiva de negocio, pero no se ocupan de sus detalles técnicos que deben ser cubiertos por otras pruebas.
Dicho de otra forma. En QA y en desarrollo utilizamos herramientas similares, los tests, pero con intereses diferentes y priorizando necesidades distintas:
- En desarrollo los tests son especificaciones del comportamiento del software, y nuestro objetivo es cumplirlas para, así, resolver un problema de negocio.
- En QA los tests actúan, además, como monitores de que el funcionamiento del software es correcto y está libre de defectos.
1. Guía para testear con dobles
Se diría que uno de los puntos más problemáticos a la hora de testear sea, para muchas personas, el utilizar dobles: cuándo usarlos, cómo y un largo etcétera de preguntas. En este artículo voy a proponer una serie de consideraciones para testear con dobles y no sufrir.
Los dobles se usan para tener bajo control el comportamiento de los colaboradores en una situación de test
La función de los dobles de test es tener bajo control el comportamiento de los colaboradores de modo que podamos eliminar su influencia sobre el comportamiento de la unidad bajo test o bien conocerla con exactitud y tenerlo en cuenta.
Además, los dobles de test, nos ayudan a evitar dificultades derivadas de usar colaboradores que no controlamos o que introducen elementos indeseables como conexiones de red, sistemas de almacenamiento, servicios de terceros, etc.
Es decir, cuando hacemos un test de una unidad de software lo que queremos comprobar es que la respuesta o efecto de esa unidad se produce como resultado de que se ejecuta su código y sólo su código.
En esa situación no queremos testear lo que hacen los colaboradores, sino que queremos decidir que harán o no en ese test concreto.
Se llaman dobles, no mocks, y los hay de varios tipos
Casi todo el mundo habla de mocks al referirse a los objetos que usamos para sustituir a los colaboradores de nuestra unidad bajo test. Sin embargo, llamarlos a todos mocks es un error. Cierto es que las librerías para fabricar dobles no ayudan en la terminología: desde phpunit
que ofrece el método createMock
o el mockBuilder
, pasando por la conocida mockery
.
Pero no, el nombre genérico es test double
. Y es posible crear varios tipos de dobles:
- Dummies
- Stubs
- Spies
- Mocks
- Fakes
En esta entrega me centraré en los dummies y los stubs. Dejaré los spies, los mocks y los fakes para otro artículo, pero todo lo que se dice aquí es aplicable a ellos. Lo peculiar de los mocks y los spies es que guardan información de como han sido utilizados. Pero en cuanto al comportamiento que simulan funcionan exactamente igual que los dummies y los stubs.
1.1. Dummies
Los dummies son dobles que no tienen comportamiento. Esto quiere decir que todos sus métodos (públicos) devuelven null
. En algún lugar se sugiere que deberían devolver una excepción para el caso de que la unidad bajo test utilice esos métodos. Sin embargo, que el dummy lance una excepción, aunque sea para protestar por el uso, no deja de ser un comportamiento, que además es exclusivamente técnico, y no me parece una buena práctica.
Nos interesa que los dummies devuelvan null
. Eso quiere decir que no tienen comportamiento y, por lo tanto, no pueden influir en la respuesta o efecto de la unidad bajo test. Cuando la unidad bajo test u otro colaborador necesiten utilizar la respuesta de estos dummies protestarán por haber recibido un null
en vez del tipo de valor esperado.
Se podría objetar que en algunos casos devolver null
sería un comportamiento adecuado de un colaborador, o al menos un comportamiento posible, por lo que el resultado de un posible test podría ser confuso.
En este caso, si esta situación te supone realmente un problema puede que debas reconsiderar si es adecuado para tu caso de uso que se pueda usar ese dato como null
y si no sería mejor lanzar una excepción en su lugar.
Los dummies normalmente se utilizan para poder construir el objeto que vamos a testear. Normalmente en construcción no necesitaremos ejecutar ninguno de sus métodos, simplemente asignarlos a los miembros adecuados de la clase bajo test. A continuación, tienes un ejemplo de un setUp
en el que se instancia un objeto de la clase que vamos a testear y se le pasan varios colaboradores dummies:
Un dummy también podría ser un argumento que pasamos a la unidad bajo test:
1.2. Stubs
Los stubs son dobles de test que tienen un comportamiento prefijado que siempre será el mismo. Dicho de otra manera: los métodos de un stub devuelven una respuesta preprogramada y no calculada. Se podría decir que un dummie es un stub que devuelve null
, aunque a veces sí queremos simular que un objeto devuelve null
como respuesta válida.
Es decir, los stubs simulan comportamientos posibles de un colaborador de nuestra unidad bajo test. Estos comportamientos pueden ser:
- Lanza una excepción, simulando que ha ocurrido algo malo.
- Devuelve algún tipo de respuesta válida, simulando que todo ha ido bien y el colaborador puede entregar su respuesta.
Esto nos permite crear un test de nuestra unidad bajo test que verifique su comportamiento cuando el colaborador falla, lanzando una excepción (si debe relanzarla, si debe devolver una respuesta especificada, etc), y también cuando el colaborador devuelve una respuesta válida. Además, en este caso, podría devolver diversos tipos de respuestas válidas.
Pero es importante insistir en que la respuesta del stub no puede ni debe ser calculada porque introduciría un factor de indeterminación en el test.
Stub de una respuesta válida
Veamos aquí un ejemplo de un stub de un objeto, el cual hemos iniciado como dummy en el setUp
que he mostrado más arriba:
En este caso, hacemos un stub del método byId
del colaborador ContractRepository
de modo que devuelve un objeto Contract
(en este caso un dummy).
Con esto podemos verificar el comportamiento de nuestra unidad bajo test cuando ContractRepository
le devuelve el contrato solicitado mediante su id
.
Stub de que se produce una excepción
Si queremos testear que la unidad bajo test reacciona de forma correcta en el caso de que no se encuentre el contrato, simularemos que se lanza una excepción:
Stub de “ida y vuelta”
¿Y qué ocurre si el colaborador tiene que recibir datos de la unidad bajo test y devolverlos transformados? ¿No deberíamos tener algún modo de hacer que el stub devuelva una respuesta adecuada al dato recibido?
La respuesta a esta pregunta es un no rotundo.
Eso implicaría añadir alguna lógica en el doble y, como decíamos antes, se introduciría una indeterminación en el resultado del test: ¿cómo podemos asegurar que esa lógica para decidir la respuesta del doble no está afectando al comportamiento que queremos testear?
Además, eso introduce un acoplamiento del test a algo que está completamente fuera del código.
Entonces, ¿qué es lo que testeamos en ese caso? Pues en ese caso, estaríamos testeando la manera en que la unidad bajo test orquesta o coordina a sus colaboradores, y el comportamiento del colaborador estaría verificado por sus propios tests.
Supongamos que estamos programando un juego en el que el jugador puede utilizar objetos que aumentan su fuerza. Por tanto, la lógica del método obtiene un dato del propio jugador, lo pasa a un colaborador (el objeto que aumenta la fuerza) y este lo devuelve transformado. Algo así:
En el test:
Nos da exactamente igual la fuerza que tenga el jugador o la que reciba. Lo que testeamos aquí es (a) que se usa el objeto y (b) que el valor que devuelve es asignado a la propiedad adecuada. Si el objeto multiplicaba o añadía fuerza, es algo que estará en sus propios tests.
1.3. Mocks y Spies
En esta continuación hablaremos de los mocks y los spies.
Mocks y spies son exactamente iguales a los dummies y stubs en lo que respecta al comportamiento. La diferencia estriba en que podemos verificar si mocks y spies han sido usados de tal o cual manera por la unidad bajo test.
Mocks y spies acoplan el test a la implementación de la unidad bajo test
La principal característica de los mocks y los spies es llevar un registro del modo en que han sido utilizados. Esto es: qué llamadas han recibido, cuántas veces y con qué parámetros.
Esto parece muy útil, y lo es, pero es también muy comprometedor para la calidad del test. Los mocks y spies no se limitan a ser dummies o stubs, simulando comportamientos de los colaboradores, sino que esperan que la unidad bajo test tenga una implementación concreta.
Mocks
Los mocks son dummies o stubs que esperan ser utilizados de cierta manera concreta. Esta expectativa equivale a una aserción y cuenta como tal en las estadísticas de la suite de test.
Veamos un mock de un dummy. Supongamos que tenemos un colaborador Logger
y simplemente queremos verificar que nuestra clase bajo test hace log de una cierta condición:
El código anterior establece una expectativa sobre cómo la clase bajo test va a utilizar a su colaborador logger
. El método expects
representa un concepto similar a una aserción, indicando que el test pasará si se llama una vez al método notice
del objeto logger
.
El mock de un stub es similar:
En este caso, además de establecer la expectativa, el mock, que sigue siendo un stub, simula una respuesta específica.
Es posible, aunque no siempre merece la pena, definir los parámetros esperados en la llamada. Siguiendo con el ejemplo anterior, vamos a suponer que esperamos el método calculate
sea llamado con el parámetro 2
, que se referiría a las horas de las que queremos obtener la cuota o precio.
Un inconveniente de los mocks es que mezclan las tres fases del test: mientras que la simulación del comportamiento forma parte del Given o Arrange, la expectativa es claramente parte del Then o Assert y, además, debes definir la expectativa antes de la fase When o Act, es decir, antes de ejecutar la unidad bajo test.
Spies
Los spies son, igualmente, dummies o stubs que registran el modo en que nuestro código interactúa con ellos y los utiliza, de modo que pueden llevar la cuenta de qué métodos han sido llamados, cuántas veces y con qué parámetros.
La ventaja sobre los mocks es que con ellos podemos hacer aserciones de manera explícita en el test, es decir, una vez ejecutada la unidad bajo test le preguntamos al spy lo que nos interese controlar de su uso.
En algunas librerías de test doubles existen métodos para usar los dobles como spies, pero los mocks nativos de PHPUnit son un poco pejigueros y no está bien documentado el método para conseguirlos.
El ejemplo anterior, podría convertirse en un spy de esta manera:
La fragilidad de los tests con mocks y spies
El mayor inconveniente o riesgo de abusar de los mocks y spies es que el test queda acoplado a la implementación ya que no testea el comportamiento de la unidad de software, sino que en el código se hacen ciertas llamadas y de una cierta manera.
De este modo, si en el futuro cambiamos esas llamadas, puede ocurrir que el test falle aunque no estemos intentando cambiar el comportamiento de la unidad. En otras palabras: el test falla pero el comportamiento de la unidad es correcto.
A esto lo llamamos fragilidad del test: el riesgo de que un test no pase por un motivo que no sea su comportamiento.
Cómo limitar la fragilidad de los tests que usan mocks y spies
Los test doubles son una parte necesaria de los tests en el nivel unitario, ya que necesitamos que los colaboradores de la unidad bajo test no ejecuten su propia lógica para no contaminar el resultado.
En otros niveles de testing podemos necesitar dobles que nos permitan simular sistemas remotos o sistemas que no podemos controlar (servicios web, mailers, clouds, etc).
Es decir, normalmente no podremos dejar de usar dobles, pero sí podemos usarlos con criterio. En este artículo daré unas ideas para hacerlo en el nivel unitario. En una próxima entrega intentaré desarrollar el tema de los dobles en los niveles de integración y de aceptación.
Definir qué necesito testear aplicando el principio Command Query Separation
El principio Command Query Seperation fue enunciado por Bertrand Mayer y dice más o menos esto:
Every method should be either a command that performs an action, either a query that returns data.
Esto es, cada método o función puede ser:
- un comando: ejecuta una acción y produce un efecto en el sistema
- una query: hace una pregunta al sistema y devuelve algo
Por tanto, para testear cualquier método o función lo primero que tenemos que saber es si se trata de un comando o una query.
Testear queries
Si es una query, el test se basa en verificar que la respuesta que devuelve coincide con la que esperamos. Por tanto, tendremos asserts que se ocupen de eso. Si la unidad bajo test usa colaboradores, éstos serán doblados mediante dummies o stubs, y en ningún caso usaremos mocks o spies.
Puedo imaginar la siguiente objeción:
Pero quiero asegurarme de que se llama a cierto método del colaborador.
Respuesta corta: no quieres eso.
La respuesta larga a esta objeción es doble:
- En primer lugar, deberías testear el comportamiento de la unidad bajo test, no la forma en que se implementa. A la larga, esto te da la libertad de cambiar esa implementación en el futuro sin que los tests se vean afectados salvo, quizá, la parte del Given si utilizas colaboradores distintos.
- En segundo lugar, las llamadas al colaborador están implícitas en la ejecución de la unidad bajo test. La respuesta que devuelve es el resultado de la ejecución del código y, si se hace la llamada, el colaborador devolverá la respuesta simulada y se obtendrá algo que es función de esa respuesta, entre otros factores.
Como regla práctica, en un test de una query nunca se usarían métodos como expects
, shouldBeCalled
o similar.
Así testearíamos un servicio CalculateFee
que utiliza un colaborador ‘FeeCalculator’:
Testear commands
En el nivel unitario, testear commands nos obliga a usar mocks. La razón es que la acción ejecutada por la unidad bajo test tendrá un efecto en algún elemento del sistema que habremos de simular mediante un doble.
Un buen ejemplo sería una unidad que construye una entidad y la guarda en un repositorio. En el nivel unitario no podremos acceder al repositorio para ver si está la entidad recién creada y guardada (a no ser que usemos un repositorio real, pero hemos quedado que en este nivel no lo haremos), por lo que necesitamos comprobar eso de otra manera.
La más sencilla es asegurarnos de que el método save
o equivalente del repositorio sea llamado con una entidad, incluso controlando que sea la entidad que esperamos que se haya creado.
La regla de oro aquí es que sólo deberíamos establecer expectativas para verificar que se produce el efecto esperado por la ejecución de la unidad bajo test. Y cuantas menos expectativas mejor. De hecho, si la unidad bajo test produce dos o más efectos lo ideal sería tener un test para cada uno de ellos.
Limitar la verificación de parámetros de las llamadas
Personalmente no soy muy partidario de especificar en los mocks los parámetros con los que llamo a los colaboradores. Introducen fragilidad en el test y suponen un trabajo extra importante y el resultado no siempre compensa.
Sin embargo, reconozco que hay bastantes casos en los que puede ser necesario. El ejemplo anterior podría ser uno de ellos, ya que el dato clave que me permite identificar que estoy creando el usuario deseado es el parámetro que recibe la unidad bajo test y que debería usarse para instanciar el objeto User
.
Ahora bien, cuando se trata de casos de uso en los que un colaborador recibe parámetros las respuestas (simuladas) de otros colaboradores creo que no es útil ni buena práctica verificarlo, es el caso de los dobles de CreateContract
y ContractRepository
. Fíjate también que la única expectativa se hace sobre el hecho de que se guarda el contrato, siendo implícito que se haya creado con el producto correcto. Lo que nos importa de este test, realmente, es cómo la unidad que testeamos coordina a los colaboradores.
Asumir que los colaboradores doblados tienen sus propios tests
El ámbito de un test es la unidad que testeamos, los colaboradores garantizan su comportamiento mediante sus propios tests, por lo que tenemos que asumir es el que esperamos.
No necesitamos testearlo de nuevo.
1.4. Fakes
No hemos hablado todavía de los fakes. Los fakes son un tipo de doble que sustituye a unidades que intervienen en el test y que por sus características introducen efectos indeseados como lentitud, dependencia de servicios de terceros, etc.
Los fakes, de hecho, son objetos que ofrecen el mismo comportamiento, o uno equivalente, que aquellos a los que reemplazan, pero evitando los costes extras de latencia, tiempo de ejecución o errores debidos a condiciones del sistema. Son, dicho en otras palabras, implementaciones alternativas de sus interfaces, creadas para usar en un entorno de test.
En entregas anteriores hemos dicho que los dobles no deberían contener lógica para generar las respuestas simuladas. En el caso de los fakes esta regla no se cumple. De hecho, quizá no deberíamos considerarlos como test doubles, sino como implementaciones a medida para situaciones de test.
Los fakes juegan un papel importante en los tests de integración y en los tests end to end, porque nos permiten crear entornos realistas, pero que a la vez están aislados de servicios remotos o que tengan alto coste en rendimiento o capacidad.
Fake
como implementación alternativa
Uno de los ejemplos clásicos es el de los repositorios en memoria. Los repositorios, que se encargan de la persistencia de las entidades, son lentos por naturaleza y costosos para realizar tests. Sin embargo, si tenemos una implementación que guarde los datos en memoria, la velocidad de ejecución del test aumentará muchísimo.
El problema, obviamente, es que tener buenos fakes implica implementar varias veces una misma interfaz y hacer que pasen exactamente los mismos tests. Por otro lado, en algunos casos, la complejidad del comportamiento que necesitamos puede ser excesiva, aunque esa circunstancia podría estar revelando problemas y debilidades en nuestro diseño.
Así, por ejemplo, es relativamente fácil implementar los comportamientos de persistir y recuperar un objeto, pero las cosas se complican cuando necesitamos hacer búsquedas en función de diversos criterios.
El ejemplo a continuación es un motor de almacenamiento en memoria muy simple (e inacabado, por cierto) que nos serviría para construir implementaciones en memoria de repositorios con algunos métodos básicos.
El fake
como suplantador de comportamiento
Cuando decimos que los fakes ofrecen el mismo comportamiento que los objetos a los que sustituyen, puede ser más correcto decir que suplantan ese comportamiento con otra versión que, de cara al consumidor del fake es indistinguible.
Supongamos, por ejemplo, que vamos a testear un proceso en el que se envían emails. Idealmente, habremos definido una interfaz de un Mailer y creado algún adaptador para que no tener una dependencia directa de una librería específica como puede ser Swift Mailer.
Es decir, en producción estamos enviando mensajes de correo electrónico, pero en los entornos de tests no queremos que lleguen a enviarse. Existen varias formas de evitarlo, como puede ser configurar el mailer para que en el entorno de test no envíe realmente los correos, o los envíe a una dirección que tenemos ad hoc, o incluso usar algún servicio intermediario que retenga los mensajes enviados.
La otra opción es crear un FakeMailer
que implemente la interfaz Mailer
de tal manera que los correos nunca se llegan a enviar. El comportamiento no es el mismo, pero desde el punto de vista del proceso que utiliza el Mailer
, no hay ninguna diferencia: una vez que le entrega el mensaje la responsabilidad es del Mailer.
Un ejemplo de Fake
Una forma de comunicarse entre distintas aplicaciones es a través de colas. Las colas son relativamente fáciles de usar pues normalmente basta con poder poner mensajes o recoger el primero disponible. Nosotros utilizamos habitualmente el servicio de colas de Amazon Web Services pero es bastante obvio que en entorno de test no nos interesa enviar mensajes a una cola real, incluso aunque sea una cola específicamente para pruebas.
Por eso necesitamos suplantar el comportamiento de nuestras colas, cosa que podemos hacer con un fake similar al que muestro a continuación. En este caso, el fake, recoge el mensaje y lo guarda en un archivo temporal en el sistema de archivos, de modo que podríamos hacer tests de tipo end to end en los que un proceso pone mensajes en una cola para que los consuma otro,
Patrones y heurísticas para trabajar con dobles en los tests
2.1. Patrones de uso de dobles de test
Usar o no usar dobles
Usar las clases reales siempre que se pueda
El primer patrón de creación y uso de dobles de test es no usarlos. Es decir, en la medida de lo posible en los tests es preferible usar los objetos “reales” en lugar de doblarlos.
Sin embargo, hay muchas buenas razones para usar dobles. En situaciones de test nos interesa asegurar que el comportamiento que se ejecuta en la unidad bajo test es exactamente el que queremos verificar. Esto es, si probamos una unidad de software (ya sea una única clase aislada, ya sean varias coordinadas) queremos cerciorarnos de que sólo esa unidad, o ese conjunto, sea la responsable del resultado que obtenemos y que ese resultado no está afectado por elementos externos.
Por otro lado, los objetos reales pueden perjudicar aspectos como la velocidad o fortaleza de los test, introduciendo variables no deseadas que, incluso sin afectar al resultado devuelto, pueden hacerlos demasiado lentos o provocar fallos por razones ajenas al comportamiento que queremos testear. Esto perjudica la utilidad del test y del ciclo de feedback que nos puede proporcionar.
De ahí los distintos niveles de test: unitarios, de integración y de aceptación, que definen el ámbito de los tests delimitando sus fronteras.
Los dobles como fronteras
Los dobles están intrínsecamente ligados a las fronteras del nivel de test. De hecho, podríamos decir que las definen.
Por ejemplo, supongamos que deseamos testear un servicio que utiliza un repositorio para obtener entidades y realizar una operación con ellas, como calcular el importe de un carrito de la compra a partir de los productos que contiene. Este repositorio está implementado mediante un ORM y accede a una base de datos que reside en otra máquina.
En el nivel unitario los límites del test están en la unidad probada y normalmente la verificamos en aislamiento, por lo que sus colaboradores estarán en la frontera del ámbito del test y, por tanto, son candidatos a ser doblados. En nuestro ejemplo, la funcionalidad que aporta el servicio no es obtener los productos, sino realizar el cálculo del importe.
Obtener los productos es tarea del repositorio, el cual se convierte en la frontera del test en este nivel. Lo que haya más allá no nos importa, nos basta con que nos devuelva una colección representativa de productos con datos suficientes para permitir el cálculo. También nos puede interesar simular que ese repositorio está inaccesible o que alguno de los productos que solicitamos no está en el repositorio, de modo que podamos verificar cómo reacciona nuestro servicio bajo test en distintas circunstancias.
Para poder controlar eso, normalmente tendremos que doblar el repositorio con una o varias implementaciones alternativas que nos procuren los escenarios deseados.
En el nivel de integración, los límites del test vienen definidos por las unidades que trabajan juntas (integradas) en ese subsistema, por lo que otros subsistemas serían candidatos a ser doblados. En nuestro ejemplo, el repositorio es ahora parte del subsistema que estamos probando y reside dentro de sus fronteras.
Ahora queremos verificar que el servicio utiliza el repositorio correctamente y se entiende con él. Sin embargo, la base de datos “física” y su contenido estaría fuera del ámbito del test y necesitaríamos reemplazarla con una base de datos local o en memoria que nos permita un acceso más rápido y con datos controlados, tanto para no afectar a datos de producción como para, de nuevo, garantizar que podemos probar los escenarios que necesitamos.
En el nivel de aceptación, las fronteras están en los sistemas externos que no están bajo nuestro control. De hecho, accederemos al subsistema concreto que va a ser ejercitado en el test a través de otros componentes de la aplicación (interface web, api, consola, etc.), pues en este nivel los límites del sistema, sus puntos de entrada y salida, son los mismos límites del test. De ahí que también hablemos de tests end-to-end o de extremo a extremo.
Doblar lo que es costoso
Como hemos visto, una clase puede utilizar colaboradores que tienen condicionantes fuera de nuestro control o perjudiciales para elaborar tests rápidos y sólidos, como pueden ser: tener sus propias dependencias, tener un alto coste de ejecución, tener fallos no predecibles o que están sujetos a condiciones que no podemos controlar, como es el caso de sistemas de bases de datos, Apis o servicios web, sistemas remotos, adaptadores a microservicios, etc.
En esos casos, que además suelen ser difíciles de instanciar y necesitan configuración para funcionar, nos interesará doblar esos colaboradores para simular las diversas condiciones del servicio: que funciona correctamente, que esté caído, que responda con demasiada latencia o lentitud, etc.
En otros casos, los colaboradores estarán bajo nuestro control, no tendrán dependencias, serán rápidos, etc. En muchos casos se tratará de comportamientos de la propia clase bajo test extraídos para su reutilización. Entonces no sería necesario doblarlos.
Tampoco es necesario doblar aquellos objetos que utiliza nuestra unidad bajo test pero que podemos considerar como “objetos dato”. Es decir, objetos que nos interesan más por representar o transportar entidades o valores que por su comportamiento. En este grupo entran entidades, value objects, DTOs y otros objetos similares.
Tomemos por ejemplo el patrón Command de dos componentes. Este patrón consta de dos elementos: el Command, que aporta la información para la ejecución, y el CommandHandler, que aporta el comportamiento.
Si queremos testear el CommandHandler, no tenemos que doblar el Command. Sin embargo, seguramente tengamos que doblar las dependencias propias del CommandHandler.
Examinemos la situación:
En el test:
Pero también hay que tener en cuenta un factor pragmático. En ocasiones puede ser más complejo montar uno de estos objetos que generar su doble, especialmente si sólo nos vamos a fijar en unos pocos de sus métodos o propiedades, o incluso si no vamos a hacer ningún uso de ellos, porque en la unidad probada el objeto simplemente se va despachando entre diversos colaboradores, algo bastante habitual en UseCases.
En el código podemos ver que $book
no se usa dentro del CommandHandler, por lo que no sería problema usar un doble en lugar de instanciar un objeto real:
Así que, en esos casos, podemos permitirnos doblar objetos en lugar de instanciarlos en beneficio de la velocidad de desarrollo y la expresividad del test. En Test Driven Development, usar dobles nos permite ir descubriendo las interfaces que necesitamos, utilizándolos como placeholders mientras no nos detenemos a desarrollar las clases colaboradores.
Utilizar una librería de dobles
Existen diversas librerías con las que generar dobles para test que nos aportan algunas utilidades interesantes, así como una manera cómoda y rápida de obtener los que necesitamos en cada caso. phpunit integra una utilidad propia. También integra el framework Prophecy, que ofrece una alternativa interesante para generar dobles aunque está diseñada para trabajar mano a mano con phpspec en TDD y BDD.
Hay un oferta bastante amplia de otras librerías de dobles, pero nos centraremos en estas dos.
Creación básica de dobles
En este apartado veremos patrones básicos de creación de dobles.
La manera más sencilla de crear un doble de test en phpunit, sería utilizar el método createMock
de nuestro test case:
Si prefieres usar prophesize, el método es un poco más verboso:
La diferencia aquí es que prophesize
devuelve un objeto Prophet que realmente es un builder que nos permite configurar el doble, el cual obtenemos con el método reveal, una vez configurado.
Phpunit también nos permite crear un builder
para personalizar al máximo lo que necesitamos de él, pero la verdad es que createMock
nos servirá como atajo válido para la mayoría de los casos generando un doble que podremos usar sin más y al que le podremos configurar su comportamiento gracias a los métodos expects
y method
.
Esta diferencia supone un pequeño engorro a la hora de usar Prophecy frente al MockBuilder
nativo de PhpUnit y es que te obliga a instanciar la unidad bajo test en cada test, en lugar de poder hacerlo una única vez en el setup
, como veremos más adelante en detalle.
Finalmente, podemos crear dobles usando clases anónimos que extienden la clase que estamos doblando o implementan su interfaz.
Crea dobles a partir de interfaces, siempre que sea posible
Tenemos dos posibilidades dependiendo de que dispongamos o no de interfaces explícitas. Si hay una interfaz definida para el tipo de objeto que queremos doblar es preferible usarla:
De este modo nos centramos en la interfaz que nos interesa. Si, por ejemplo, alguna de las implementaciones que podríamos doblar está cumpliendo otras interfaces (o simplemente está violando el principio de sustitución de Liskov) nos evitamos tener que cargarnos de métodos extra (en aplicación del principio de segregación de interfaces).
La otra opción, es crear el doble a partir de una implementación.
Generando así los dobles no encontraremos grandes diferencias prácticas, por no decir ninguna, pero siempre que usamos interfaces garantizamos bajo acoplamiento lo que siempre es una ventaja de cara al futuro.
Creación con clases anónimas
De forma alternativa podríamos crear un doble instanciando una clase anónima. Es un método muy eficiente, aunque puede resultar trabajoso si las interfaces son complejas o si no disponemos de ellas. En esta modalidad, queda mucho más claro el beneficio que aportan las interfaces explícitas, incluso aunque sólo tengamos una implementación de producción.
En primer lugar, vamos a suponer que tenemos una interfaz:
En ese caso, podemos instanciar el doble así:
Ahora, imaginemos que no tenemos una interfaz explícita, sino que contamos sólo con una implementación.
La mejor solución sería extraer una interfaz y hacer que el doble la implemente, lo que nos libera de muchos problemas. También debes hacer que los consumidores dependan de la interfaz para que todo funcione.
Con esto no tenemos más que:
Lo anterior viene siendo la aplicación de la estructura ports and adapters también a los elementos que usamos en los tests.
Ahora bien, si por alguna razón no queremos o no podemos extraer la interfaz explícita, quizá porque estamos doblando una dependencia de terceros o por la razón que sea, crear el doble es posible, aunque un poco más costoso.
Imaginemos que tenemos que hacerlo con el mismo ejemplo de antes. En este caso, lo que haremos será extender la clase:
Vamos a fijarnos en varios detalles que, además, nos ayudarán a entender cómo funcionan los dobles de test:
El primer detalle es que tenemos que sobreescribir el constructor para evitar las dependencias que pueda tener la clase CalculateFee
. En el ámbito del test, el doble ha de ser un cascarón vacío, sin comportamiento y sin dependencias innecesarias. De este modo, instanciar un doble tiene que ser sencillo e inmediato.
El segundo detalle es que sobreescribimos los métodos en los que estamos interesados en el test para que no puedan realizar su comportamiento estándar. De nuevo la idea del cascarón vacío.
Para asegurar la intercambiabilidad, necesitamos reproducir la signatura de los métodos. Eso es algo a lo que estaríamos obligados en caso de usar una interfaz explícita, pero al hacerlo extendiendo una implementación deberíamos respetarlo aunque el lenguaje pueda permitirnos no hacerlo.
El return type nos obliga a devolver un valor y simplemente devolvemos un valor cualquiera compatible.
Crear dummies
Los dummies son dobles que no tienen comportamiento. Por definición, sus métodos devolverán null
. Se suele decir que se utilizan para poder cumplir una interfaz.
Crearemos un dummy
cuando necesitemos un doble de un colaborador de nuestra clase del cual no nos interesa que llegue a realizar su comportamiento en un determinado test.
Para hacerlo nos basta con utilizar el método createMock
de nuestro test case:
Con prophecy:
De forma alternativa podríamos crear un dummy mediante una clase anónima, extendiendo el colaborador y sobreescribiendo sus métodos para evitar que se ejecuten (cuando no tenemos interfaz explícita):
Lo más llamativo de este ejemplo es que el método generate
, que debería devolver un string por return type lanzará una excepción en caso de que lleguemos a utilizarlo, cosa que no es nuestra intención en este momento.
Veamos ahora algunos patrones de uso:
Instanciación de la unidad bajo test
Cuando necesitamos simplemente instanciar la unidad bajo test tenemos que pasarle los colaboradores adecuados los cuales, normalmente, no se utilizarán en el constructor más que para ser asignados a variables de clase.
En el ejemplo a continuación podemos ver como en el método setUp creamos dummies de los colaboradores, los cuales convertimos en stubs en el test al configurarles comportamientos.
Cuando testeamos servicios, usecases y otros objetos que no tienen estadoes una buena idea instanciarlos en el setup, de modo que nos evitemos repetir el proceso en cada test. Dentro de cada test configuramos el comportamiento que nos interesa para ese escenario concreto, lo que nos permite un test menos farragoso y más claro.
Esto es algo que podemos hacer fácilmente con el mockBuilder
de phpunit.
Con prophesize tenemos que usar otro enfoque un poco más incómodo, teniendo que instanciar la unidad bajo test en cada test.
Instancias de parámetros
Se suele decir que los dummies se crean para poder cumplir una interfaz de la unidad bajo test. Es decir, para tener un objeto que podamos pasar como parámetro al método testeado respetando el type hinting, aunque no tengamos que llamarlo directamente.
En muchos use cases las entidades y valores pasados como parámetros no son usados nunca directamente por el código bajo test, sino que este código coordina la actuación de los colaboradores pasándole estas entidades o valores tal cual. Por eso, en este tipo de situaciones un dummy es el objeto adecuado.
En este test, que ya hemos visto más arriba, Book
se utiliza como dummy:
Crear Stubs
Los stubs son dobles que tienen un comportamiento fijo, devolviendo una respuesta predeterminada cada vez que se les llama. Los stubs se usan para simular de forma controlada el comportamiento de los colaboradores de un objeto.
La idea es generar distintos escenarios a los que la unidad bajo test debe poder reaccionar y verificar, por tanto, que lo maneja de la forma correcta.
Stubs optimistas
Algunos de estos comportamientos son optimistas, es decir, el doble simula que la respuesta del colaborador es de un tipo esperable y manejable por la unidad bajo test. En el ejemplo anterior, tanto $bookPrinter
como $bookRepostory
son configurados como stubs simplemente indicando qué deben devolver cuando son llamados algunos de sus métodos:
El mismo patrón con prophecy queda así (recuerda que debes llamar a reveal para obtener el doble que tienes que pasar como dependencia):
Podemos ver que un aspecto ventajoso de prophecy es que llamas al mismo método que pretendes stubbear
, siendo en este aspecto más fácil de escribir y leer.
Con una clase anónima también podemos crear stubs usando varios patrones. Este sería quizá el más sencillo:
En ocasiones nos puede convenir poder configurar de algún modo el stub para poder reutilizarlo sin estar condicionados por una respuesta prefijada única o simplemente porque la respuesta puede tener cierta complejidad y preferimos prepararla fuera. Esto lo podemos hacer pasando las respuestas deseadas mediante el constructor:
A medida que aumentan las posibles respuestas a configurar o la cantidad de lógica que tendríamos que introducir en estos dobles generados mediante clases anónimas, se ve más clara la conveniencia de utilizar librerías de dobles, que nos puede ahorrar bastante esfuerzo.
Llamadas múltiples a un colaborador
Normalmente los dobles serán idempotentes, es decir, siempre que sean llamados devolverán exactamente la misma respuesta. Por tanto, si no especificamos otra cosa, cuando el mismo método de un doble recibe llamadas repetidas del código bajo test, realizará el mismo comportamiento.
Respuesta del colaborador en función de los parámetros
En ocasiones es casi inevitable llegar a una situación en la que tenemos que disponer de una mínima lógica en el colaborador doblado que devuelva una respuesta según el parámetro recibido.
Testear un cálculo interno con stubs
En los dos ejemplos anteriores no se establece una expectativa sobre el argumento exacto que se pasará a éstos métodos. Sin embargo, es posible especificarlo y asegurarnos así de que los colaboradores son llamados con los parámetros deseados, lo que es útil cuando esos parámetros han sido calculados en el código de la unidad bajo test y queremos asegurarnos de que se han generado correctamente:
Con mock builder:
Con prophecy:
Stubs pesimistas: el colaborador lanza una excepción
Otros comportamientos son pesimistas simulando condiciones como excepciones o diversos tipos de fallos o incluso respuestas inconsistentes de los colaboradores:
Con prophecy:
Con una clase anónima no tenemos más que hacer que el stub lance una excepción incondicional:
Mocks: Verificar que un colaborador ha sido llamado
Cuando queremos verificar que un colaborador ha sido llamado por nuestro código bajo test tenemos la tentación de crear un mock o un spy, es decir, un doble que registra el modo en que ha sido utilizado.
Para testear esto de manera explícita podemos hacerlo mediante este patrón en phpunit:
self::once() representa que se espera que sea llamado una vez y existen constraints
para otras necesidades que puedes consultar en la documentación.
En prophecy, se puede expresar de esta forma:
Sin embargo, no deberías abusar de estas expectativas. Establécelas tan solo cuando una llamada a un colaborador sea el efecto que esperamos que provoque el código.
Por ejemplo, en este test no hay ninguna necesidad de esperar las llamadas ya que está implícito que el resultado devuelto por el handler
se obtiene porque esas llamadas se ejecutan realmente:
La peor consecuencia de hacer este tipo de test es que acabas testeando la implementación de la unidad bajo test, no su comportamiento, y el test fallará cuando quieras refactorizarlo.
Si la unidad bajo test no devuelve una respuesta tendremos que fijar una expectativa sobre los efectos que esperamos que cause su ejecución:
Crear el doble de una clase que no existe
Si haces Test Driven Development es frecuente encontrarte con que a medida que desarrollas vas descubriendo la necesidad de disponer de colaboradores para la unidad concreta en la que estás trabajando pero, a la vez, no tienes una idea clara de cual debería ser su interfaz, pero tampoco quieres apartar el foco de la parte que estás escribiendo.
Así puedes crear un doble sin tener que salir del test:
Por ejemplo:
Esta técnica, sin embargo, no es posible con Prophecy, que necesita que exista la interfaz o la clase que deseas doblar.
Crear el doble de una clase que requiere constructor
En Prophecy, se procede de la forma habitual en este framework:
Testeando con dependencias no inyectadas
A veces nos podemos encontrar con la siguiente situación, relativamente frecuente en algunos frameworks o en bases de código que utilizan mucho la herencia: queremos testear una clase que extiende de otra, la cual instancia en su interior una dependencia en lugar de serle inyectada.
El problema viene cuando esa dependencia necesita ser doblada, como en este ejemplo en el que tenemos un cliente que hace llamadas a una API.
El primer paso sería intentar aislar la instanciación de la dependencia en un método de la clase en la que corresponda. No siempre tendremos acceso a ella para modificarla o no siempre será posible aislarla limpiamente.
En phpunit puedes hacer algo que suena contraintuitivo, doblando la clase bajo test pero diciendo que doble el método que instancia la dependencia. En este caso, la librería Guzzle proporciona un MockHandler
para poder simular llamadas fácilmente.
Con clases anónimas, puedes hacer algo como esto. Se trata de extender la clase bajo test y sobreescribir el método que instancia la dependencia.
Prophecy está diseñado de modo que prohibe específicamente este tipo de arreglos para favorecer que siempre se inyecten las dependencias. Por eso, no siempre resulta fácil utilizarlo cuando trabajamos con legacy o código basado en ciertos frameworks.
2.2. Anti-patrones
Llamamos antipatrones a ciertas formas de resolver un problema que aunque funcionan resultan ser ineficientes o incluso perjudiciales a la larga para nuestros objetivos.
Antes de empezar, repasemos los tests doubles. Podemos clasificarlos en base a dos dimensiones:
- Por su grado de implementación de comportamiento.
- Por el conocimiento que tienen de cómo son usados por la unidad bajo test.
Por comportamiento
- Dummies: no tienen comportamiento. Sus métodos siempre devuelven null.
- Stubs: tienen un comportamiento prefijado.
- Fakes: implementaciones alternativas para situaciones de test.
Por conocimiento
- Passives: no recogen ninguna información.
- Spies: recogen información sobre cómo son usados, la cual se puede consultar para hacer aserciones sobre ellas.
- Mocks: tienen expectativas sobre cómo son usados, haciendo fallar el test si no se cumplen.
Dobles sabihondos (smart-ass doubles)
Cuando testeamos una unidad de software, utilizamos dobles para controlar el comportamiento de los colaboradores, a los cuales no estamos testeando.
Pero en ocasiones se pueden observar dobles que intentan comportarse como objetos de negocio reales.
Es más fácil de entender con un ejemplo. Supongamos que estamos testeando un use case para actualizar un contrato. El use case utiliza un servicio colaborador con el que comprueba si el producto se comercializa actualmente y, en caso de que no, lo reemplaza por la versión equivalente que sí esté disponible.
El servicio debe ser doblado para hacer el test unitario, pero no tenemos que hacer que este mock “sepa” qué productos están descatalogados y cómo hacer la conversión. Dicho de otro modo: no necesitamos programar en el mock ninguna lógica que haga esa conversión.
En nuestro test sólo necesitamos simular que se ha solicitado un producto y se obtiene otro. Lo que estamos testeando es la capacidad del Use Case para devolver el producto actualizado.
Este fragmento de test intenta construir un doble de ProductRepository
que “sepa” que si pasamos un id que corresponde a un producto descatalogado “de verdad”, devuelve un nuevo producto. En este caso se hace poniendo como condición que se pasa un valor determinado como parámetro al método byId
.
Pero no hace falta, a efectos del test unitario eso es completamente transparente:
El test del comportamiento de actualización (que un producto X es sustituido exactamente por un producto Y que es su sucesor según la definición del negocio) corresponde en el nivel unitario al servicio que hace esa función y esa situación merece su propio test en el nivel de aceptación.
Este antipatrón indicaría que se están intentando testear en el nivel unitario responsabilidades que corresponderían al test del colaborador mockeado, o que se deberían testear a nivel de aceptación.
Demasiadas expectativas
Normalmente, tener demasiadas expectativas sobre algo es malo porque es fácil que el resultado final sea decepcionante. Pues bien: en el mundo del testing ocurre lo mismo.
Tenemos dos situaciones:
Cuanto testeamos una unidad de software que devuelve una respuesta con la ayuda de colaboradores no necesitamos hacer expectativas sobre esos colaboradores. Nos basta con hacer stubs de las distintas formas posibles de su comportamiento.
Lo que estamos probando es cómo reacciona la unidad bajo test a esos posibles comportamientos de los colaboradores que nosotros definimos en el contexto de cada test. Por ejemplo: el colaborador devuelve un resultado, no devuelve nada o lanza una o varias excepciones.
Por su parte, cuando testeamos un comando, que no devuelve una respuesta pero provoca un efecto, en el nivel unitario la única forma que tenemos de testear es mediante un mock del efecto que esperamos. Por ejemplo, nuestra unidad de software puede recibir un DTO, construir una entidad a partir de los datos de ese objeto y guardarla en un repositorio. Este último paso sería justamente el efecto que queremos testear.
Este test tiene un exceso de expectativas:
No hace falta esperar el método isClientChange
porque para que ocurran lo que el test dice, tiene que haber ocurrido antes que se ha llamado a ese método y ha entregado el valor false
, pues un vistazo al código nos diría que si el valor es true
hay que realizar otras operaciones antes.
Tampoco es necesario verificar las llamadas para hacer transaccional esta operación en el test unitario. De hecho, no nos afecta para nada en este nivel ya que no se trata de un repositorio real. Es algo que comprobaríamos en un test de integración o de aceptación, en el que probaremos que si falla la operación que ocurre en createContractFromApplication
no se ejecutan otras acciones dependientes de esa.
De hecho, nos puede perjudicar si en algún momento decidimos que la transaccionalidad se gestiona de otra forma, por ejemplo mediante un Middleware de un CommandBus a través del cual se ejecute este UseCase. Lo único que hacen esos expects
es acoplar nuestro test a la implementación.
El hecho es que si la entidad garantiza por sí misma una construcción consistente1, no tenemos ninguna necesidad de mockear nada en el DTO. Tan solo entregar un DTO real adecuado para el test o hacer un stub del mismo si nos es más sencillo hacerlo así.
Lo que sí hacemos es establecer una expectativa sobre el hecho de que la entidad se guarde. O sea, que el método save
o store
del repositorio sea llamado. Si necesitamos más precisión podemos testear que sea llamado con una entidad determinada.
En ese caso, puede ser buena idea delegar la construcción de la entidad a partir del DTO a un servicio, de modo que podamos hacer un stub
del mismo y entregar la entidad que queremos, lo que nos garantizaría que la unidad de software tiene el comportamiento deseado.
No debemos hacer mocks de DTO, requests, etc. Es decir, no debemos establecer expectativas sobre cómo esos objetos son usados, pues no hacen más que acoplar el test y la implementación del SUT.
2.3. Heurísticas para testear con dobles
Let It Fail: una heurística para descubrir cómo hacer los dobles
Cómo descubrir los test dobles
Let It Fail es una heurística para construir test doubles.
Cuando vamos a testear una clase que utiliza colaboradores es fácil caer en la tentación de intentar diseñar sus dobles. Nuestra propuesta es no hacerlo, sino dejar que sea el propio test el que nos diga lo que necesitamos.
Set Up del TestCase En el setUp
inicializamos como dummies todos los colaboradores necesarios para instanciar nuestro Subject Under Test o SUT. Nuestro objetivo es que se pueda instanciar y nada más, no vamos a hacer stubs de ningún comportamiento, ni mucho menos mocks.
Primer test A continuación buscamos el resultado posible de ejecutar el SUT más inmediato que podamos conseguir y que suele ser una excepción o un retorno precoz.
Lo primero es preparar los parámetros que necesita el método que vamos a ejercitar en el test. Si estos parámetros son objetos, podemos usar el objeto reales o dobles.
Muchas veces es mejor empezar también con dummies. Si los objetos que pasamos son complejos o no tenemos claro qué datos concretos necesitan es mucho más cómodo. En algunos casos, ese objeto sólo se va a ir moviendo de colaborador en colaborador, así que eso que salimos ganando.
Una vez que el escenario está listo, ejecutamos el test y observamos lo que ocurre. Aquí tenemos un ejemplo y, como vemos, ni siquiera es necesario hacer que el doble de RegistrationDto
devuelva datos.
Si lanza un error o una excepción puede tratarse de dos cosas:
- Una excepción propia del comportamiento bajo test, en cuyo caso lo que hacemos es testear que se lanza la excepción en esas condiciones. Por ejemplo, hemos pasado un parámetro inválido, cosa detectada en una cláusula de guarda la cual lanza una excepción. Así que ese es un comportamiento esperable del SUT y nuestro test verifica que si pasamos un parámetro no válido se lanzará la excepción y eso es lo que hacemos.
-
Una excepción o error debidos a que no estamos pasando datos en los parámetros. Por ejemplo, hemos pasado un objeto dummy que sólo devuelve
null
y que debería permitir al SUT obtener un id con el que buscar en un repositorio. Pero como sólo devuelve null no se satisface el type hinting del colaborador que espera un objetoUuid
, por ejemplo. En este caso, arreglamos lo que sea necesario para que ese parámetro tenga un valor adecuado, bien sea personalizando el objeto, bien sea fijando stubs a través de su doble.
En otras ocasiones puede ocurrir que necesitemos simular el comportamiento de uno de los colaboradores que pasamos al principio. Por ejemplo, un repositorio puede tener que devolver un objeto en una llamada getById
, o un conjunto de ellos en un un findBy
, o bien lanzar una excepción porque no se encuentra lo buscado. Lo mismo se puede aplicar a cualquier servicio.
Aquí tenemos un ejemplo de un test en el que sólo hacemos stub de uno de los getters del DTO (que en total tiene unos treinta):
To Mock or not to Mock? Una heurística para determinar qué colaboradores necesitan stubs
Si el SUT es un Query
Si el subject under test devuelve una respuesta, siempre testearemos sobre la respuesta, por lo que únicamente haremos dummies o stubs en los colaboradores doblados.
Haremos stub
de los métodos de los dobles que el test nos vaya pidiendo. Por ejemplo, si un repo tiene que devolver una entidad para que otro servicio la reciba y haga algo con ella, haremos un doble de la entidad, hacemos el stub de la respuesta del repo y ya está.
En ese caso no ponemos ningún expects
en los dobles. Si el test pasa correctamente, es que se han realizado las llamadas necesarias. Si el test no pasa, indicaría que no se está moviendo la información de la manera esperada.
Si el SUT es un Command
¿Qué podemos testear si el subject under test no devuelve una respuesta, sino que produce un efecto en el sistema?
En el nivel unitario no tenemos forma de observar un efecto real en el sistema, así que en este caso sí tendremos que recurrir a mocks.
¿Qué debemos mockear? Pues aquello que hayamos definido como efecto deseado del subject under test. Por ejemplo, si el efecto es crear y guardar una entidad en un repositorio, lo que haremos será definir que el repositorio espera que se llame el método save
con una entidad.
Podemos ser más precisos definiendo qué entidad exacta esperamos recibir (o qué características tiene), aunque hay que tener en cuenta que si se cumple lo siguiente, realmente no hay que llegar a tanta precisión:
- Si el método save tiene type hinting, fallará si no recibe una entidad.
- Si la entidad sólo se puede construir de manera consistente, el test fallará si pretendemos crearla con datos inconsistentes.
En cualquier caso, la construcción consistente de la entidad debería estar testeada en otra parte. Incluso, puede ser buena idea sacarla del SUT y llevarla a otro colaborador que sí podríamos testear.
Usar mocks para entender el legacy
Supón que haces un doble de un clase que tiene muchos métodos (frecuentemente alguna entidad o un objeto request que lleva muchos datos). ¿Necesitas hacer stubs de todos esos métodos? ¿Cuáles son realmente necesarios? ¿Tenemos que doblarlos todos?
Pues bien, basta con convertir los stubs en mocks, añadiendo expects
. Si al ejecutar el test falla la expectativa diciendo que aunque espera llamadas no se ha usando nunca, entonces puedes eliminar ese stub ya que nunca se llama, al menos en el contexto de ese test.
Si este código falla, puedes quitar el stub del método email
:
En cambio, si el expects
no falla, es que ese stub era necesario. Pero sí retiramos la expectativa para evitar el antipatrón.
También podemos controlar esto de una manera indirecta. Como decíamos antes, los dobles nacen como dummies, por lo que todos los métodos devuelven null
. Si el código bajo test tiene que usar la salida de alguno de esos métodos lo más seguro es que falle, bien sea por temas de type hinting, bien porque espera un tipo o una interfaz. Este fallo nos está indicando qué debería devolver el método, por lo que podremos hacer su stub.
Por cierto que eso nos llevará seguramente a encontrarnos con un montón de violaciones de la Ley de Demeter.
Lo que pasa en el doble se queda en el doble
Los dobles de tests se utilizan para simular el comportamiento de objetos colaboradores de aquel que estemos probando. Con frecuencia, debido a la naturaleza del comportamiento testeado no es posible hacer aserciones sobre la respuesta del subject under test o de sus efectos en el sistema. Por esa razón, establecemos expectativas sobre los dobles: esperamos que sean usados por nuestro SUT de cierta manera, lo que los convierte en mocks y son la base para que nuestro test nos aporte alguna información.
Un caso típico puede ser un test unitario de un servicio que utiliza un objeto Mailer para enviar un mensaje de correo electrónico. No podemos observar ese efecto para los objetivos del test, tan sólo podemos recoger su respuesta (el Mailer podría devolver un código que nos diga si el envío se ha realizado) o bien asegurarnos de que ha sido llamado por el SUT con los parámetros esperados:
Cualquier cosa que pueda pasar dentro del método send
de ese mailer es completamente prescindible para nosotros. En el test, lo que verificamos es que nuestro código está usándolo con un objeto mensaje, confiando en que el Mailer real hace su trabajo, bien porque es una biblioteca de terceros, ya porque hemos demostrado que funciona mediante sus propios tests.
A nivel de tests unitarios normalmente no es necesario crear Fakes que tengan comportamiento real, o asimilable a real. En consecuencia no necesitamos doblar ni sus dependencias ni los objetos que use internamente. Debemos ver esa dependencia de nuestro SUT como una caja negra con una interfaz pública de la que podemos esperar ciertos comportamientos, representados por outputs que nos interesa probar. Lo único que tenemos que hacer es simular esos outputs, incluyendo lanzar excepciones, para verificar que nuestro código bajo test reacciona adecuadamente.
Aplicación al testeo de código desconocido
Recientemente he comenzado a trabajar en otro proyecto dentro de la empresa ayudando con el testing. Es una parte del negocio que conozco poco todavía y la aplicación con la que estoy trabajando es, por supuesto, distinta, aunque similar en planteamiento.
En esta ocasión voy a hablar sobre cómo estoy afrontando el testeo de un código que apenas conozco.
Como primer ejemplo, voy a tomar un caso bastante simple, pero que me permitirá ilustrar el enfoque con el que estoy atacando la tarea.
Se trata de un Middleware para MessageBus. No es un concepto de negocio, pero me viene muy bien porque sólo tiene un flujo de ejecución y, además, me permite poner en juego un par de técnicas interesantes.
Esta es la clase que vamos a testear:
Lo primero que hago es preparar el TestCase con los dobles de las dependencias y el propio subject under test. Lo defino todo en el setUp
mirando cómo es el constructor de la clase que voy a testear.
Me aseguro de que las dependencias son exactamente las mismas comparando los use
y verificando que se importan de los mismos namespaces.
El siguiente paso es escribir un test que verifique algún aspecto del flujo que nos interese.
La mayor parte de las veces prefiero empezar testeando sad paths, por ejemplo, cuando se pueden lanzar excepciones o se devuelve una respuesta vacía. Habitualmente es bastante sencillo montar estos tests y programar los stubs.
En este caso, no se da ninguna de esas circunstancias, así que tendremos que buscar por otro lado, analizando el flujo y decidiendo qué es lo que mejor describe el comportamiento de la clase.
Nosotros tenemos dos comportamientos importantes:
- Que, efectivamente, al recibir un evento se ejecuta el
logger
. - Que se ejecuta el
Callable $next
, que sería el siguiente paso en el pipeline delMessageBus
y se le pasa el evento recibido.
Ciertamente podemos determinar qué pasa leyendo el código, pero con el test lo vamos a certificar. Podría decirse que vamos a elaborar una hipótesis sobre lo que hace el código y el test nos va a servir para verificarla.
Lo primero que vamos a intentar demostrar es que el evento se añade al log. La forma concreta en que se anota no es lo que nos interesa ahora, simplemente nos basta con asegurarnos de que se hace.
La clave está en la línea:
Convertimos a $this->logger
en un mock definiendo una expectativa de cómo queremos que sea usado. Estamos diciendo que esperamos que el método info
sea llamado una sola vez. Esa es la aserción del test.
Es un test frágil porque si decidiésemos cambiar el nivel de logging, por ejemplo a notice
o a warning
, el test fallaría aunque la clase esté funcionando bien. La forma de evitar esto es cambiar el test primero para definir el nuevo comportamiento y modificar el código de producción después.
Por otro lado, los mocks
se llevan la aserción fuera del test lo que no me gusta demasiado. Una alternativa sería crear un Logger
específico para tests, al que le pudiésemos preguntar a posteriori qué mensajes ha registrado y, así, realizar las aserciones de la manera habitual en el test.
Sin embargo, no siempre merece la pena hacer este esfuerzo extra y los dobles nos aportan otras ventajas. En cada caso hay que valorar qué nos viene mejor.
Por otro lado, como veremos en otro momento, los dobles nos permiten trabajar más fácilmente con clases desconocidas e ir descubriendo sus métodos o el tipo de datos que manejan a medida que los necesitamos. En este ejemplo no lo vamos a ver, pero intentaré mostrar más adelante casos en los que evitamos tener que montar objetos complejos para los tests gracias a los dobles.
Dobles con clases anónimas
Para crear un DomainEvent de prueba utilizaré una clase anónima.
PHP no permite implementar la interfaz DateTimeInterface
, por lo que el generador de doubles de phpunit no puede hacer un double de nuestro DomainEvent
pues éste devuelve un objeto DateTimeInterface en el método occurredOn
y no puede doblarlo.
En todo caso, el test pasa, demostrando que el MiddleWare realiza su función principal, que es anotar en el log que el evento ha sido recibido.
Dobla Callables
mediante self-shunt
El siguiente paso que necesito demostrar es que se ejecuta el Callable $next
. Esto es necesario para garantizar que el evento sigue su camino en el MessageBus.
La cuestión es: ¿cómo verificar esto?
Lo que se me ha ocurrido es una especie de self-shunt. Es decir, crearé un método en el TestCase que pasaré al MiddleWare como Callable. Este método simplemente incrementará un contador de modo que pueda verificar que ha sido llamado al comprobar si tiene un valor mayor que cero.
El TestCase queda así:
El test pasa, verificando que se ejecuta el Callable.
Sin embargo, nos queda por verificar que recibe el evento o mensaje, por lo que necesitamos añadir alguna forma de verificar que el mensaje es pasado.
En principio, bastaría con que registerCall
nos obligue a pasarle un parámetro, incluso aunque no lo use. Nuestro objetivo es verificar que se pasa, lo que haga el Callable no es un objetivo de este test.
Pero para estar más seguros, vamos a comprobar que es el mismo mensaje que se pasa al Middleware. También cambio el nombre del test para reflejarlo:
Y, con esto, el comportamiento de la clase ha sido caracterizado. De este modo, además de aumentar la cobertura de tests de la aplicación, he podido aprender cómo funciona esta clase.
2.4. Testing expresivo
Desde hace un tiempo estoy explorando una forma de organizar el código de mis tests, al menos de una parte de ellos. A falta de un nombre mejor he decidido llamar a esta organización testing expresivo.
Todo empezó por un twit que desgraciadamente olvidé guardar, pero al que no dejé de darle vueltas tras leerlo.
La idea es la siguiente: como bien sabemos, los tests se estructuran en tres partes principales:
- Given o Arrange: donde definimos el escenario y las condiciones de la prueba.
- When o Act: donde ejecutamos la unidad de software bajo test.
- Then o Assert: donde comparamos el resultado esperado con el que obtenemos.
Cuando escribimos un test, normalmente usaremos esta estructura de manera implícita:
Si añadimos unos comentarios para marcarla nos podría quedar una cosa así:
Pero como ya ssabemos, muchos comentarios pueden eliminarse si hacemos explícito en el código lo que éste hace, algo que normalmente podemos conseguir extrayendo bloques de código a métodos privados con nombres expresivos.
Veamos ambos tests juntos:
vs.
¿Qué te parece? En este ejemplo, el código del test original es bastante sencillo y bastante fácil de interpretar. Sin embargo, yo diría que su versión expresiva es imbatible en cuanto a legibilidad.
Tanto es así, que podrías enseñarle el código un una persona que no sea desarrolladora y entendería lo que se intenta probar con el test.
Esto me suena de algo, ¿no?
Efectivamente. Esta forma de nombrar los métodos se parece muchísimo a la manera en que escribiríamos escenarios en lenguaje Gherkin al hacer BDD. ¿No debería estar usando las herramientas de BDD en su lugar?
Creo que no necesariamente. En este ejemplo he mostrado un test unitario, que no es algo propio de BDD. Aunque el ejemplo es muy simple, no es difícil imaginar casos en los que preparar el escenario del test se hace complicado, en especial cuando tenemos que hacer dobles y stubs de colaboradores del subject under test.
Por ejemplo, imagina lo que puede haber dentro de este test:
Este test está en el nivel End to End de una API, así que por debajo prepara un entorno en el que se simula un usuario conectado al sistema, una llamada a un API con una muestra de datos (en este caso, incompletos o inválidos), así como las aserciones sobre la respuesta. Todo ese código técnico metido dentro del test haría éste muy difícil de leer y seguir.
En cambio, ocultando la complejidad técnica del test bajo métodos cuyo nombre explica lo que pasa a un nivel mayor de abstracción nos permite lograr un test razonablemente expresivo.
Hay algunas ventajas más ya que muchas veces estos métodos son fácilmente reutilizables, lo que nos permite crear una especie de lenguaje dentro del propio test.
2.5. La performance de los métodos para crear test doubles
Hace algunos meses estuve haciendo análisis sobre la performance de los diversos métodos para crear test doubles y cómo impactan en nuestras suites de tests. Ahora lo he retomado con mejores herramientas.
La suite de tests de Holaluz ha crecido mucho y me preocupa un poco cómo compaginar su crecimiento en número de tests y cobertura, manteniendo un tiempo de ejecución razonable.
Una suite de test que tarda mucho en ejecutarse pierde utilidad porque el feedback no llega tan rápido como se desea y comienza a verse como un estorbo más que una herramienta necesaria en el trabajo.
Velocidad de los tests según la metodología para crear dobles
Son muchos los factores que influyen en el tiempo de ejecución de una suite de tests y, de momento, voy a centrarme en un aspecto concreto: los métodos para generar test doubles. En varios artículos del blog hemos hablado de ellos. Fundamentalmente me interesaba comprar los dobles generados con librerías de mocking frente a usar las clases originales o instanciar clases anónimas como dobles.
Después de unos primeros análisis bastante simples, los resultados fueron un tanto inesperados ya que el método más eficiente resultó ser la creación de dobles nativos de phpunit (los que se obtienen con createMock
), con bastante diferencia sobre usar las clases originales y sobre los dobles usando la librería prophecy, integrada también en phpunit.
Un ejemplo de los resultados obtenidos fue este:
Method | Time (ms) | Memory (MB) |
---|---|---|
testMockBuilderExample | 88 | 10.00 |
testProphecyExample | 206 | 26.00 |
testRealObjectExample | 269 | 26.00 |
testAnonymousClassExample | 287 | 26.00 |
Para alguien bastante fan de prophecy fueron resultados sorprendentes y un tanto decepcionantes. Particularmente me sorprendió que la clase original fuese más lenta que que alguno de los frameworks. En principio, eso me hizo sospechar que la metodología no era suficientemente buena.
Sin embargo, reescribí algunos tests que eran particularmente lentos en mi entorno para ver qué efecto tendría cambiar el framework de dobles:
TestCase | Prophecy (ms) | Phpunit (ms) |
---|---|---|
TestCase 1 | 1652 | 377 |
TestCase 2 | 35774 | 860 |
TestCase 3 | 74899 | 11682 |
La mejora es muy grande, y en algunos casos escandalosamente grande, por lo que empezamos a escribir los tests con los dobles nativos, así como a migrarlos de un framework a otro cuando teníamos oportunidad. De hecho, en alguno de los TestCase reescritos pudimos añadir aún más tests y mantener una velocidad alta.
Un análisis más profundo
Con todo, no quedé muy convencido de este primer análisis. No me cuadraba del todo que crear dobles con una librería fuera más eficiente que las clases originales, por lo menos en casos de Value Objects y otros objetos sin comportamiento. Seguramente en objetos con comportamiento o cuya ejecución pudiese ser lenta, como repositorios, habría ganancias usando dobles, al menos a partir de un cierto tiempo de ejecución, pero no en objetos que residen en memoria y hacen muy poquitas cosas.
Finalmente, he tenido tiempo para preparar un entorno más robusto y flexible con el que poder comparar la velocidad de ejecución y consumo de memoria de un conjunto de TestCases arbitrario. Este entorno lo puedes examinar en github. No voy a entrar en detalles, que se pueden ver en el código, pero la idea es poder ejecutar repetidas veces varios TestCases y comparar el tiempo necesario para ejecutarlos, así como el consumo de recursos o, al menos, una aproximación. En principio, los tests para probar deben llamarse ‘test’, aunque es algo que espero cambiar en una próxima iteración.
https://github.com/franiglesias/tb-doubles
Por tanto, he podido reproducir mi análisis original, obteniendo nuevos resultados y de mejor calidad.
Clases simples
El primer ejemplo que probé es una clase simple que almacena un valor, una especie de value object, sin apenas comportamiento.
Las clases testeadas y los tests pueden verse en el repositorio.
Los resultados han sido estos, ejecutando los test 50 veces:
Para empezar, los resultados parecen más consistentes con lo esperado. La instanciación de la clase bajo test es el método más eficaz, mientras que la instanciación de una clase anónima que extiende de ella es marginalmente más lenta. En términos de ejecución, serían intercambiables.
A cierta distancia, pero no demasiada, los dobles nativos de phpunit son un poco más lentos. Sin embargo, ofrecen un buen compromiso entre el coste en performance y la complejidad que puede suponer en no pocas ocasiones instanciar la clase real, especialmente si la necesitamos como dummy o sólo se nos requiere hacer stub de uno o dos de sus métodos.
Finalmente, generar los dobles con prophecy parece ser el método más costoso en tiempo. Lo sorprendente es la gran diferencia con respecto a cualquiera de los otros métodos.
En general, para objetos que no tengan comportamiento, o este es muy simple, o que no tengan dependencias, utilizar la clase original de un colaborador de la unidad bajo test es el método más eficiente. Si se trata de una clase abstracta o incluso una interfaz, la clase anónima es también una buena alternativa.
Simulando comportamiento
Para simular comportamiento he puesto una clase bastante sencilla con un cálculo muy simple.
Los resultados están en la línea de los anteriores, siendo los métodos más eficientes los que usan la clase nativa como doble o una clase anónima extendiendo de aquella. Por su parte, el doble creado con prophecy sigue siendo el menos eficiente.
En este caso hemos añadido un test que usa como doble una clase anónima que sobreescribe el método bajo test. Como veremos más adelante, esto tiene un efecto visible.
Pero si el comportamiento es complejo o existen dependencias que provocan un tiempo de ejecución alto de la unidad bajo test encontramos unos resultados diferentes.
Para simular esto hemos añadido un sleep
de 1 segundo para simular un comportamiento complejo, que requiere mucho más tiempo. Imagina que la dependencia sea un repositorio que accede a una base de datos con una query relativamente pesada.
En estas condiciones la mejor forma de generar dobles es mediante clases anónimas sobreescribiendo los métodos implicados en el test para que no ejecuten el comportamiento original (la definición de stub).
En ese caso el stub basado en una clase anónima con los métodos sobreescritos vence a todos los demás métodos con gran claridad.
Los resultados, por lo demás, eran bastante predecibles y, en esta ocasión, los frameworks de creación de dobles ganan la partida a la clase original con gran diferencia, mientras que la instancia real de la clase requiere una cantidad desorbitada de tiempo para un test. Los dobles creados con algún framework no ejecutan el comportamiento lento con lo que se libran de la penalización de tiempo, y es por esa razón que los usamos.
Comparando los dos frameworks que hemos probado, se puede ver que prophecy es menos eficiente que el mock builder nativo de phpunit aunque no tanto como esperaba inicialmente (en la versión anterior del proyecto, había una diferencia bastante mayor).
Conclusiones
Aunque todavía quedan muchas mejoras para este entorno de análisis, creo que puede ser útil para optimizar las suites de test y obtener alguna información con la que fundamentar las decisiones que se tomen para ello.
De entrada, hay que destacar las ventajas de usar instancias de las clases nativas en lugar de dobles: conforman la opción más rápida y la que menos memoria consume. Esto aplica perfectamente para Value Objects, DTO, Events, Commands, objetos-parámetro y también Entidades.
Cuando necesitamos doblar clases abstractas o interfaces podemos usar fácilmente clases anónimas con prácticamente la misma eficiencia.
Si hay comportamiento y este es especialmente complejo o lento, como pueden ser las clases de acceso a bases de datos, servicios de terceros a través de apis, sistemas de archivos, etc. la mejor opción en eficiencia podrían ser las clases anónimas.
Sin embargo, la ventaja de las clases anónimas se diluye un poco ante la conveniencia de los frameworks de dobles, ya que no tenemos que preocuparnos por muchas cuestiones a la hora de doblar, como pueden ser sobreescribir el contructor para eliminar las dependencias o sobreescribir todos los métodos implicados en el test. Es decir, nos ahorran trabajo y la penalización en velocidad de ejecución puede compensarse con un esfuerzo de programación menor.
Además, hay que tener en cuenta que podemos tener que utilizar muchas variantes de la misma clase para definir los distintos escenarios de test, lo que suele resultar más cómodo usando un framework.
Por otro lado, cuando la complejidad de construir las clases nativas, o incluso su versión anónima, es alta resulta más conveniente utilizar los frameworks de test. En general, diría que para todo tipo de servicios, command handlers, etc, que tengan comportamientos complejos y dependencias lo mejor es utilizar un framework para obtener sus dobles.
Y en caso de utilizarlo, la opción más eficiente en el mock builder nativo de phpunit.
Notas
Desarrollar una cultura de testing
1porque tus entidades garantizan una construcción consistente, ¿verdad?↩
2Y tampoco al refactoring. Tanto el testing como el refactoring son parte de nuestro proceso como developers.↩
2.2. Anti-patrones
1porque tus entidades garantizan una construcción consistente, ¿verdad?↩