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.
1 public function testShouldUpdateProductIfDeprecated(): void
2 {
3 $newProduct = $this->createMock(Product::class);
4 $productRepository = $this->createMock(ProductRepositoryInterface::class);
5 $productRepository->method('byId')
6 ->willReturn($newProduct)
7 ->with('deprecated-product-id');
8 //...
9 }
Pero no hace falta, a efectos del test unitario eso es completamente transparente:
1 public function testShouldUpdateProductIfDeprecated(): void
2 {
3 $newProduct = $this->createMock(Product::class);
4 $productRepository = $this->createMock(ProductRepositoryInterface::class);
5 $productRepository->method('byId')
6 ->willReturn($newProduct)
7 //...
8 }
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:
1 public function testValidApplicationIdShouldSignAndCreateContract(): void
2 {
3 $application = $this->createMock(Application::class);
4 $application->expects($this->once())
5 ->method('isClientChange')
6 ->willReturn(false);
7 $this->applicationRepository
8 ->method('byIdOrFail')
9 ->willReturn($application);
10
11 $this->applicationRepository->expects($this->once())->method('startTransaction');
12 $this->createContractFromApplication->expects($this->once())->method('execute');
13 $this->applicationRepository->expects($this->once())->method('commitTransaction'\
14 );
15
16 $this->signContract->execute('1234');
17 }
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.
1 public function testValidApplicationIdShouldSignAndCreateContract(): void
2 {
3 $application = $this->createMock(Application::class);
4 $application
5 ->method('isClientChange')
6 ->willReturn(false);
7 $this->applicationRepository
8 ->method('byIdOrFail')
9 ->willReturn($application);
10
11 $this->createContractFromApplication->expects($this->once())->method('execute');
12
13 $this->signContract->execute('1234');
14 }
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.