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.

 1 protected function setUp()
 2 {
 3     $this->registrationRepository = $this->createMock(RegistrationRepositoryInterfac\
 4 e::class);
 5     $this->contractRepository = $this->createMock(ContractRepositoryInterface::class\
 6 );
 7     $this->walleSipsCommunication = $this->createMock(WalleSipsCommunicationInterfac\
 8 e::class);
 9     $this->municipalitiesCommunication = $this->createMock(MunicipalitiesCommunicati\
10 onInterface::class);
11     $this->clientCommunication = $this->createMock(ClientCommunicationInterface::cla\
12 ss);
13     $this->rateRepository = $this->createMock(RateRepositoryInterface::class);
14     $this->generateRegistrationFile = $this->createMock(GenerateRegistrationFile::cl\
15 ass);
16     $this->guessEnagasCode = $this->createMock(GuessEnagasCode::class);
17     $this->eventBus = $this->createMock(MessageBus::class);
18 
19     $this->createValidatedRegistration = new CreateValidatedRegistration(
20         $this->registrationRepository,
21         $this->contractRepository,
22         $this->walleSipsCommunication,
23         $this->municipalitiesCommunication,
24         $this->clientCommunication,
25         $this->rateRepository,
26         $this->generateRegistrationFile,
27         $this->guessEnagasCode,
28         $this->eventBus
29     );
30 }

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.

 1 public function testShouldNotGenerateRegistrationIfEmptyData(): void
 2 {
 3     /** @var RegistrationDto | MockObject $registrationDto */
 4     $registrationDto = $this->createMock(RegistrationDto::class);
 5     $this->generateRegistrationFile->expects(self::never())->method('execute');
 6 
 7     $result = $this->createValidatedRegistration->execute($registrationDto);
 8 
 9     $this->assertFalse($result->errors()->isEmpty());
10 }

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 objeto Uuid, 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):

 1 public function testShouldDetectInvalidPersonId(): void
 2 {
 3     /** @var RegistrationDto | MockObject $registrationDto */
 4     $registrationDto = $this->createMock(RegistrationDto::class);
 5     $registrationDto->method('personIdNumber')->willReturn('00000000F');
 6 
 7     $result = $this->createValidatedRegistration->execute($registrationDto);
 8 
 9     $this->assertNotNull($result->errors()->get('person_id'));
10 }

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?

 1 public function testSomething(): void
 2 {
 3     $request = $this->createMock(Request::class);
 4     
 5     // stub methods
 6     $request->method('id')->willReturn('some-id');
 7     $request->method('name')->willReturn('some-name');
 8     $request->method('address')->willReturn('some-address');
 9     $request->method('email')->willReturn('some-email');
10     $request->method('data')->willReturn('some-data');
11 }

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.

1     $request = $this->createMock(Request::class);
2     
3     // stub methods
4     $request->method('id')->willReturn('some-id');
5     $request->method('name')->willReturn('some-name');
6     $request->method('address')->willReturn('some-address');
7     $request->expects($this->once())
8         ->method('email')->willReturn('some-email');
9     $request->method('data')->willReturn('some-data');

Si este código falla, puedes quitar el stub del método email:

1     $request = $this->createMock(Request::class);
2     
3     // stub methods
4     $request->method('id')->willReturn('some-id');
5     $request->method('name')->willReturn('some-name');
6     $request->method('address')->willReturn('some-address');
7     $request->method('data')->willReturn('some-data');

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:

 1 public function testShouldSendAnEmail ()
 2 {
 3     $message = new Message();
 4     $message->to('person@example.com');
 5     $message->body('notification for you');
 6 
 7     $mailer = $this->createMock(Mailer::class);
 8     $mailer->expects($this->once())->method('send')->with($message);    
 9     
10     $service = new SendNotification($mailer);
11     $service->notify($message);
12 }

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:

 1 <?php
 2 
 3 namespace Project\App\Application\Common;
 4 
 5 use Exception;
 6 use Project\App\Domain\Common\Event\DomainEvent;
 7 use Psr\Log\LoggerInterface;
 8 use SimpleBus\Message\Bus\Middleware\MessageBusMiddleware;
 9 
10 class EventLoggerMiddleware implements MessageBusMiddleware
11 {
12     private const DATE_FORMAT = 'Y/m/d H:i:s';
13 
14     /** @var LoggerInterface */
15     private $logger;
16 
17     /**
18      * LoggerDomainEventSubscriber constructor.
19      * @param LoggerInterface $logger
20      */
21     public function __construct(
22         LoggerInterface $logger
23     ) {
24         $this->logger = $logger;
25     }
26 
27     /**
28      * @param          $message
29      * @param callable $next
30      * @return void
31      * @throws Exception
32      */
33     public function handle($message, callable $next): void
34     {
35         $this->logEvent($message);
36         $next($message);
37     }
38 
39     /**
40      * @param $event
41      * @throws Exception
42      */
43     private function logEvent($event): void
44     {
45         $this->logger->info(
46             'Event dispatched',
47             [
48                 'event' => [
49                     'name' => get_class($event),
50                     'data' => json_encode($event),
51                     'occurred_on' => ($event instanceof DomainEvent)
52                         ? $event->occurredOn()->format(self::DATE_FORMAT)
53                         : (new \DateTime())->format(self::DATE_FORMAT),
54                     'string' => ($event instanceof DomainEvent) ? $event->__toString\
55 () : ''
56                 ]
57             ]
58         );
59     }
60 }

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.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Project\App\Application\Common;
 5 
 6 use PHPUnit\Framework\MockObject\MockObject;
 7 use PHPUnit\Framework\TestCase;
 8 use Psr\Log\LoggerInterface;
 9 
10 class EventLoggerMiddlewareTest extends TestCase
11 {
12     /** @var LoggerInterface | MockObject */
13     private $logger;
14 
15     /** @var EventLoggerMiddleware */
16     private $eventLoggerMiddleware;
17 
18     public function setUp()
19     {
20         $this->logger = $this->createMock(LoggerInterface::class);
21         
22         $this->eventLoggerMiddleware = new EventLoggerMiddleware($this->logger);
23     }
24 }

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 del MessageBus 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.

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Project\App\Application\Common;
 5 
 6 use DateTimeInterface;
 7 use Project\App\Domain\Common\Event\DomainEvent;
 8 use PHPUnit\Framework\MockObject\MockObject;
 9 use PHPUnit\Framework\TestCase;
10 use Psr\Log\LoggerInterface;
11 
12 class EventLoggerMiddlewareTest extends TestCase
13 {
14     /** @var LoggerInterface | MockObject */
15     private $logger;
16 
17     /** @var EventLoggerMiddleware */
18     private $eventLoggerMiddleware;
19 
20     public function setUp()
21     {
22         $this->logger = $this->createMock(LoggerInterface::class);
23 
24         $this->eventLoggerMiddleware = new EventLoggerMiddleware($this->logger);
25     }
26 
27     public function testShouldLogTheEvent(): void
28     {
29         $event = $this->createDomainEvent();
30 
31         $this->logger->expects($this->once())->method('info');
32 
33         $this->eventLoggerMiddleware->handle($event, function() {});
34     }
35 
36     private function createDomainEvent()
37     {
38         $event = new class() implements DomainEvent
39         {
40 
41             public function occurredOn(): DateTimeInterface
42             {
43                 return new \DateTimeImmutable();
44             }
45 
46             public function __toString(): string
47             {
48                 return 'Domain Event';
49             }
50         };
51 
52         return $event;
53     }
54 }

La clave está en la línea:

1 $this->logger->expects($this->once())->method('info');

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.

 1 <?php
 2 namespace Project\App\Domain\Common\Event;
 3 
 4 use DateTimeInterface;
 5 
 6 interface DomainEvent
 7 {
 8     public function occurredOn(): DateTimeInterface;
 9     public function __toString(): string;
10 }

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

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Project\App\Application\Common;
 5 
 6 use DateTimeInterface;
 7 use Project\App\Domain\Common\Event\DomainEvent;
 8 use PHPUnit\Framework\MockObject\MockObject;
 9 use PHPUnit\Framework\TestCase;
10 use Psr\Log\LoggerInterface;
11 
12 class EventLoggerMiddlewareTest extends TestCase
13 {
14     /** @var LoggerInterface | MockObject */
15     private $logger;
16 
17     /** @var EventLoggerMiddleware */
18     private $eventLoggerMiddleware;
19     /** @var int */
20     private $calls;
21 
22     public function setUp()
23     {
24         $this->calls = 0;
25 
26         $this->logger = $this->createMock(LoggerInterface::class);
27 
28         $this->eventLoggerMiddleware = new EventLoggerMiddleware($this->logger);
29     }
30 
31     public function testShouldLogTheEvent(): void
32     {
33         $event = $this->createDomainEvent();
34 
35         $this->logger->expects($this->once())->method('info');
36 
37         $this->eventLoggerMiddleware->handle($event, function() {});
38     }
39 
40     public function testShouldExecuteNext(): void
41     {
42         $event = $this->createDomainEvent();
43 
44         $callable = [$this, 'registerCall'];
45 
46         $this->eventLoggerMiddleware->handle($event, $callable);
47 
48         $this->assertGreaterThan(0, $this->calls());
49     }
50 
51     public function registerCall(): void
52     {
53         $this->calls++;
54     }
55 
56     public function calls(): int
57     {
58         return $this->calls;
59     }
60 
61     private function createDomainEvent()
62     {
63         $event = new class() implements DomainEvent
64         {
65 
66             public function occurredOn(): DateTimeInterface
67             {
68                 return new \DateTimeImmutable();
69             }
70 
71             public function __toString(): string
72             {
73                 return 'Domain Event';
74             }
75         };
76 
77         return $event;
78     }
79 }

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:

 1 <?php
 2 declare(strict_types=1);
 3 
 4 namespace Project\App\Application\Common;
 5 
 6 use DateTimeInterface;
 7 use Project\App\Domain\Common\Event\DomainEvent;
 8 use PHPUnit\Framework\MockObject\MockObject;
 9 use PHPUnit\Framework\TestCase;
10 use Psr\Log\LoggerInterface;
11 
12 class EventLoggerMiddlewareTest extends TestCase
13 {
14     /** @var LoggerInterface | MockObject */
15     private $logger;
16 
17     /** @var EventLoggerMiddleware */
18     private $eventLoggerMiddleware;
19     /** @var int */
20     private $calls;
21     /** @var DomainEvent */
22     private $message;
23 
24     public function setUp()
25     {
26         $this->calls = 0;
27 
28         $this->logger = $this->createMock(LoggerInterface::class);
29 
30         $this->eventLoggerMiddleware = new EventLoggerMiddleware($this->logger);
31     }
32 
33     public function testShouldLogTheEvent(): void
34     {
35         $event = $this->createDomainEvent();
36 
37         $this->logger->expects($this->once())->method('info');
38 
39         $this->eventLoggerMiddleware->handle($event, function() {});
40     }
41 
42     public function testShouldExecuteNextWithTheMessage(): void
43     {
44         $event = $this->createDomainEvent();
45 
46         $callable = [$this, 'registerCall'];
47 
48         $this->eventLoggerMiddleware->handle($event, $callable);
49 
50         $this->assertGreaterThan(0, $this->calls());
51         $this->assertEquals($event, $this->message);
52     }
53 
54     public function registerCall($message): void
55     {
56         $this->calls++;
57         $this->message = $message;
58     }
59 
60     public function calls(): int
61     {
62         return $this->calls;
63     }
64 
65     private function createDomainEvent()
66     {
67         $event = new class() implements DomainEvent
68         {
69 
70             public function occurredOn(): DateTimeInterface
71             {
72                 return new \DateTimeImmutable();
73             }
74 
75             public function __toString(): string
76             {
77                 return 'Domain Event';
78             }
79         };
80 
81         return $event;
82     }
83 }

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.