Outside-in mockista

Outside-in TDD, también llamado mockist o London school, es una aproximación al desarrollo dirigido por tests que busca implementar features en el software partiendo de un test de aceptación y procediendo hacia el interior del software.

En lugar de diseñar el sistema en la fase de refactoring, como hace el enfoque clásico, la aproximación outside-in lo hace durante la fase en rojo, es decir, cuando el test de aceptación todavía está fallando. El desarrollo estará terminado cuando el test de aceptación pasa. A medida que tenemos que implementar componentes, estos se desarrollan con un estilo clásico.

Así por ejemplo, en el desarrollo de una API, primero se escribiría un test de aceptación contra la API, como si el test fuese un consumidor más de esa API. El siguiente paso sería diseñar y testear el controlador, luego el caso de uso, y luego los servicios y entidades manejados por ese caso de uso, hasta llegar al dominio de la aplicación. En todos los casos haríamos mocks de las dependencias, de modo que estaríamos testeando los mensajes entre objetos de la aplicación.

Para hacerlo, la metodología se basa en dos ciclos:

  • Ciclo test de aceptación. Se trata de un test que describe la feature completa en el nivel end to end, usando implementaciones reales de los componentes del sistema, excepto aquellas que definen límites del mismo. Los fallos de los test en este nivel nos sirven como guía para saber qué es lo próximo que tenemos que desarrollar.
  • Ciclo de tests unitarios. Una vez que tenemos un fallo en el test de aceptación que nos indica qué tenemos que desarrollar, daremos un paso hacia el interior del sistema y usaremos tests unitarios para desarrollar el componente correspondiente, mockeando aquellos colaboradores o dependencias que este pueda necesitar. Cuando terminamos, volvemos al ciclo del test de aceptación para encontrar el que será nuestro próximo objetivo.
El ciclo outside-in mockista

Desarrollo

En esta ocasión vamos a desarrollar la kata en PHP, usando este repositorio, ya que contiene una instalación preparada de PHP y Symfony, lo que nos proporciona un framework HTTP con el que empezar a desarrollar:

https://github.com/franiglesias/tb

En el repositorio ya tenemos un test básico que utilizaremos como punto de partida:

 1 namespace App\Tests\Katas\TodoList;
 2 
 3 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 4 
 5 class TodoListAcceptanceTest extends WebTestCase
 6 {
 7 
 8     protected function setUp(): void
 9     {
10         $this->resetRepositoryData();
11     }
12 
13     protected function tearDown(): void
14     {
15         $this->resetRepositoryData();
16     }
17 
18     private function resetRepositoryData(): void
19     {
20         if (file_exists('repository.data')) {
21             unlink('repository.data');
22         }
23     }
24 }

Diseñando el test de aceptación

Necesitamos un test de aceptación que describa cómo tiene que funcionar la aplicación. Para ello tenemos un ejemplo. Estas son las tareas que vamos a poner en nuestra lista:

1 1. Write a test that fails (done)
2 2. Write Production code that makes the test pass
3 3. Refactor if there is opportunity

Los pasos que el test tiene que ejecutar, por tanto, son anotar las tres tareas, marcar la primera como hecha y ser capaz de mostrarnos la lista. Estas operaciones son:

 1 POST /api/todo
 2 payload: [task:Write a test that fails]
 3 
 4 POST /api/todo
 5 payload: [task:Write Production code that makes the test pass]
 6 
 7 POST /api/todo
 8 payload: [task:Refactor if there is opportunity]
 9 
10 PATCH /api/todo/1
11 payload: [done:true]
12 
13 GET /api/todo
14 Response:
15 [√] 1. Write a test that fails
16 [ ] 2. Write Production code that makes the test pass
17 [ ] 3. Refactor if there is opportunity

Para simplificar, la respuesta será una representación de cada tarea en una línea de texto con el formato que se puede ver arriba.

Empezando por el final: cuál será el resultado esperado

Para empezar a diseñar nuestro test, comenzamos por el final, es decir, por la llamada para recuperar la lista de tareas y que representa el resultado que esperamos obtener al final del proceso. A partir de ahí iremos reproduciendo los pasos previos necesarios para llegar a ese estado.

 1     /** @test */
 2     public function shouldAllowAddingTaskCompleteAndRetrieveTheList(): \
 3 void
 4     {
 5         $expectedList = [
 6             '[√] 1. Write a test that fails',
 7             '[ ] 2. Write Production code that makes the test pass',
 8             '[ ] 3. Refactor if there is opportunity',
 9         ];
10 
11         $client = self::createClient();
12         $client->request('GET', '/api/todo');
13         $response = $client->getResponse();
14         $list = json_decode($response->getContents(), true);
15         
16         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
17 ));
18         self::assertEquals($list, $expectedList);
19     }

Para llegar a este punto, necesitaríamos haber hecho una petición a la API por cada tarea y una petición más para marcar una tarea como completada. De este modo, el test completo quedaría así:

 1     /** @test */
 2     public function shouldAllowAddingTaskCompleteAndRetrieveTheList(): \
 3 void
 4     {
 5         $expectedList = [
 6             '[√] 1. Write a test that fails',
 7             '[ ] 2. Write Production code that makes the test pass',
 8             '[ ] 3. Refactor if there is opportunity',
 9         ];
10 
11         $client = self::createClient();
12 
13         $taskDescription = 'Write a test that fails';
14         $client->request(
15             'POST',
16             '/api/todo',
17             [],
18             [],
19             ['CONTENT-TYPE' => 'json/application'],
20             json_encode(['task' => $taskDescription])
21         );
22 
23         $taskDescription = 'Write Production code that makes the test p\
24 ass';
25         $client->request(
26             'POST',
27             '/api/todo',
28             [],
29             [],
30             ['CONTENT-TYPE' => 'json/application'],
31             json_encode(['task' => $taskDescription])
32         );
33 
34         $taskDescription = 'Refactor if there is opportunity';
35         $client->request(
36             'POST',
37             '/api/todo',
38             [],
39             [],
40             ['CONTENT-TYPE' => 'json/application'],
41             json_encode(['task' => $taskDescription])
42         );
43 
44         $taskId = 1;
45         $client->request(
46             'PATCH',
47             '/api/todo/'.$taskId,
48             [],
49             [],
50             ['CONTENT-TYPE' => 'json/application'],
51             json_encode(['done' => true])
52         );
53 
54         $client->request('GET', '/api/todo');
55         $response = $client->getResponse();
56         $list = json_decode($response->getContent(), true);
57 
58         self::assertEquals($list, $expectedList);
59     }

Si lo ejecutamos empezaremos a ver fallos acerca de problemas de la configuración del framework. Lo primero que tenemos que hacer es conseguir que el test falle por el motivo correcto, que no es otro, sino que al pedir la lista de tareas la respuesta $list no sea la que esperamos. Por lo tanto, primero iremos resolviendo estos problemas hasta lograr que el test se ejecute.

Resolviendo los detalles necesarios en el framework

El primer error nos dice que no hay ningún controlador en la ubicación esperada por el framework. En nuestro caso, además de eso, queremos montar una solución con una arquitectura limpia. Según eso, los controladores del API deberían estar en la capa de Infraestructura, por lo que vamos a cambiar la configuración de services.yaml de Symfony de modo que espere encontrar los controladores en otra ruta. En concreto, yo prefiero ponerlos en:

src/Infrastructure/EntryPoint/Api/Controller

Por tanto, services.yaml quedará así:

1     # controllers are imported separately to make sure services can be \
2 injected
3     # as action arguments even if you don't extend any base controller \
4 class
5     App\Infrastructure\EntryPoint\Api\Controller\:
6         resource: '../src/Infrastructure/EntryPoint/Api/Controller'
7         tags: ['controller.service_arguments']

Si ejecutamos el test de nuevo, veremos que el mensaje de error ha cambiado, lo cual indica que hemos intervenido de manera correcta. Ahora nos indica que no hay controladores en el nuevo lugar definido, así que vamos a crear una clase TodoListController en la ubicación: \App\Infrastructure\EntryPoint\Api\Controller\TodoListController.

1 namespace App\Infrastructure\EntryPoint\Api\Controller;
2 
3 
4 class TodoListController
5 {
6 
7 }

Y de momento, la dejamos así. Ejecutamos el test para ver qué nos dice. Tenemos dos tipos de mensajes. Por una parte, varias excepciones que nos indican que no se encuentran las rutas de los endpoints, las cuales no hemos definido todavía.

Por otra parte, el test nos indica que la llamada al endpoint devuelve null y, por tanto, no tenemos todavía la lista de tareas.

Así que necesitamos que nuestro controlador sea capaz de gestionar estas rutas antes de nada. La primera ruta que no encuentra es la de POST /api/todo, con la que añadimos tareas a la lista. Para ello, introduciremos una entrada en el archivo routes.yaml.

1 api_add_task:
2   path: /api/todo
3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
4 roller::addTask
5   methods: ['POST']

Una vez añadida la ruta, ejecutamos de nuevo el test de aceptación. Lo adecuado es lanzar el test con cada cambio para confirmar que falla por el motivo esperado. En este caso, esperamos que nos diga que no tenemos un método addTask en TodoListController, y lo tenemos que añadir para avanzar.

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 class TodoListController
 5 {
 6 
 7     public function addTask()
 8     {
 9         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
10 __, __METHOD__));
11     }
12 }

Como puedes ver, en el método lanzo una excepción que me permitirá ver cuando se está llamando al controlador real. De este modo, sabré con seguridad si es lo que tengo que implementar a continuación. Esta técnica se la he visto a Sandro Mancuso en su vídeo sobre Outside-in y me parece muy útil. En algunas ocasiones el propio compilador o intérprete podría señalar este falta de implementación, pero hacerlo explícito hará que todo sea más fácil para nosotras.

Al relanzar el test, el primer error nos dice literalmente que hay que implementar el método addTask.

Y esto nos lleva al ciclo de tests unitarios.

Primer test unitario

El primer test unitario nos introduce un paso hacia el interior de la aplicación. El test de aceptación ejercita el código desde fuera de la aplicación, mientras que el controlador se encuentra en la capa de Infraestructura. Lo que vamos a hacer es desarrollar el controlador con un test unitario, pero en lugar de usar el enfoque clásico, que consiste en implementar una solución y luego usar la etapa de refactor para diseñar los componentes, empezaremos por este punto.

Es decir, lo que queremos hacer es diseñar qué componentes queremos que use el controlador para devolver una respuesta, mockearlos en el test, implementando solo el código propio del controlador.

En este ejemplo, voy a suponer que cada controlador invoca un caso de uso en la capa de aplicación. Para que se entienda mejor no usaré un bus de comandos como haría en una aplicación real, sino que invocaré directamente los casos de uso.

Este es mi primer test unitario:

 1 namespace App\Tests\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 use App\Infrastructure\EntryPoint\Api\Controller\TodoListController;
 4 use PHPUnit\Framework\TestCase;
 5 use Symfony\Component\HttpFoundation\Request;
 6 use Symfony\Component\HttpFoundation\Response;
 7 
 8 class TodoListControllerTest extends TestCase
 9 {
10 
11     /** @test */
12     public function shouldAddTask(): void
13     {
14         $addTask = $this->createMock(AddTaskHandler::class);
15         $addTask
16             ->expects(self::once())
17             ->method('execute')
18             ->with('Task Description');
19             
20         $todoListController = new TodoListController($addTask);
21 
22         $request = new Request(
23             [],
24             [],
25             [],
26             [],
27             [],
28             ['CONTENT-TYPE' => 'json/application'],
29             json_encode(['task' => 'Task Description'], JSON_THROW_ON_E\
30 RROR)
31         );
32         
33         $response = $todoListController->addTask($request);
34         
35         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
36 Code());
37     }
38 }

Por un lado, en el test simulamos una request con un payload JSON, que será la que nos proporcione los datos necesarios. El mock de AddTaskHandler simula que simplemente llamamos a su método execute pasándole como parámetro la descripción de la tarea proporcionada en la llamada al endpoint.

Gracias al uso de mocks no tenemos que preocuparnos de qué pasa más adentro en la aplicación. Lo que estamos testando es el modo en el que el controlador obtiene los datos relevantes y se los pasa al caso de uso para que este haga lo que tenga que hacer. Si no hay ningún problema, el controlador retornará una respuesta 201, indicando que el recurso ha sido creado. No nos vamos a ocupar en este ejemplo de todos los posibles fallos que podrían ocurrir, pero puedes hacerte una idea de cómo se gestionaría.

Ahora ejecutamos el test TodoListController para asegurar que falla por las razones esperadas: que no se llama a AddTaskHandler y que no se devuelve el código HTTP 201.

En este caso, el primer error es que no tenemos una clase AddTaskHandler que mockear, así que la creamos. La vamos a poner en App\Application.

1 namespace App\Application;
2 
3 
4 class AddTaskHandler
5 {
6 
7 }

Tiramos de nuevo el test, que nos indicará que no existe un método execute que se pueda mockear. Lo añadimos, pero dejamos que lance una excepción para decirnos que no está implementado. Veremos la utilidad de ello dentro de un rato, porque en este test no se va a ejecutar en realidad.

 1 namespace App\Application;
 2 
 3 
 4 class AddTaskHandler
 5 {
 6     public function execute(string $taskDescription)
 7     {
 8         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
 9 __, __METHOD__));
10     }
11 }

En cambio, si todo ha ido bien, en este punto el test nos pedirá que implementemos el método addTask del controlador, que es el punto al que queríamos llegar.

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use Symfony\Component\HttpFoundation\JsonResponse;
 6 use Symfony\Component\HttpFoundation\Request;
 7 use Symfony\Component\HttpFoundation\Response;
 8 
 9 class TodoListController
10 {
11 
12     private AddTaskHandler $addTask;
13 
14     public function __construct(AddTaskHandler $addTask)
15     {
16         $this->addTask = $addTask;
17     }
18 
19     public function addTask(Request $request): Response
20     {
21         $body = json_decode($request->getContent(), true);
22 
23         $this->addTask->execute($body['task']);
24 
25         return new JsonResponse('', Response::HTTP_CREATED);
26     }
27 }

Este código hace pasar el test. Puesto que es relativamente sencillo no vamos a hacerlo en pasos muy pequeños a fin de avanzar más rápido con la explicación.

Vamos a aprovechar que el test está en verde para refactorizarlo un poco. Sabemos que tendremos que añadir más tests en este TestCase y que habrá que instanciar el controlador varias veces, así que vamos a hacernos la vida un poco más fácil para el futuro próximo. Tras asegurarnos de que sigue pasando, el test queda así:

 1 namespace App\Tests\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 use App\Application\AddTaskHandler;
 4 use App\Infrastructure\EntryPoint\Api\Controller\TodoListController;
 5 use PHPUnit\Framework\TestCase;
 6 use Symfony\Component\HttpFoundation\Request;
 7 use Symfony\Component\HttpFoundation\Response;
 8 
 9 class TodoListControllerTest extends TestCase
10 {
11     private AddTaskHandler $addTaskHandler;
12     private TodoListController $todoListController;
13 
14 
15     protected function setUp(): void
16     {
17         $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
18 );
19         $this->todoListController = new TodoListController($this->addTa\
20 skHandler);
21     }
22     
23     /** @test */
24     public function shouldAddTask(): void
25     {
26         $this->addTaskHandler
27             ->expects(self::once())
28             ->method('execute')
29             ->with('Task Description');
30 
31         $request = new Request(
32             [],
33             [],
34             [],
35             [],
36             [],
37             ['CONTENT-TYPE' => 'json/application'],
38             json_encode(['task' => 'Task Description'], JSON_THROW_ON_E\
39 RROR)
40         );
41 
42         $response = $this->todoListController->addTask($request);
43 
44         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
45 Code());
46     }
47 }

Es momento de volver a ejecutar el test de aceptación.

De vuelta en el ciclo de aceptación

Ahora que el test TodoListController está pasando, ya no tenemos más trabajo que hacer en este nivel, así que volvemos al test de aceptación para ver si sigue fallando algo y qué es lo que falla.

En este punto, lo que nos dice es que AddTaskHandler::execute no está implementada. ¿Recuerdas la excepción que pusimos antes? Pues eso nos dice que tenemos que movernos un nivel más adentro y ponernos en la capa de Aplicación para desarrollar el caso de uso. Por supuesto, con un test unitario.

Como hemos dicho antes, en outside-in diseñamos en la fase de test en rojo y mockeamos los componentes que la unidad actual pueda utilizar como colaboradores. Normalmente, no haremos dobles de entidades. En este caso, lo que esperamos del caso de uso es:

  • Que cree una nueva tarea, modelada con una entidad de dominio Task
  • Que la persista en un repositorio
  • La tarea tiene que adquirir un ID, el cual será proporcionado por el repositorio.

Esto indica que el caso de uso tendrá una dependencia, el repositorio TaskRepository, y que empezaremos a modelar las tareas con una entidad Task. Este es el test.

 1 namespace App\Tests\Application;
 2 
 3 use App\Application\AddTaskHandler;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class AddTaskHandlerTest extends TestCase
 7 {
 8 
 9     /** @test */
10     public function shouldAddATaskToRepository(): void
11     {
12         $taskRepository = $this->createMock(TaskRepository::class);
13         $task = new Task(1, 'Task Description');
14         
15         $taskRepository
16             ->method('nextId')
17             ->willReturn(1);
18         
19         $taskRepository
20             ->expects(self::once())
21             ->method('store')
22             ->with($task);
23         
24         
25         $addTaskHandler = new AddTaskHandler($taskRepository);
26         
27         $addTaskHandler->execute('Task Description');
28     }
29 }

Lo ejecutamos y nos irá diciendo qué tenemos que hacer.

Lo primero será crear TaskRepository para poder mockearlo. En este caso, el repositorio se define como interfaz en la capa de dominio, como ya sabemos. Así que empezamos por ahí.

1 namespace App\Domain;
2 
3 
4 interface TaskRepository
5 {
6 
7 }

Lo siguiente será la entidad Task, que también está en el dominio.

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9 
10     public function __construct(int $id, string $description)
11     {
12         $this->id = $id;
13         $this->description = $description;
14     }
15 }

De momento, me limito a crear lo básico, ya veremos lo que el desarrollo nos va pidiendo.

El siguiente error nos indica que no tenemos un método nextId en TaskRepository, así que lo introducimos en la interfaz.

1 namespace App\Domain;
2 
3 
4 interface TaskRepository
5 {
6     public function nextId(): int;
7 }

Y tampoco tenemos un método store. Lo mismo:

1 namespace App\Domain;
2 
3 
4 interface TaskRepository
5 {
6     public function nextId(): int;
7 
8     public function store(Task $task): void;
9 }

Por último, al invocar el método execute, nos lanza la consabida excepción de que no tiene código, indicando que ya hemos preparado todo lo necesario hasta ahora, así que vamos a implementar por fin.

 1 namespace App\Application;
 2 
 3 
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 
 7 class AddTaskHandler
 8 {
 9     private TaskRepository $taskRepository;
10 
11     public function __construct(TaskRepository $taskRepository)
12     {
13         $this->taskRepository = $taskRepository;
14     }
15 
16     public function execute(string $taskDescription): void
17     {
18         $id = $this->taskRepository->nextId();
19 
20         $task = new Task($id, $taskDescription);
21 
22         $this->taskRepository->store($task);
23     }
24 }

Con este código, el test pasa. Ya no tenemos nada más que hacer aquí, salvo ver si podemos refactorizar alguna cosa. En el test vemos algunos detalles que se pueden mejorar, para hacerlo todo más fácil de entender:

 1 namespace App\Tests\Application;
 2 
 3 use App\Application\AddTaskHandler;
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class AddTaskHandlerTest extends TestCase
 9 {
10     private const NEW_TASK_ID = 1;
11     private const NEW_TASK_DESCRIPTION = 'Task Description';
12 
13     /** @test */
14     public function shouldAddATaskToRepository(): void
15     {
16         $task = new Task(self::NEW_TASK_ID, self::NEW_TASK_DESCRIPTION);
17         
18         $taskRepository = $this->createMock(TaskRepository::class);
19         $taskRepository
20             ->method('nextId')
21             ->willReturn(self::NEW_TASK_ID);
22         $taskRepository
23             ->expects(self::once())
24             ->method('store')
25             ->with($task);
26         
27         $addTaskHandler = new AddTaskHandler($taskRepository);
28 
29         $addTaskHandler->execute(self::NEW_TASK_DESCRIPTION);
30     }
31 }

Volvamos al test de aceptación, a ver qué ocurre.

Nueva visita al test de aceptación

Al ejecutar de nuevo el test de aceptación, nos indica que aunque tenemos una interfaz para TaskRepository no hemos definido ninguna implementación concreta, por lo que el test no se ejecuta. Es hora de desarrollar una.

Teniendo en cuenta que estamos creando una API REST necesitamos que las tareas que almacenemos persistan entre llamadas, por lo que en principio un repositorio en memoria no nos valdrá. En nuestro caso usaremos un vendor, que se encuentra en el repositorio que estamos usando como base para este desarrollo. Se trata de la clase FileStorageEngine. Simplemente, guarda los objetos en un archivo, de modo que simulamos una base de datos real, cuya persistencia es suficiente para ejecutar el test.

 1 namespace App\Lib;
 2 
 3 
 4 class FileStorageEngine
 5 {
 6     private string $filePath;
 7 
 8     public function __construct($filePath)
 9     {
10         $this->filePath = $filePath;
11     }
12 
13     public function loadObjects(string $class): array
14     {
15         if (!file_exists($this->filePath)) {
16             return [];
17         }
18 
19         $file = fopen($this->filePath, 'rb');
20         $objects = unserialize(fgets($file), ['allowed_classes' => [$cl\
21 ass]]);
22         fclose($file);
23 
24         return $objects;
25     }
26 
27     public function persistObjects(array $objects): void
28     {
29         $file = fopen($this->filePath, 'wb');
30         fwrite($file, serialize($objects));
31         fclose($file);
32     }
33 
34 }

Vamos entonces a escribir tests unitarios para desarrollar un repositorio de tareas que utilice FileStorageEngine.

 1 namespace App\Tests\Infrastructure\Persistence;
 2 
 3 use App\Domain\Task;
 4 use App\Infrastructure\Persistence\FileTaskRepository;
 5 use App\Lib\FileStorageEngine;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class FileTaskRepositoryTest extends TestCase
 9 {
10     /** @test */
11     public function shouldBeAbleToStoreTasks(): void
12     {
13         $task = new Task(1, 'TaskDescription');
14         $storageEngine = $this->createMock(FileStorageEngine::class);
15         $storageEngine
16             ->method('loadObjects')
17             ->with(Task::class)
18             ->willReturn([]);
19         $storageEngine
20             ->expects(self::once())
21             ->method('persistObjects')
22             ->with(['1' => $task]);
23 
24         $taskRepository = new FileTaskRepository($storageEngine);
25         $taskRepository->store($task);
26     }
27 }

Al ejecutar el test, nos dice que no tenemos un FileTaskRepository, así que empezamos a construirlo. Al fallar, el test nos irá indicando qué tenemos que hacer. Y este es el resultado:

 1 namespace App\Infrastructure\Persistence;
 2 
 3 
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 use App\Lib\FileStorageEngine;
 7 
 8 class FileTaskRepository implements TaskRepository
 9 {
10     private FileStorageEngine $storageEngine;
11 
12     public function __construct(FileStorageEngine $storageEngine)
13     {
14         $this->storageEngine = $storageEngine;
15     }
16 
17     public function store(Task $task): void
18     {
19         $tasks = $this->storageEngine->loadObjects(Task::class);
20         $tasks[$task->id()] = $task;
21         $this->storageEngine->persistObjects($tasks);
22     }
23 
24     public function nextId(): int
25     {
26         throw new \RuntimeException('Implement nextId() method.');
27     }
28 }

De nuevo, nos hemos saltado algunos baby steps para llegar a la implementación deseada. Una vez que el test pasa, volveremos al test de aceptación.

El test ahora nos indica que nos falta por implementar el método nextId en FileTaskRepository. Así que volveremos al test unitario.

En principio lo que vamos a hacer es simplemente devolver como nuevo id el número de tareas guardadas más uno. Esto no funcionará bien en el caso de que lleguemos a borrar tareas, pero por el momento será suficiente. Este es el test:

 1     /** @test */
 2     public function shouldProvideNextIdentity(): void
 3     {
 4         $storageEngine = $this->createMock(FileStorageEngine::class);
 5         $storageEngine
 6             ->method('loadObjects')
 7             ->with(Task::class)
 8             ->willReturn([]);
 9 
10         $taskRepository = new FileTaskRepository($storageEngine);
11         $id = $taskRepository->nextId();
12         self::assertEquals(1, $id);
13     }

Y esta, la implementación:

1     public function nextId(): int
2     {
3         $tasks = $this->storageEngine->loadObjects(Task::class);
4 
5         return count($tasks) + 1;
6     }

Sería necesario añadir un par de casos más para verificarlo, pero lo dejaremos así para avanzar más rápido ahora.

Finalizando la primera historia de usuario

Si lanzamos ahora el test de aceptación, veremos que el error que aparece es que no tenemos ruta para el endpoint en el que marcamos una tarea como completada. Esto quiere decir que la primera de nuestras User Stories está terminada: ya se pueden añadir tareas en la lista.

Hemos ido desde el exterior de la aplicación hasta los detalles de implementación y cada paso estaba cubierto por tests. Lo cierto es que hemos podido completar mucho trabajo, pero aún nos queda camino por delante.

Y el primer paso debería sonarnos familiar. Tenemos que definir la ruta al endpoint, el controlador, un nuevo caso de uso y la interacción con el repositorio de tareas. En routes.yaml añadimos la ruta:

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
 4 roller::addTask
 5   methods: ['POST']
 6 
 7 api_mark_task_completed:
 8   path: /api/todo/{taskid}
 9   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
10 roller::markTaskCompleted
11   methods: ['PATCH']

Añadimos un método a TodoListController:

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use Symfony\Component\HttpFoundation\JsonResponse;
 6 use Symfony\Component\HttpFoundation\Request;
 7 use Symfony\Component\HttpFoundation\Response;
 8 
 9 class TodoListController
10 {
11 
12     private AddTaskHandler $addTask;
13 
14     public function __construct(AddTaskHandler $addTask)
15     {
16         $this->addTask = $addTask;
17     }
18 
19     public function addTask(Request $request): Response
20     {
21         $payload = json_decode($request->getContent(), true);
22 
23         $this->addTask->execute($payload['task']);
24 
25         return new JsonResponse('', Response::HTTP_CREATED);
26     }
27 
28     public function markTaskCompleted(int $taskid, Request $request): R\
29 esponse
30     {
31         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
32 __, __METHOD__));
33     }
34 }

Al añadir este código y ejecutar el test de aceptación el mensaje de error nos pide implementar el nuevo método. Así que nos vamos a TodoListControllerTest y añadimos el siguiente test:

 1 namespace App\Tests\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 use App\Application\AddTaskHandler;
 4 use App\Infrastructure\EntryPoint\Api\Controller\TodoListController;
 5 use PHPUnit\Framework\TestCase;
 6 use Symfony\Component\HttpFoundation\Request;
 7 use Symfony\Component\HttpFoundation\Response;
 8 
 9 class TodoListControllerTest extends TestCase
10 {
11     private AddTaskHandler $addTaskHandler;
12     private TodoListController $todoListController;
13     private MarkTaskCompletedHandler $markTaskCompletedHandler;
14 
15 
16     protected function setUp(): void
17     {
18         $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
19 );
20         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCom\
21 pletedHandler::class);
22         $this->todoListController = new TodoListController(
23             $this->addTaskHandler,
24             $this->markTaskCompletedHandler
25         );
26     }
27 
28     /** @test */
29     public function shouldAddTask(): void
30     {
31         $this->addTaskHandler
32             ->expects(self::once())
33             ->method('execute')
34             ->with('Task Description');
35 
36         $request = new Request(
37             [],
38             [],
39             [],
40             [],
41             [],
42             ['CONTENT-TYPE' => 'json/application'],
43             json_encode(['task' => 'Task Description'], JSON_THROW_ON_E\
44 RROR)
45         );
46 
47         $response = $this->todoListController->addTask($request);
48 
49         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
50 Code());
51     }
52 
53     /** @test */
54     public function shouldMarkATaskCompleted(): void
55     {
56         $this->markTaskCompletedHandler
57             ->expects(self::once())
58             ->method('execute')
59             ->with(1);
60 
61         $request = new Request(
62             [],
63             [],
64             [],
65             [],
66             [],
67             ['CONTENT-TYPE' => 'json/application'],
68             json_encode(['done' => true], JSON_THROW_ON_ERROR)
69         );
70 
71         $taskId = 1;
72         $response = $this->todoListController->markTaskCompleted($taskI\
73 d, $request);
74 
75         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
76 ));
77     }
78 
79 
80 }

Este test fallará porque no hemos definido MarkTaskCompletedHandler, así que iremos ejecutando el test y respondiendo a los distintos errores hasta que falle por las razones correctas y, posteriormente, implementar lo necesario para que pase.

 1 namespace App\Application;
 2 
 3 
 4 class MarkTaskCompletedHandler
 5 {
 6     public function execute(int $taskId, bool $done): void
 7     {
 8         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
 9 __, __METHOD__));
10     }
11 }

Una vez que hemos añadido el código básico del caso de uso, podemos empezar a implementar el controlador, que quedará así:

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use App\Application\MarkTaskCompletedHandler;
 6 use Symfony\Component\HttpFoundation\JsonResponse;
 7 use Symfony\Component\HttpFoundation\Request;
 8 use Symfony\Component\HttpFoundation\Response;
 9 
10 class TodoListController
11 {
12 
13     private AddTaskHandler $addTask;
14     private MarkTaskCompletedHandler $markTaskCompleted;
15 
16     public function __construct(
17         AddTaskHandler $addTask,
18         MarkTaskCompletedHandler $markTaskCompleted
19     )
20     {
21         $this->addTask = $addTask;
22         $this->markTaskCompleted = $markTaskCompleted;
23     }
24 
25     public function addTask(Request $request): Response
26     {
27         $payload = json_decode($request->getContent(), true);
28 
29         $this->addTask->execute($payload['task']);
30 
31         return new JsonResponse('', Response::HTTP_CREATED);
32     }
33 
34     public function markTaskCompleted(int $taskid, Request $request): R\
35 esponse
36     {
37         $payload = json_decode($request->getContent(), true);
38 
39         $done = $payload['done'];
40 
41         $this->markTaskCompleted->execute($taskid, $done);
42 
43         return new JsonResponse('', Response::HTTP_OK);
44     }
45 }

Y con esto hacemos pasar el test TodoListControllerTest. Es momento de lanzar de nuevo el test de aceptación para que nos diga qué tenemos que hacer ahora.

Y básicamente lo que nos dice es que debemos implementar MarkTaskCompletedHandler, que no tiene código todavía. Para eso necesitaremos un test unitario.

El caso de uso necesitará el repositorio para obtener la tarea deseada y actualizarla. Eso será lo que vamos a mockear.

 1 namespace App\Tests\Application;
 2 
 3 use App\Application\MarkTaskCompletedHandler;
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class MarkTaskCompletedHandlerTest extends TestCase
 9 {
10     private const TASK_ID = 1;
11 
12     /** @test */
13     public function shouldMarkTaskAsComplete(): void
14     {
15         $task = $this->createMock(Task::class);
16         $task
17             ->expects(self::once())
18             ->method('markCompleted');
19         
20         $taskRepository = $this->createMock(TaskRepository::class);
21         $taskRepository
22             ->method('retrieve')
23             ->with(self::TASK_ID)
24             ->willReturn($task);
25         $taskRepository
26             ->expects(self::once())
27             ->method('store')
28             ->with($task);
29 
30         $markTaskAsCompleted = new MarkTaskCompletedHandler($taskReposi\
31 tory);
32 
33         $markTaskAsCompleted->execute(self::TASK_ID, true);
34     }
35 }

Como detalle llamativo señalar que vamos a mockear una entidad. Esto es necesario para poder testar que pase algo que nos interesa: que llamamos a su método markCompleted. Esto nos obligará a implementarlo. Normalmente evitaría mockear entidades.

Al ejecutar el test, nos pide un método retrieve, que aún no tenemos en el repositorio.

 1 namespace App\Domain;
 2 
 3 
 4 interface TaskRepository
 5 {
 6     public function nextId(): int;
 7 
 8     public function store(Task $task): void;
 9 
10     public function retrieve(int $taskId): Task;
11 }

Así como markCompleted en Task:

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9 
10     public function __construct(int $id, string $description)
11     {
12         $this->id = $id;
13         $this->description = $description;
14     }
15 
16     public function id(): int
17     {
18         return $this->id;
19     }
20 
21     public function markCompleted(): void
22     {
23         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
24 __, __METHOD__));
25     }
26 }

Finalmente, tenemos que implementar el método execute del caso de uso, que quedará así:

 1 namespace App\Application;
 2 
 3 
 4 use App\Domain\TaskRepository;
 5 
 6 class MarkTaskCompletedHandler
 7 {
 8     private TaskRepository $taskRepository;
 9 
10     public function __construct(TaskRepository $taskRepository)
11     {
12         $this->taskRepository = $taskRepository;
13     }
14 
15     public function execute(int $taskId, bool $done): void
16     {
17         $task = $this->taskRepository->retrieve($taskId);
18 
19         $task->markCompleted();
20 
21         $this->taskRepository->store($task);
22     }
23 }

Y, de momento, estamos listas por aquí.

Ejecutaremos de nuevo el test de aceptación. A ver qué nos dice.

Lo primero que nos indica es que no tenemos método retrieve en el repositorio FileTaskRepository. Tenemos que implementarlo para poder seguir. Para ello, usaremos el mismo FileTaskRepositoryTestCase que ya habíamos comenzado.

 1     /** @test */
 2     public function shouldRetrieveTasksById(): void
 3     {
 4         $storageEngine = $this->createMock(FileStorageEngine::class);
 5         $task1 = new Task(1, 'Task 1');
 6         $task2 = new Task(2, 'Task 2');
 7         $storageEngine
 8             ->method('loadObjects')
 9             ->with(Task::class)
10             ->willReturn([1 => $task1, 2 => $task2]);
11 
12         $taskRepository = new FileTaskRepository($storageEngine);
13         $task = $taskRepository->retrieve(2);
14 
15         self::assertEquals($task2, $task);
16     }

Nos pedirá implementar retrieve. Nos bastaría con esto:

 1 namespace App\Infrastructure\Persistence;
 2 
 3 
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 use App\Lib\FileStorageEngine;
 7 
 8 class FileTaskRepository implements TaskRepository
 9 {
10     private FileStorageEngine $storageEngine;
11 
12     public function __construct(FileStorageEngine $storageEngine)
13     {
14         $this->storageEngine = $storageEngine;
15     }
16 
17     public function store(Task $task): void
18     {
19         $tasks = $this->storageEngine->loadObjects(Task::class);
20         $tasks[$task->id()] = $task;
21         $this->storageEngine->persistObjects($tasks);
22     }
23 
24     public function nextId(): int
25     {
26         $tasks = $this->storageEngine->loadObjects(Task::class);
27 
28         return count($tasks) + 1;
29     }
30 
31     public function retrieve(int $taskId): Task
32     {
33         $tasks = $this->storageEngine->loadObjects(Task::class);
34 
35         return $tasks[$taskId];
36     }
37 }

Y efectivamente nos llega. Ahora que estamos en verde, podemos aprovechar para arreglar un poquito el test.

 1 namespace App\Tests\Infrastructure\Persistence;
 2 
 3 use App\Domain\Task;
 4 use App\Infrastructure\Persistence\FileTaskRepository;
 5 use App\Lib\FileStorageEngine;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class FileTaskRepositoryTest extends TestCase
 9 {
10     private FileStorageEngine $storageEngine;
11     private FileTaskRepository $taskRepository;
12     
13     protected function setUp(): void
14     {
15         $this->storageEngine = $this->createMock(FileStorageEngine::cla\
16 ss);
17         $this->taskRepository = new FileTaskRepository($this->storageEn\
18 gine);
19     }
20 
21 
22     /** @test */
23     public function shouldBeAbleToStoreTasks(): void
24     {
25         $task = new Task(1, 'TaskDescription');
26         $this->storageEngine
27             ->method('loadObjects')
28             ->with(Task::class)
29             ->willReturn([]);
30         $this->storageEngine
31             ->expects(self::once())
32             ->method('persistObjects')
33             ->with([1 => $task]);
34 
35         $this->taskRepository->store($task);
36     }
37 
38     /** @test */
39     public function shouldProvideNextIdentity(): void
40     {
41         $this->storageEngine
42             ->method('loadObjects')
43             ->with(Task::class)
44             ->willReturn([]);
45         
46         $id = $this->taskRepository->nextId();
47         self::assertEquals(1, $id);
48     }
49 
50     /** @test */
51     public function shouldRetrieveTasksById(): void
52     {
53         $task1 = new Task(1, 'Task 1');
54         $task2 = new Task(2, 'Task 2');
55         $this->storageEngine
56             ->method('loadObjects')
57             ->with(Task::class)
58             ->willReturn([1 => $task1, 2 => $task2]);
59 
60         $task = $this->taskRepository->retrieve(2);
61 
62         self::assertEquals($task2, $task);
63     }
64 }

Una vez hecho esto, podemos lanzar de nuevo el test de aceptación y ver dónde hemos llegado.

Al hacerlo, nos salta la excepción que habíamos dejado en Task::markCompleted. De momento la vamos a implementar sin hacer nada. Esperaremos a que otros tests nos obliguen, ya que no tenemos realmente forma de verificarlo sin crear un método solo para poder revisar su estado en un test.

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9 
10     public function __construct(int $id, string $description)
11     {
12         $this->id = $id;
13         $this->description = $description;
14     }
15 
16     public function id(): int
17     {
18         return $this->id;
19     }
20 
21     public function markCompleted(): void
22     {
23     }
24 }

Esto hace que el test pueda llegar al siguiente punto interesante: no tenemos una ruta para recuperar la lista de tareas. En routes.yaml añadimos la definición:

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
 4 roller::addTask
 5   methods: ['POST']
 6 
 7 api_mark_task_completed:
 8   path: /api/todo/{taskid}
 9   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
10 roller::markTaskCompleted
11   methods: ['PATCH']
12 
13 api_get_tasks_list:
14   path: /api/todo
15   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListCont\
16 roller::getTasksList
17   methods: ['GET']

Lanzamos el test de aceptación para ver que ya no pide la ruta, sino la implementación de un controlador. Y añadimos un esqueleto en TodoListController.

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use App\Application\MarkTaskCompletedHandler;
 6 use Symfony\Component\HttpFoundation\JsonResponse;
 7 use Symfony\Component\HttpFoundation\Request;
 8 use Symfony\Component\HttpFoundation\Response;
 9 
10 class TodoListController
11 {
12 
13     private AddTaskHandler $addTask;
14     private MarkTaskCompletedHandler $markTaskCompleted;
15 
16     public function __construct(
17         AddTaskHandler $addTask,
18         MarkTaskCompletedHandler $markTaskCompleted
19     )
20     {
21         $this->addTask = $addTask;
22         $this->markTaskCompleted = $markTaskCompleted;
23     }
24 
25     public function addTask(Request $request): Response
26     {
27         $payload = json_decode($request->getContent(), true);
28 
29         $this->addTask->execute($payload['task']);
30 
31         return new JsonResponse('', Response::HTTP_CREATED);
32     }
33 
34     public function markTaskCompleted(int $taskid, Request $request): R\
35 esponse
36     {
37         $payload = json_decode($request->getContent(), true);
38 
39         $done = $payload['done'];
40 
41         $this->markTaskCompleted->execute($taskid, $done);
42 
43         return new JsonResponse('', Response::HTTP_OK);
44     }
45 
46     public function getTasksList(): Response
47     {
48         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
49 __, __METHOD__));
50     }
51 }

Así que hay volver a TodoListControllerTestCase para desarrollar este método:

  1 namespace App\Tests\Infrastructure\EntryPoint\Api\Controller;
  2 
  3 use App\Application\AddTaskHandler;
  4 use App\Application\MarkTaskCompletedHandler;
  5 use App\Domain\Task;
  6 use App\Infrastructure\EntryPoint\Api\Controller\TodoListController;
  7 use PHPUnit\Framework\TestCase;
  8 use Symfony\Component\HttpFoundation\Request;
  9 use Symfony\Component\HttpFoundation\Response;
 10 
 11 class TodoListControllerTest extends TestCase
 12 {
 13     private AddTaskHandler $addTaskHandler;
 14     private TodoListController $todoListController;
 15     private MarkTaskCompletedHandler $markTaskCompletedHandler;
 16     private GetTasksListHandler $getTasksListHandler;
 17     
 18     protected function setUp(): void
 19     {
 20         $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
 21 );
 22         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCom\
 23 pletedHandler::class);
 24         $this->getTasksListHandler = $this->createMock(GetTasksListHand\
 25 ler::class)
 26 
 27         $this->todoListController = new TodoListController(
 28             $this->addTaskHandler,
 29             $this->markTaskCompletedHandler,
 30             $this->getTasksListHandler
 31         );
 32     }
 33 
 34     /** @test */
 35     public function shouldAddTask(): void
 36     {
 37         $this->addTaskHandler
 38             ->expects(self::once())
 39             ->method('execute')
 40             ->with('Task Description');
 41 
 42         $request = new Request(
 43             [],
 44             [],
 45             [],
 46             [],
 47             [],
 48             ['CONTENT-TYPE' => 'json/application'],
 49             json_encode(['task' => 'Task Description'], JSON_THROW_ON_E\
 50 RROR)
 51         );
 52 
 53         $response = $this->todoListController->addTask($request);
 54 
 55         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
 56 Code());
 57     }
 58 
 59     /** @test */
 60     public function shouldMarkATaskCompleted(): void
 61     {
 62         $this->markTaskCompletedHandler
 63             ->expects(self::once())
 64             ->method('execute')
 65             ->with(1);
 66 
 67         $request = new Request(
 68             [],
 69             [],
 70             [],
 71             [],
 72             [],
 73             ['CONTENT-TYPE' => 'json/application'],
 74             json_encode(['done' => true], JSON_THROW_ON_ERROR)
 75         );
 76 
 77         $taskId = 1;
 78         $response = $this->todoListController->markTaskCompleted($taskI\
 79 d, $request);
 80 
 81         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
 82 ));
 83     }
 84 
 85     /** @test */
 86     public function shouldGetListOfTasks(): void
 87     {
 88         $task1 = new Task(1, 'Task 1');
 89         $task1->markCompleted();
 90 
 91         $task2 = new Task(2, 'Task 2');
 92 
 93         $expectedList = ['[√] Task 1', '[ ] Task 2'];
 94 
 95         $this->getTasksListHandler
 96             ->expects(self::once())
 97             ->method('execute')
 98             ->willReturn([$task1, $task2]);
 99         
100         $request = new Request();
101         
102         $response = $this->todoListController->getTasksList($request);
103         
104         $list = json_decode($response->getContent(), true);
105         
106         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
107 ));
108         self::assertEquals($expectedList, $list);
109     }
110 }

El test fallará ya que necesitamos implementar GetTasksListHandler.

 1 namespace App\Application;
 2 
 3 
 4 class GetTasksListHandler
 5 {
 6     public function execute(): array
 7     {
 8         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
 9 __, __METHOD__));
10     }
11 }

Cuando podemos ejecutar todo el test, empezamos a implementar. Esta es nuestra tentativa:

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use App\Application\GetTasksListHandler;
 6 use App\Application\MarkTaskCompletedHandler;
 7 use Symfony\Component\HttpFoundation\JsonResponse;
 8 use Symfony\Component\HttpFoundation\Request;
 9 use Symfony\Component\HttpFoundation\Response;
10 
11 class TodoListController
12 {
13 
14     private AddTaskHandler $addTask;
15     private MarkTaskCompletedHandler $markTaskCompleted;
16     private GetTasksListHandler $getTasksList;
17 
18     public function __construct(
19         AddTaskHandler $addTask,
20         MarkTaskCompletedHandler $markTaskCompleted,
21         GetTasksListHandler $getTasksList
22     )
23     {
24         $this->addTask = $addTask;
25         $this->markTaskCompleted = $markTaskCompleted;
26         $this->getTasksList = $getTasksList;
27     }
28 
29     public function addTask(Request $request): Response
30     {
31         $payload = json_decode($request->getContent(), true);
32 
33         $this->addTask->execute($payload['task']);
34 
35         return new JsonResponse('', Response::HTTP_CREATED);
36     }
37 
38     public function markTaskCompleted(int $taskid, Request $request): R\
39 esponse
40     {
41         $payload = json_decode($request->getContent(), true);
42 
43         $done = $payload['done'];
44 
45         $this->markTaskCompleted->execute($taskid, $done);
46 
47         return new JsonResponse('', Response::HTTP_OK);
48     }
49 
50     public function getTasksList(Request $request): Response
51     {
52         $list = $this->getTasksList->execute();
53 
54         return new JsonResponse($list, Response::HTTP_OK);
55     }
56 }

El problema aquí es que tenemos que introducir una forma de convertir la lista tal como la devuelve el caso de uso GetTaskListHandler al formato requerido por el consumidor del endpoint. Se trata de una representación de la tarea en forma de cadena de texto.

Hay varias formas de resolver esto, y todas requieren que Task pueda darnos algún tipo de representación utilizable:

  • La más sencilla sería hacer la conversión en el propio controlador, recorriendo la lista de tareas y generando su representación. Para ello nos hará falta un método que se encargue.
  • Otra consistiría en crear un servicio que haga la conversión. Sería una dependencia del controlador.
  • Y una tercera alternativa sería usar ese mismo servicio, pero pasándolo a GetTaskListHandler como estrategia. De este modo el controlador decide cómo quiere obtener la lista, aunque sea GetTaskListHandler quien la prepara.

Esta última opción es la que vamos a usar. Pero para eso tendremos que cambiar tests. No mucho, por suerte, tan solo TodoListControllerTest necesita cambios realmente.

  1 namespace App\Tests\Infrastructure\EntryPoint\Api\Controller;
  2 
  3 use App\Application\AddTaskHandler;
  4 use App\Application\GetTasksListHandler;
  5 use App\Application\MarkTaskCompletedHandler;
  6 use App\Application\TaskListFormatter;
  7 use App\Domain\Task;
  8 use App\Infrastructure\EntryPoint\Api\Controller\TodoListController;
  9 use PHPUnit\Framework\TestCase;
 10 use Symfony\Component\HttpFoundation\Request;
 11 use Symfony\Component\HttpFoundation\Response;
 12 
 13 class TodoListControllerTest extends TestCase
 14 {
 15     private AddTaskHandler $addTaskHandler;
 16     private TodoListController $todoListController;
 17     private MarkTaskCompletedHandler $markTaskCompletedHandler;
 18     private GetTasksListHandler $getTasksListHandler;
 19     private TaskListFormatter $taskListFormatter;
 20 
 21     protected function setUp(): void
 22     {
 23         $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
 24 );
 25         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCom\
 26 pletedHandler::class);
 27         $this->getTasksListHandler = $this->createMock(GetTasksListHand\
 28 ler::class);
 29         $this->taskListFormatter = $this->createMock(TaskListFormatter:\
 30 :class);
 31 
 32         $this->todoListController = new TodoListController(
 33             $this->addTaskHandler,
 34             $this->markTaskCompletedHandler,
 35             $this->getTasksListHandler,
 36             $this->taskListFormatter
 37         );
 38     }
 39 
 40     /** @test */
 41     public function shouldAddTask(): void
 42     {
 43         $this->addTaskHandler
 44             ->expects(self::once())
 45             ->method('execute')
 46             ->with('Task Description');
 47 
 48         $request = new Request(
 49             [],
 50             [],
 51             [],
 52             [],
 53             [],
 54             ['CONTENT-TYPE' => 'json/application'],
 55             json_encode(['task' => 'Task Description'], JSON_THROW_ON_E\
 56 RROR)
 57         );
 58 
 59         $response = $this->todoListController->addTask($request);
 60 
 61         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
 62 Code());
 63     }
 64 
 65     /** @test */
 66     public function shouldMarkATaskCompleted(): void
 67     {
 68         $this->markTaskCompletedHandler
 69             ->expects(self::once())
 70             ->method('execute')
 71             ->with(1);
 72 
 73         $request = new Request(
 74             [],
 75             [],
 76             [],
 77             [],
 78             [],
 79             ['CONTENT-TYPE' => 'json/application'],
 80             json_encode(['done' => true], JSON_THROW_ON_ERROR)
 81         );
 82 
 83         $taskId = 1;
 84         $response = $this->todoListController->markTaskCompleted($taskI\
 85 d, $request);
 86 
 87         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
 88 ));
 89     }
 90 
 91     /** @test */
 92     public function shouldGetListOfTasks(): void
 93     {
 94         $expectedList = ['[√] Task 1', '[ ] Task 2'];
 95 
 96         $this->getTasksListHandler
 97             ->expects(self::once())
 98             ->method('execute')
 99             ->with($this->taskListFormatter)
100             ->willReturn($expectedList);
101 
102         $request = new Request();
103 
104         $response = $this->todoListController->getTasksList($request);
105 
106         $list = json_decode($response->getContent(), true);
107 
108         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
109 ));
110         self::assertEquals($expectedList, $list);
111     }
112 }

Y el controlador quedará así:

 1 namespace App\Infrastructure\EntryPoint\Api\Controller;
 2 
 3 
 4 use App\Application\AddTaskHandler;
 5 use App\Application\GetTasksListHandler;
 6 use App\Application\MarkTaskCompletedHandler;
 7 use App\Application\TaskListFormatter;
 8 use Symfony\Component\HttpFoundation\JsonResponse;
 9 use Symfony\Component\HttpFoundation\Request;
10 use Symfony\Component\HttpFoundation\Response;
11 
12 class TodoListController
13 {
14 
15     private AddTaskHandler $addTask;
16     private MarkTaskCompletedHandler $markTaskCompleted;
17     private GetTasksListHandler $getTasksList;
18     private TaskListFormatter $taskListFormatter;
19 
20 
21     public function __construct(
22         AddTaskHandler $addTask,
23         MarkTaskCompletedHandler $markTaskCompleted,
24         GetTasksListHandler $getTasksList,
25         TaskListFormatter $taskListFormatter
26     )
27     {
28         $this->addTask = $addTask;
29         $this->markTaskCompleted = $markTaskCompleted;
30         $this->getTasksList = $getTasksList;
31         $this->taskListFormatter = $taskListFormatter;
32     }
33 
34     public function addTask(Request $request): Response
35     {
36         $payload = json_decode($request->getContent(), true);
37 
38         $this->addTask->execute($payload['task']);
39 
40         return new JsonResponse('', Response::HTTP_CREATED);
41     }
42 
43     public function markTaskCompleted(int $taskid, Request $request): R\
44 esponse
45     {
46         $payload = json_decode($request->getContent(), true);
47 
48         $done = $payload['done'];
49 
50         $this->markTaskCompleted->execute($taskid, $done);
51 
52         return new JsonResponse('', Response::HTTP_OK);
53     }
54 
55     public function getTasksList(Request $request): Response
56     {
57         $list = $this->getTasksList->execute($this->taskListFormatter);
58 
59         return new JsonResponse($list, Response::HTTP_OK);
60     }
61 }

Y el caso de uso será este:

 1 namespace App\Application;
 2 
 3 
 4 class GetTasksListHandler
 5 {
 6     public function execute(TaskListFormatter $taskListFormatter): array
 7     {
 8         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
 9 __, __METHOD__));
10     }
11 }

Y, de momento, la implementación que tenemos del formateador sería así:

 1 namespace App\Application;
 2 
 3 
 4 class TaskListFormatter
 5 {
 6     public function format(array $tasks): array
 7     {
 8         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
 9 __, __METHOD__));
10     }
11 }

Hemos vuelto a verde, y en este caso, como veremos, significa que ya hemos acabado con TodoListController. Veamos qué dice el test de aceptación.

El test de aceptación nos pide implementar el caso de uso. Así que tenemos que crear un nuevo test unitario.

 1 namespace App\Tests\Application;
 2 
 3 use App\Application\GetTasksListHandler;
 4 use App\Application\TaskListFormatter;
 5 use App\Domain\Task;
 6 use App\Domain\TaskRepository;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class GetTasksListHandlerTest extends TestCase
10 {
11 
12     /** @test */
13     public function shouldGetTheListOfTasks(): void
14     {
15         $tasks = [
16             new Task(1, 'Task 1'),
17             new Task(2, 'Task 2')
18         ];
19 
20         $expectedList = ['[√] Task 1', '[ ] Task 2'];
21 
22         $tasksRepository = $this->createMock(TaskRepository::class);
23         $tasksRepository->method('findAll')->willReturn($tasks);
24         
25         $formatter = $this->createMock(TaskListFormatter::class);
26         $formatter
27             ->expects(self::once())
28             ->method('format')
29             ->with($tasks)
30             ->willReturn($expectedList);
31 
32         $getTaskListHandler = new GetTasksListHandler($tasksRepository);
33         $list = $getTaskListHandler->execute($formatter);
34 
35         self::assertEquals($expectedList, $list);
36     }
37 }

Ejecutar el test nos revela la necesidad de implementar un método findAll en el repositorio. Una vez subsanado esto, nos tocará implementar el método execute del caso de uso:

 1 namespace App\Application;
 2 
 3 
 4 use App\Domain\TaskRepository;
 5 use App\Application\TaskListFormatter;
 6 
 7 class GetTasksListHandler
 8 {
 9     private TaskRepository $taskRepository;
10 
11     public function __construct(TaskRepository $taskRepository)
12     {
13         $this->taskRepository = $taskRepository;
14     }
15 
16     public function execute(TaskListFormatter $taskListFormatter): array
17     {
18         $tasks = $this->taskRepository->findAll();
19 
20         return $taskListFormatter->format($tasks);
21     }
22 }

Esta sencilla implementación nos lleva a verde y podemos volver a lanzar el test de aceptación. Estamos muy cerca ya del final. Pero tenemos que añadir el método findAll al repositorio concreto. Primero el test:

 1 namespace App\Tests\Infrastructure\Persistence;
 2 
 3 use App\Domain\Task;
 4 use App\Infrastructure\Persistence\FileTaskRepository;
 5 use App\Lib\FileStorageEngine;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class FileTaskRepositoryTest extends TestCase
 9 {
10     private FileStorageEngine $storageEngine;
11     private FileTaskRepository $taskRepository;
12 
13     protected function setUp(): void
14     {
15         $this->storageEngine = $this->createMock(FileStorageEngine::cla\
16 ss);
17         $this->taskRepository = new FileTaskRepository($this->storageEn\
18 gine);
19     }
20 
21 
22     /** @test */
23     public function shouldBeAbleToStoreTasks(): void
24     {
25         $task = new Task(1, 'TaskDescription');
26         $this->storageEngine
27             ->method('loadObjects')
28             ->with(Task::class)
29             ->willReturn([]);
30         $this->storageEngine
31             ->expects(self::once())
32             ->method('persistObjects')
33             ->with([1 => $task]);
34 
35         $this->taskRepository->store($task);
36     }
37 
38     /** @test */
39     public function shouldProvideNextIdentity(): void
40     {
41         $this->storageEngine
42             ->method('loadObjects')
43             ->with(Task::class)
44             ->willReturn([]);
45 
46         $id = $this->taskRepository->nextId();
47         self::assertEquals(1, $id);
48     }
49 
50     /** @test */
51     public function shouldRetrieveTasksById(): void
52     {
53         $task1 = new Task(1, 'Task 1');
54         $task2 = new Task(2, 'Task 2');
55         $this->storageEngine
56             ->method('loadObjects')
57             ->with(Task::class)
58             ->willReturn([1 => $task1, 2 => $task2]);
59 
60         $task = $this->taskRepository->retrieve(2);
61 
62         self::assertEquals($task2, $task);
63     }
64 
65     /** @test */
66     public function shouldRetrieveAllTasks(): void
67     {
68         $expectedTasks = [
69             1 => new Task(1, 'Task 1'),
70             2 => new Task(2, 'Task 2'),
71         ];
72 
73         $this->storageEngine
74             ->method('loadObjects')
75             ->with(Task::class)
76             ->willReturn($expectedTasks);
77 
78         $tasks = $this->taskRepository->findAll();
79 
80         self::assertEquals($expectedTasks, $tasks);
81     }
82 }

Test que se resuelve rápidamente con:

 1 namespace App\Infrastructure\Persistence;
 2 
 3 
 4 use App\Domain\Task;
 5 use App\Domain\TaskRepository;
 6 use App\Lib\FileStorageEngine;
 7 
 8 class FileTaskRepository implements TaskRepository
 9 {
10     private FileStorageEngine $storageEngine;
11 
12     public function __construct(FileStorageEngine $storageEngine)
13     {
14         $this->storageEngine = $storageEngine;
15     }
16 
17     public function store(Task $task): void
18     {
19         $tasks = $this->storageEngine->loadObjects(Task::class);
20         $tasks[$task->id()] = $task;
21         $this->storageEngine->persistObjects($tasks);
22     }
23 
24     public function nextId(): int
25     {
26         $tasks = $this->storageEngine->loadObjects(Task::class);
27 
28         return count($tasks) + 1;
29     }
30 
31     public function retrieve(int $taskId): Task
32     {
33         $tasks = $this->storageEngine->loadObjects(Task::class);
34 
35         return $tasks[$taskId];
36     }
37 
38     public function findAll(): array
39     {
40         return $this->storageEngine->loadObjects(Task::class);
41     }
42 }

Y volvemos a lanzar el test de aceptación para ver por dónde seguir. En esta ocasión el test nos dice que tenemos que implementar el método TaskListFormatter::format. Realmente estamos a dos pasos, pero tenemos que crear un test unitario.

En este punto podríamos plantear diversos diseños que eviten tratar temas de presentación en una entidad de dominio, pero para simplificar haremos que Task sea capaz de proporcionar su representación en forma de texto añadiendo un método asString.

Cabe preguntarse si aquí sería adecuado usar un doble de Task, algo que ya hicimos en otro test y esperar a que el test de aceptación nos pida desarrollar Task, o si sería preferible usar la entidad tal cual y que el test nos fuerce a introducir los métodos necesarios.

En la práctica, llegadas a este punto creo que todo depende de la complejidad que pueda suponer. En este ejercicio, el comportamiento de Task es bastante trivial, por lo que podríamos avanzar con la entidad sin más complicaciones. Pero si el comportamiento es complejo, posiblemente sea mejor ir despacio, trabajar con el mock y dedicarle el tiempo necesario después.

Así que aquí también usaremos mocks para eso.

 1 namespace App\Tests\Application\Formatter;
 2 
 3 use App\Domain\Task;
 4 use App\Application\TaskListFormatter;
 5 use PHPUnit\Framework\TestCase;
 6 
 7 class TaskListFormatterTest extends TestCase
 8 {
 9 
10     /** @test */
11     public function shouldFormatAListOfTasks(): void
12     {
13         $expected = [
14             '[√] 1. Task 1',
15             '[ ] 2. Task 2'
16         ];
17 
18         $task1 = $this->createMock(Task::class);
19         $task1->method('asString')->willReturn('[√] 1. Task 1');
20 
21         $task2 = $this->createMock(Task::class);
22         $task2->method('asString')->willReturn('[ ] 2. Task 2');
23 
24         $formatter = new TaskListFormatter();
25         $formattedList = $formatter->format([$task1, $task2]);
26 
27         self::assertEquals($expected, $formattedList);
28     }
29 }

Lanzamos el test para ver que falla porque no tenemos el método asString en Task. Así que lo introducimos. Fíjate que todavía no hemos implementado markCompleted.

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9 
10     public function __construct(int $id, string $description)
11     {
12         $this->id = $id;
13         $this->description = $description;
14     }
15 
16     public function id(): int
17     {
18         return $this->id;
19     }
20 
21     public function markCompleted(): void
22     {
23     }
24 
25     public function asString(): string
26     {
27         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
28 __, __METHOD__));
29     }
30 }

Al relanzar el test ya protesta porque no está implementado el método format, así que vamos a ello:

 1 namespace App\Application\Formatter;
 2 
 3 
 4 class TaskListFormatter
 5 {
 6     public function format(array $tasks): array
 7     {
 8         $formatted = [];
 9 
10         foreach ($tasks as $task) {
11             $formatted[] = $task->asString();
12         }
13 
14         return $formatted;
15     }
16 }

Y ya estamos en verde. Turno de volver al bucle del test de aceptación.

Últimos pasos

El test de aceptación, como cabía esperar, falla porque Task::asString no está implementado. También habíamos dejado Task:markCompleted sin implementar no haciendo nada. Podría ser buena idea dejar que se queje de nuevo y así asegurarnos de que se llama y no olvidarnos de gestionarlo también.

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9 
10     public function __construct(int $id, string $description)
11     {
12         $this->id = $id;
13         $this->description = $description;
14     }
15 
16     public function id(): int
17     {
18         return $this->id;
19     }
20 
21     public function markCompleted(): void
22     {
23         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
24 __, __METHOD__));
25     }
26 
27     public function asString(): string
28     {
29         throw new \RuntimeException(sprintf('Implement %s::%s', __CLASS\
30 __, __METHOD__));
31     }
32 }

Y al volver a lanzar el test de aceptación vemos que se queja de eso exactamente y que es ahí donde queríamos estar ahora.

Tenemos que seguir con el desarrollo de Task, usando un test unitario. Como no queremos añadir métodos, de momento, para verificar el estado de done, lo haremos a través de asString.

 1 namespace App\Tests\Domain;
 2 
 3 use App\Domain\Task;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class TaskTest extends TestCase
 7 {
 8 
 9     /** @test */
10     public function shouldHaveTextualRepresentation(): void
11     {
12         $task = new Task(1, 'Task Description');
13         
14         $formatted = $task->asString();
15         
16         self::assertEquals('[ ] 1. Task Description', $formatted);
17     }
18 }

Este test pasa. Por lo que hay que volver al test de aceptación.

Ahora el mensaje del test ha cambiado. Nos pide implementar markCompleted en Task, pero el test en sí ahora falla porque las respuestas no coinciden. Espera esto:

1 Array (
2     0 => '[√] 1. Write a test that fails'
3     1 => '[ ] 2. Write Production code ...t pass'
4     2 => '[ ] 3. Refactor if there is o...tunity'
5 )

Y obtiene esto:

1 Array (
2     0 => '[ ] 1. Write a test that fails'
3     1 => '[ ] 2. Write Production code ...t pass'
4     2 => '[ ] 3. Refactor if there is o...tunity'
5 )

A estas alturas, el motivo es obvio. No hay nada implementado en Task que se ocupe de mantener el estado de “done”.

Añadamos un caso más al test:

 1 namespace App\Tests\Domain;
 2 
 3 use App\Domain\Task;
 4 use PHPUnit\Framework\TestCase;
 5 
 6 class TaskTest extends TestCase
 7 {
 8 
 9     /** @test */
10     public function shouldHaveTextualRepresentation(): void
11     {
12         $task = new Task(1, 'Task Description');
13 
14         $formatted = $task->asString();
15 
16         self::assertEquals('[ ] 1. Task Description', $formatted);
17     }
18 
19     /** @test */
20     public function shouldHaveTextualRepresentationWhenDone(): void
21     {
22         $task = new Task(1, 'Task Description');
23         $task->markCompleted();
24         
25         $formatted = $task->asString();
26 
27         self::assertEquals('[√] 1. Task Description', $formatted);
28     }
29 }

Ahora lo implementamos:

 1 namespace App\Domain;
 2 
 3 
 4 class Task
 5 {
 6 
 7     private int $id;
 8     private string $description;
 9     private bool $done;
10 
11     public function __construct(int $id, string $description)
12     {
13         $this->id = $id;
14         $this->description = $description;
15         $this->done = false;
16     }
17 
18     public function id(): int
19     {
20         return $this->id;
21     }
22 
23     public function markCompleted(): void
24     {
25         $this->done = true;
26     }
27 
28     public function asString(): string
29     {
30         $done = $this->done ? '√' : ' ';
31         return sprintf('[%s] %s. %s', $done, $this->id, $this->descript\
32 ion);
33     }
34 }

Con el test en verde, volvemos a lanzar el test de aceptación y… ¡Sí! El test pasa sin ningún problema más: hemos terminado el desarrollo de nuestra aplicación.

Qué hemos aprendido con esta kata

  • La modalidad outside-in mockista parece contravenir las normas de TDD. Pese a ello, todo el proceso ha sido guiado por lo que nos indican los test.
  • El test de aceptación fallará mientras no se haya implementado todo lo necesario para ejecutar la aplicación.
  • Nos movemos siempre entre el loop del test de aceptación y el de cada uno de los tests unitarios que tendremos que usar para desarrollar los componentes.
  • Una vez que el test de aceptación pasa, la feature está completa, al menos en los términos que hayamos definido el test.
  • En los tests unitarios usamos mocks para definir la interfaz pública de cada componente en función de las necesidades de sus consumidores, lo que nos ayuda a mantener el principio de segregación de interfaces.