Lista de tareas, outside-in TDD por historias de usuario

En esta versión del mismo ejercicio de crear una aplicación usando TDD trabajaremos con el proyecto organizado en historias de usuario. Esto es: hemos dividido el proyecto en funcionalidades que aporten valor. El objetivo es mostrar una metodología de trabajo que podríamos llevar a la práctica en proyectos reales.

Este proyecto también lo haremos en PHP, usando PHPUnit y algunos componentes del framework Symfony. La resolución es un poco diferente a la que hicimos en un capítulo anterior, porque esta vez limitaremos el alcance de nuestro trabajo a la historia de usuario, lo que impone algunas restricciones que antes no se presentaban.

Añadir tareas a una lista

Repasemos la definición.

US 1

  • As a User
  • I want to add tasks to a to-do list
  • So that, I can organize my tasks

Para completar esta historia de usuario necesitaremos, aparte de un endpoint al que poder llamar y un controlador que lo gestione, un caso de uso para añadir tareas a la lista y un repositorio en el que guardarlas. Nuestro caso de uso va a ser un command, por lo que el resultado de la acción será una llamada al repositorio guardando cada nueva tarea.

Para poder verificar esto en un test no queremos escribir código que no vaya a ser necesario en producción. Por ejemplo, no vamos a desarrollar métodos (todavía) para recuperar información del repositorio. Estrictamente hablando, de momento no sabemos siquiera si las vamos a necesitar (spoiler: sí, pero eso sería programar para un futuro que aún no conocemos). Así que, inicialmente, usaremos un mock del repositorio y verificaremos que se hacen las llamadas adecuadas.

Una vez que tenemos esto claro, escribimos un test que enviará un POST al endpoint para crear una tarea nueva y verificará que, en algún momento, estamos llamando a un repositorio de tareas, confiando en que la implementación real lo gestionará correctamente cuando esté disponible.

Suele ser buena idea, empezar el test por el final, es decir, por lo que esperamos, y construir el resto con las acciones necesarias. En este caso, esperamos la existencia de un TaskRepository, que será una interfaz por el momento. También introducimos el concepto de Task.

 1 namespace App\Tests\Katas\TodoList;
 2 
 3 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 4 
 5 class TodoListAcceptanceTest extends WebTestCase
 6 {
 7     /** @test */
 8     public function asUserIWantToAddTaskToAToDoList(): void
 9     {
10         $taskRepository = $this->createMock(TaskRepository::class);
11         $task = new Task(1, 'Write a test that fails');
12         $taskRepository
13             ->expects(self::once())
14             ->method('store')
15             ->with($task);
16 
17         $client = self::createClient();
18 
19         $client->getContainer()->set(TaskRepository::class, $taskReposi\
20 tory);
21 
22         $client->request(
23             'POST',
24             '/api/todo',
25             [],
26             [],
27             ['CONTENT-TYPE' => 'json/application'],
28             json_encode(['task' => 'Write a test that fails'], JSON_THR\
29 OW_ON_ERROR)
30         );
31     }
32 
33     protected function setUp(): void
34     {
35         $this->resetRepositoryData();
36     }
37 
38     protected function tearDown(): void
39     {
40         $this->resetRepositoryData();
41     }
42 
43     private function resetRepositoryData(): void
44     {
45         if (file_exists('repository.data')) {
46             unlink('repository.data');
47         }
48     }
49 }

Tendremos que ejecutar el test e implementar todo lo que nos vaya pidiendo hasta lograr que falle por la razón adecuada.

El primer mensaje de error es que no tenemos definido TaskRepository, así que empezamos por ahí:

1 Cannot stub or mock class or interface "App\Tests\Katas\TodoList\TaskRe\
2 pository" which does not exist

Este error en concreto es específico de PHP y PHPUnit. En otros lenguajes podrías encontrar un error diferente.

De momento mi solución es iniciarlo en el mismo test, si el mensaje de error cambia, entonces lo moveré a su propio archivo.

1 interface TaskRepository
2 {
3 
4 }

El test ahora falla por una razón diferente, así que hemos pasado este escollo. Usamos el refactor Move Class para poner TaskRepository en App\TodoList\Domain\TaskRepository y lanzamos nuevamente los tests, obteniendo el siguiente error, que es:

1 Error : Class 'App\Tests\Katas\TodoList\Task' not found

Que nos está diciendo que no hemos definido la clase Task. De momento, crearemos Task en el mismo archivo, relanzando el test para ver si cambia el error.

1 class Task
2 {
3     
4 }

Ahora el error nos indica que no existe un método store en TaskRepository, por lo que no se puede mockear. Tenemos que introducirlo, pero antes, moveremos Task a su lugar en App\TodoList\Domain. Como puedes ver, estamos organizando el código conforme a una arquitectura en capas.

Tras mover Task, añadimos el método store en TaskRepository:

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

El siguiente error es algo más extraño:

1 Symfony\Component\Config\Exception\LoaderLoadException : The file "../s\
2 rc/Controller" does not exist
3  (in: /application/config) in /application/config/services.yaml 
4  (which is loaded in resource "/application/config/services.yaml").

Tiene que ver con la configuración de Symfony, el framework de PHP que estamos usando para este ejercicio. Este mensaje nos indica que no hay archivos que contengan controladores en el path y namespace indicados. De hecho, yo tampoco no los quiero ahí, sino en App\TodoList\Infrastructure\EntryPoint\Api. Esto es porque quiero mantener esa arquitectura limpia, con los componentes organizados en capas. Los controladores y los puntos de entrada a la aplicación están en la capa de infraestructura, dentro de una categoría EntryPoint que, en este caso, tiene un “puerto” relacionado con la comunicación mediante Api.

Para lograr esto, no tenemos más que ir al archivo config/services.yaml y cambiar lo necesario:

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\TodoList\Infrastructure\EntryPoint\Api\:
6         resource: '../src/TodoList/Infrastructure/EntryPoint/Api'
7         tags: ['controller.service_arguments']

Al ejecutar el test, tendremos un error semejante:

1 Symfony\Component\Config\Exception\LoaderLoadException : The file "../s\
2 rc/TodoList/Infrastructure/EntryPoint/Api" 
3  does not exist (in: /application/config) in /application/config/servic\
4 es.yaml
5  (which is loaded in resource "/application/config/services.yaml").

Es positivo porque refleja que hemos hecho el cambio de services.yaml correctamente, pero aún no hemos añadido un controlador en la ubicación deseada que se pueda cargar y evitar el error. Así que añadimos un archivo TodoListController, en la ubicación definida.

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

Al ejecutar el test obtenemos dos nuevos mensajes de error. Por un lado:

1 "No route found for "POST /api/todo""

Nos indica un problema en el framework, ya que el cliente HTTP del test está llamando a un endpoint que aún no hemos definido en ninguna parte. Lo resolvemos configurando lo necesario en routes.yaml:

1 api_add_task:
2   path: /api/todo
3   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
4 ller::addTask
5   methods: ['POST']

Como hacemos después de un cambio, ejecutamos el test, que ahora se quejará de que no existe un método en el controlador encargado de responder a este endpoint.

1 "The controller for URI "/api/todo" is not callable. 
2 Expected method "addTask" on class "App\TodoList\Infrastructure\EntryPo\
3 int\Api\TodoListController"...

Lo implementamos así:

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

Es una simple línea que lanza una excepción para indicar que el método no está implementado. Esto lo hacemos para que el propio test nos indique que tenemos algo sin implementar. En este caso concreto, un cuerpo vacío no nos indicaría nada y, en muchos casos, sería fácil perder la pista de lo que tenemos pendiente de escribir.

De hecho, si lanzamos el test nos indica justamente ese error.

1 RuntimeException: "Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::addTask"

Pero también este otro, que es propio del test:

1 Expectation failed for method name is equal to 'store' when invoked 1 t\
2 ime(s).
3 Method was expected to be called 1 times, actually called 0 times.

Este error es el que esperaríamos del test tal como lo hemos definido. Ya no hay errores de configuración del framework. Nos dice que nunca llega a intentarse guardar una Task en el repositorio, que es como decir, que no hay código de producción que haga lo que deseamos.

Estos dos errores juntos nos indican momento de implementar.

Y para hacerlo, necesitamos avanzar un paso hacia el interior de nuestra aplicación, que en nuestro ejemplo es TodoListController. En este punto abandonamos el ciclo del test de aceptación y entramos en un ciclo de test unitarios para desarrollar TodoListController::addTask.

Diseñando en rojo

El test de aceptación no está pasando, y nos está pidiendo que implementemos algo en TodoListController. Para hacerlo, lo que vamos a hacer es pensar cómo queremos que sea el controlador y si delegará en otros objetos el trabajo.

En particular, queremos que el controlador sea una capa muy fina que se encargue de:

  • Obtener la información necesaria de la request
  • Pasársela a un caso de uso para que haga lo que sea necesario
  • Obtener la respuesta del caso de uso y enviarla como respuesta del endpoint

En un enfoque clásico, implementaríamos la solución completa en el controlador y luego iríamos moviendo la lógica a los componentes necesarios.

En lugar de eso, en el enfoque mockista, diseñamos cómo va a ser ese nivel de implementación y usamos dobles para los colaboradores que vayamos necesitando. Por ejemplo, este es nuestro test:

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

En este test se verifican dos cosas. Por un lado, que devolvemos una respuesta con código 201 (recurso creado) y que tendremos un caso de uso llamado AddTaskHandler que se encarga de procesar la creación de la tarea a partir de su descripción, que recibe como payload en la request.

Al ejecutar el test, empezamos a obtener los errores esperados. El primero es que no tenemos ningún AddTaskHandler. De nuevo, empezaré añadiéndolo en el archivo del test y lo moveré en el siguiente paso. De hecho, es literalmente lo que indica el error:

1 Cannot stub or mock class or interface "App\Tests\TodoList\Infrastructu\
2 re\EntryPoint\Api\AddTaskHandler" 
3 which does not exist

Así que, añadimos:

1 class AddTaskHandler
2 {
3 
4 }

Al ejecutar ahora el test, nos pide incorporar el método execute, que aún no está definido. Antes de añadirlo, vamos a mover AddTaskHandler, que es el caso de uso, a su lugar en la capa de aplicación: App\TodoList\Application. A continuación, añadimos el método incluyendo nuestra excepción de no implementado.

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

De este modo, lo que ocurrirá es lo siguiente: una vez que hayamos implementado el controlador, veremos que su test unitario pasa, puesto que estamos usando el doble de AddTaskHandler y no llamamos al código real. Esto ocurrirá al lanzar el test de aceptación, lo que nos estará indicando que deberíamos implementar AddTaskHandler y profundizar un nivel más en la aplicación.

El siguiente fallo es conocido:

1 RuntimeException : Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::addTask

Lo que nos indica que el test ya está llamando al método addTask, que aún no está implementado. Es justo donde queríamos estar. En TodoListController::addTask implementaremos lógica que haga pasar el test:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\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     /** @var AddTaskHandler */
13     private AddTaskHandler $addTaskHandler;
14 
15     public function __construct(AddTaskHandler $addTaskHandler)
16     {
17         $this->addTaskHandler = $addTaskHandler;
18     }
19 
20     public function addTask(Request $request): Response
21     {
22         $payload = json_decode($request->getContent(), true, 512, JSON_\
23 THROW_ON_ERROR);
24 
25         $this->addTaskHandler->execute($payload['task']);
26         return new JsonResponse('', Response::HTTP_CREATED);
27     }
28 }

¡El test pasa!

Podríamos haber ido más despacio aquí para dirigir la implementación con pasos más pequeños, pero creo que es mejor hacerlo en uno solo porque la lógica no es muy compleja y así no nos vamos mucho por las ramas. Lo importante, en todo caso, es que hemos cumplido con el objetivo de desarrollar este controlador con un test unitario que ahora mismo pasa.

Como el test unitario ya pasa, no tenemos más que hacer en este nivel. En todo caso, voy a hacer un pequeño refactor para ocultar los detalles de la obtención del payload de la request, lo que deja el cuerpo del controlador un poco más limpio y fácil de seguir.

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\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     /** @var AddTaskHandler */
12     private AddTaskHandler $addTaskHandler;
13 
14     public function __construct(AddTaskHandler $addTaskHandler)
15     {
16         $this->addTaskHandler = $addTaskHandler;
17     }
18 
19     public function addTask(Request $request): Response
20     {
21         $payload = $this->obtainPayload($request);
22 
23         $this->addTaskHandler->execute($payload['task']);
24 
25         return new JsonResponse('', Response::HTTP_CREATED);
26     }
27 
28     private function obtainPayload(Request $request): array
29     {
30         return json_decode($request->getContent(), true, 512, JSON_THRO\
31 W_ON_ERROR);
32     }
33 }

Volviendo al test de aceptación

Una vez que hemos hecho pasar el test unitario, tenemos que volver al nivel de aceptación para que nos diga como seguir. Lo ejecutamos y obtenemos lo siguiente:

1 RuntimeException: "Implement App\TodoList\Application\AddTaskHandler::A\
2 pp\TodoList\Application\AddTaskHandler::execute" 

Ahora nos toca internarnos un poco más en la aplicación y movernos al caso de uso AddTaskHandler. Lo que esperamos de este UseCase es que use la información recibida para crear una tarea y la guarde en TaskRepository.

Para crear una tarea, necesitaremos asignarle un ID, el cual le vamos a pedir al propio repositorio que tendrá un método a propósito.

Esto lo podemos expresar con el siguiente test unitario.

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

Ejecutamos el test. Obtenemos primero este error:

1 Trying to configure method "nextIdentity" which cannot be configured be\
2 cause it does not exist, has not been specified, is final, or is static

Añadimos el método en la interfaz:

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

Lo que genera este error:

1 RuntimeException : Implement App\TodoList\Application\AddTaskHandler::A\
2 pp\TodoList\Application\AddTaskHandler::execute

Y estamos listos para implementar el caso de uso. Este código debería bastar:

 1 namespace App\TodoList\Application;
 2 
 3 use App\TodoList\Domain\Task;
 4 use App\TodoList\Domain\TaskRepository;
 5 
 6 class AddTaskHandler
 7 {
 8     /** @var TaskRepository */
 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->nextIdentity();
19 
20         $task = new Task($id, $taskDescription);
21 
22         $this->taskRepository->store($task);
23     }
24 }

El código es suficiente para hacer pasar el test, por lo que podemos volver al nivel de aceptación.

Nuevo ciclo

Al relanzar el test de aceptación nos encontramos que este pasa. Sin embargo, la historia de usuario no está implementada aún, ya que no tenemos un repositorio concreto en el que se estén guardando Task. De hecho, nuestras clases Task no tienen ningún código todavía.

El motivo es que estamos usando un mock de TaskRepository en el test de aceptación. Nos interesaría dejar de usarlo para que TodoList utilice una implementación concreta. El problema que tendríamos ahora es que de momento no vamos a tener métodos con los que explorar el contenido del repositorio y verificar el test. Vamos a hacer esto en dos fases.

En la primera simplemente eliminamos el uso del mock y verificamos que la respuesta del API devuelve el código 201 (created).

 1 namespace App\Tests\Katas\TodoList;
 2 
 3 use App\TodoList\Domain\Task;
 4 use App\TodoList\Domain\TaskRepository;
 5 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 6 use Symfony\Component\HttpFoundation\Response;
 7 
 8 class TodoListAcceptanceTest extends WebTestCase
 9 {
10     /** @test */
11     public function asUserIWantToAddTaskToAToDoList(): void
12     {
13         $client = self::createClient();
14 
15         $client->request(
16             'POST',
17             '/api/todo',
18             [],
19             [],
20             ['CONTENT-TYPE' => 'json/application'],
21             json_encode(['task' => 'Write a test that fails'], JSON_THR\
22 OW_ON_ERROR)
23         );
24 
25         $response = $client->getResponse();
26 
27         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
28 Code());
29     }
30 
31 
32     protected function setUp(): void
33     {
34         $this->resetRepositoryData();
35     }
36 
37     protected function tearDown(): void
38     {
39         $this->resetRepositoryData();
40     }
41 
42     private function resetRepositoryData(): void
43     {
44         if (file_exists('repository.data')) {
45             unlink('repository.data');
46         }
47     }
48 }

Antes de continuar, tenemos que eliminar la definición del servicio que hicimos antes en services_test.yaml. Como es el único que tenemos declarado aquí, podemos eliminar el archivo sin problema.

Y al ejecutar el test, nos aparece el siguiente error del framework:

1 Cannot autowire service "App\TodoList\Application\AddTaskHandler": argu\
2 ment "$taskRepository" of method "__construct()"

Esto ocurre porque solo tenemos una interfaz de TaskRepository y necesitaríamos una implementación concreta que usar. De este modo, tenemos un error que nos permite avanzar en el desarrollo. Necesitaremos un test para implementar FileTaskRepository, un repositorio basado en un sencillo archivo de texto para guardar los objetos serializados:

 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 }

En primer lugar, vamos a crear una implementación por defecto para FileTaskRepository en su lugar, que será App\TodoList\Infrastructure\Persistence:

 1 namespace App\TodoList\Infrastructure\Persistence;
 2 
 3 
 4 use App\TodoList\Domain\Task;
 5 use App\TodoList\Domain\TaskRepository;
 6 
 7 class FileTaskRepository implements TaskRepository
 8 {
 9 
10     public function store(Task $task): void
11     {
12         throw new \RuntimeException('Implement store() method.');
13     }
14 
15     public function nextIdentity(): int
16     {
17         throw new \RuntimeException('Implement nextIdentity() method.');
18     }
19 }

Al volver a ejecutar el test de aceptación se producen dos errores. Uno nos dice que tenemos que implementar el método nextIdentity del repositorio. El otro, que es un error propio del test, nos informa de que el endpoint devuelve el código 500 en lugar de 201. Es lógico porque la implementación que tenemos ahora de FileTaskRepository fallará de forma fatal.

Pero es una buena noticia, porque nos dice por dónde seguir. Así que crearemos un nuevo test unitario para guiar el desarrollo de FileTaskRepository. En este test simulamos distinto número de objetos en el almacenamiento para asegurar la implementación correcta.

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

Con este test pasando, volvemos al test de aceptación, que vuelve a fallar. El endpoint devuelve un error 500 porque no tenemos una implementación del método store en FileTaskRepository.

Introduciremos un nuevo test, aunque antes lo hemos refactorizado un poco a fin de que sea más fácil introducir los cambios:

 1 namespace App\Tests\TodoList\Infrastructure\Persistence;
 2 
 3 use App\Lib\FileStorageEngine;
 4 use App\TodoList\Domain\Task;
 5 use App\TodoList\Domain\TaskRepository;
 6 use App\TodoList\Infrastructure\Persistence\FileTaskRepository;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class FileTaskRepositoryTest extends TestCase
10 {
11     private FileStorageEngine $fileStorageEngine;
12     private TaskRepository $taskRepository;
13 
14     public function setUp(): void
15     {
16         $this->fileStorageEngine = $this->createMock(FileStorageEngine:\
17 :class);
18         $this->taskRepository = new FileTaskRepository($this->fileStora\
19 geEngine);
20     }
21 
22     /** @test */
23     public function shouldProvideNextIdentityCountingExistingObjects():\
24  void
25     {
26         $this->fileStorageEngine
27             ->method('loadObjects')
28             ->willReturn(
29                 [],
30                 ['Task'],
31                 ['Task', 'Task']
32             );
33 
34         self::assertEquals(1, $this->taskRepository->nextIdentity());
35         self::assertEquals(2, $this->taskRepository->nextIdentity());
36         self::assertEquals(3, $this->taskRepository->nextIdentity());
37     }
38 
39     /** @test */
40     public function shouldStoreATask(): void
41     {
42         $task = new Task(1, 'Task Description');
43 
44         $this->fileStorageEngine
45             ->method('loadObjects')
46             ->willReturn([]);
47         $this->fileStorageEngine
48             ->expects(self::once())
49             ->method('persistObjects')
50             ->with([1 => $task]);
51 
52         $this->taskRepository->store($task);
53     }
54 }

Esta es nuestra implementación para pasar el test:

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

Tenemos que implementar el método Task::id, lo que nos hace introducir también un constructor:

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

La implementación hace pasar el test. Para no alargarnos no introduciré más ejemplos, que sería lo propio para tener más confianza en el comportamiento del test. Pero de momento nos vale para entender el proceso.

Como estamos en verde, volvemos al test de aceptación para comprobar qué avances hemos tenido. Y al ejecutarlo, el test de aceptación pasa, indicando que la feature está completa. O casi, ya que por el momento no tenemos forma de saber si las tareas se han almacenado o no.

Una posibilidad es obtener el contenido de FileStorageEngine y ver si allí se encuentran nuestras tareas. No nos obliga a implementar nada en el código de producción:

 1 namespace App\Tests\Katas\TodoList;
 2 
 3 use App\Lib\FileStorageEngine;
 4 use App\TodoList\Domain\Task;
 5 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 6 use Symfony\Component\HttpFoundation\Response;
 7 
 8 class TodoListAcceptanceTest extends WebTestCase
 9 {
10     /** @test */
11     public function asUserIWantToAddTaskToAToDoList(): void
12     {
13         $client = self::createClient();
14 
15         $client->request(
16             'POST',
17             '/api/todo',
18             [],
19             [],
20             ['CONTENT-TYPE' => 'json/application'],
21             json_encode(['task' => 'Write a test that fails'], JSON_THR\
22 OW_ON_ERROR)
23         );
24 
25         $response = $client->getResponse();
26 
27         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
28 Code());
29 
30         $storage = new FileStorageEngine('repository.data');
31         $tasks = $storage->loadObjects(Task::class);
32 
33         self::assertCount(1, $tasks);
34         self::assertEquals(1, $tasks[1]->id());
35     }
36 
37 
38     protected function setUp(): void
39     {
40         $this->resetRepositoryData();
41     }
42 
43     protected function tearDown(): void
44     {
45         $this->resetRepositoryData();
46     }
47 
48     private function resetRepositoryData(): void
49     {
50         if (file_exists('repository.data')) {
51             unlink('repository.data');
52         }
53     }
54 }

El test verifica que hemos guardado una tarea en el repositorio, confirmando que la primera historia de usuario está implementada. Puede ser buen momento para examinar lo que hemos hecho y ver si podemos hacer algún refactor que pueda facilitar los siguientes pasos del desarrollo.

Empecemos con el test de aceptación:

 1 namespace App\Tests\Katas\TodoList;
 2 
 3 use App\Lib\FileStorageEngine;
 4 use App\TodoList\Domain\Task;
 5 use Symfony\Bundle\FrameworkBundle\Client;
 6 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
 7 use Symfony\Component\HttpFoundation\Response;
 8 
 9 class TodoListAcceptanceTest extends WebTestCase
10 {
11     private Client $client;
12 
13     /** @test */
14     public function asUserIWantToAddTaskToAToDoList(): void
15     {
16         $response = $this->whenWeRequestToCreateATaskWithDescription('W\
17 rite a test that fails');
18 
19         $this->thenResponseShouldBeSuccesful($response);
20 
21         $this->thenTheTaskIsStored();
22     }
23 
24 
25     protected function setUp(): void
26     {
27         $this->resetRepositoryData();
28 
29         $this->client = self::createClient();
30     }
31 
32     protected function tearDown(): void
33     {
34         $this->resetRepositoryData();
35     }
36 
37     private function resetRepositoryData(): void
38     {
39         if (file_exists('repository.data')) {
40             unlink('repository.data');
41         }
42     }
43 
44     private function whenWeRequestToCreateATaskWithDescription(string $\
45 taskDescription): Response
46     {
47         $this->client->request(
48             'POST',
49             '/api/todo',
50             [],
51             [],
52             ['CONTENT-TYPE' => 'json/application'],
53             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERR\
54 OR)
55         );
56 
57         return $this->client->getResponse();
58     }
59 
60     private function thenResponseShouldBeSuccesful(Response $response):\
61  void
62     {
63         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
64 Code());
65     }
66 
67     private function thenTheTaskIsStored(): void
68     {
69         $storage = new FileStorageEngine('repository.data');
70         $tasks = $storage->loadObjects(Task::class);
71 
72         self::assertCount(1, $tasks);
73         self::assertEquals(1, $tasks[1]->id());
74     }
75 }

TodoListControllerTest:

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

Hay otros pequeños cambios en archivos, pero no los vamos a detallar aquí.

Ver las tareas de la lista

US 2

  • As a User
  • I want to see the task in my to-do list
  • So that, I can know what I have to do next

Nuestra segunda historia requiere su propio endpoint, controlador y caso de uso. Ya tenemos un repositorio de tareas, al cual tendremos que añadir un método con el que obtener las lista completa.

Como tenemos una implementación real del repositorio ya no tenemos que usar un mock como nos hizo falta antes para poder arrancar el desarrollo. En una situación en la que estuviésemos usando una persistencia en base de datos o similar, posiblemente necesitaríamos una implementación fake, como un repositorio en memoria o incluso este simple repositorio en archivo que estamos utilizando, que necesitamos por el problema de la persistencia entre requests de PHP.

Esta es la primera versión del test de aceptación para esta historia de usuario:

  1 namespace App\Tests\Katas\TodoList;
  2 
  3 use App\Lib\FileStorageEngine;
  4 use App\TodoList\Domain\Task;
  5 use Symfony\Bundle\FrameworkBundle\Client;
  6 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  7 use Symfony\Component\HttpFoundation\Response;
  8 
  9 class TodoListAcceptanceTest extends WebTestCase
 10 {
 11     private Client $client;
 12 
 13     /** @test */
 14     public function asUserIWantToAddTaskToAToDoList(): void
 15     {
 16         $response = $this->whenWeRequestToCreateATaskWithDescription('W\
 17 rite a test that fails');
 18 
 19         $this->thenResponseShouldBeSuccesful($response);
 20 
 21         $this->thenTheTaskIsStored();
 22     }
 23 
 24     /** @test */
 25     public function asUserIWantToSeeTheTasksInMyTodoList(): void
 26     {
 27         $expectedList = [
 28             '[ ] 1. Write a test tha fails',
 29             '[ ] 2. Write code to make the test pass'
 30         ];
 31         
 32         $this->apiCreateTaskWithDescription('Write a test tha fails');
 33         $this->apiCreateTaskWithDescription('Write code to make the tes\
 34 t pass');
 35         
 36         $this->client->request(
 37             'GET',
 38             '/api/todo'
 39         );
 40 
 41         $response =  $this->client->getResponse();
 42         
 43         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
 44 ));
 45         
 46         $taskList = json_decode($response->getContent(), true);
 47         
 48         self::assertEquals($expectedList, $taskList);
 49     }
 50 
 51     protected function setUp(): void
 52     {
 53         $this->resetRepositoryData();
 54 
 55         $this->client = self::createClient();
 56     }
 57 
 58     protected function tearDown(): void
 59     {
 60         $this->resetRepositoryData();
 61     }
 62 
 63     private function resetRepositoryData(): void
 64     {
 65         if (file_exists('repository.data')) {
 66             unlink('repository.data');
 67         }
 68     }
 69 
 70     private function whenWeRequestToCreateATaskWithDescription(string $\
 71 taskDescription): Response
 72     {
 73         return $this->apiCreateTaskWithDescription($taskDescription);
 74     }
 75 
 76     private function thenResponseShouldBeSuccesful(Response $response):\
 77  void
 78     {
 79         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
 80 Code());
 81     }
 82 
 83     private function thenTheTaskIsStored(): void
 84     {
 85         $storage = new FileStorageEngine('repository.data');
 86         $tasks = $storage->loadObjects(Task::class);
 87 
 88         self::assertCount(1, $tasks);
 89         self::assertEquals(1, $tasks[1]->id());
 90     }
 91 
 92     private function apiCreateTaskWithDescription(string $taskDescripti\
 93 on): Response
 94     {
 95         $this->client->request(
 96             'POST',
 97             '/api/todo',
 98             [],
 99             [],
100             ['CONTENT-TYPE' => 'json/application'],
101             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERR\
102 OR)
103         );
104 
105         return $this->client->getResponse();
106     }
107 }

Así que lo ejecutamos y, como antes, nos vamos fijando en los errores que lanza para arreglarlos hasta que el test falle por las razones correctas. En este caso podemos ver dos errores relacionados.

El primero es que no hay una ruta adecuada para el endpoint.

1 "No route found for "GET /api/todo": Method Not Allowed (Allow: POST)"

Lo que, por supuesto, causa el error en el test al verificar el código de estado:

1 Failed asserting that 405 matches expected 200.

Configuramos la ruta en routes.yaml:

1 api_add_task:
2   path: /api/todo
3   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
4 ller::addTask
5   methods: ['POST']

Lanzamos el test. El error es diferente, lo que indica que hemos hecho el cambio correctamente, pero ahora nos hace falta el controlador específico:

1 "The controller for URI "/api/todo" is not callable. Expected method "g\
2 etTaskList" on class "App\TodoList\Infrastructure\EntryPoint\Api\TodoLi\
3 stController"

Así que añadimos nuestra implementación vacía inicial:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\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     /** @var AddTaskHandler */
12     private AddTaskHandler $addTaskHandler;
13 
14     public function __construct(AddTaskHandler $addTaskHandler)
15     {
16         $this->addTaskHandler = $addTaskHandler;
17     }
18 
19     public function addTask(Request $request): Response
20     {
21         $payload = $this->obtainPayload($request);
22 
23         $this->addTaskHandler->execute($payload['task']);
24 
25         return new JsonResponse('', Response::HTTP_CREATED);
26     }
27 
28     public function getTaskList(): Response
29     {
30         throw new \RuntimeException(sprintf('Implement %s', __METHOD__)\
31 );
32     }
33 
34     private function obtainPayload(Request $request): array
35     {
36         return json_decode($request->getContent(), true, 512, JSON_THRO\
37 W_ON_ERROR);
38     }
39 }

Al volver a lanzar el test, se lanza la excepción que nos indica que necesitamos implementar algo. Es el momento de volver al test unitario de TodoListController. Es importante aprender a identificar cuando tenemos que movernos entre el ciclo del test de aceptación y el ciclo de tests unitarios.

El nuevo test nos ayuda a introducir el nuevo caso de uso GetTaskListHandler, pero también nos plantea un problema interesante: ¿qué debería devolver GetTaskListHandler, objetos Task o una representación de estos?

En este caso, lo más correcto sería utilizar algún tipo de DataTransformer y aplicar un patrón Strategy de modo que TodoListController le indique al caso de uso qué DataTransformer quiere usar. Este transformer se puede pasar como dependencia al controlador y este se lo enviará al caso de uso como parámetro.

Como puedes ver, ahora estamos literalmente diseñando. Así que vamos a ver cómo queda el test.

 1 namespace App\Tests\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 use App\TodoList\Application\AddTaskHandler;
 4 use App\TodoList\Infrastructure\EntryPoint\Api\TodoListController;
 5 use PHPUnit\Framework\TestCase;
 6 use Symfony\Component\HttpFoundation\Request;
 7 
 8 class TodoListControllerTest extends TestCase
 9 {
10     private const TASK_DESCRIPTION = 'Task Description';
11     private AddTaskHandler $addTaskHandler;
12     private TodoListController $todoListController;
13     private GetTaskListHandler $getTaskListHandler;
14     private TaskListTransformer $taskListTransformer;
15 
16     protected function setUp(): void
17     {
18         $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
19 );
20         $this->getTaskListHandler = $this->createMock(GetTaskListHandle\
21 r::class);
22         $this->taskListTransformer = $this->createMock(TaskListTransfor\
23 mer::class);
24         $this->todoListController = new TodoListController(
25             $this->addTaskHandler,
26             $this->getTaskListHandler,
27             $this->taskListTransformer
28         );
29     }
30 
31     /** @test */
32     public function shouldAddTask(): void
33     {
34         $this->addTaskHandler
35             ->expects(self::once())
36             ->method('execute')
37             ->with(self::TASK_DESCRIPTION);
38 
39         $request = new Request(
40             [],
41             [],
42             [],
43             [],
44             [],
45             ['CONTENT-TYPE' => 'json/application'],
46             json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_\
47 ON_ERROR)
48         );
49 
50         $response = $this->todoListController->addTask($request);
51 
52         self::assertEquals(201, $response->getStatusCode());
53     }
54 
55     /** @test */
56     public function shouldGetTaskList(): void
57     {
58         $expectedList = [
59             '[ ] 1. Task Description',
60             '[ ] 2. Task Description',
61         ];
62         $this->getTaskListHandler
63             ->expects(self::once())
64             ->method('execute')
65             ->with($this->taskListTransformer)
66             ->willReturn($expectedList);
67 
68         $response = $this->todoListController->getTaskList(new Request(\
69 ));
70 
71         self::assertEquals(200, $response->getStatusCode());
72 
73         $list = json_decode($response->getContent(), true);
74 
75         self::assertEquals($expectedList, $list);
76     }
77 }

En este punto, solo necesitamos TaskListTransformer para que el controlador lo pase al caso de uso. Si lanzamos el test, fallará porque no tenemos aún definida la clase GetTaskListHandler. Introducimos una implementación inicial.

1 class GetTaskListHandler
2 {
3     
4 }

Lanzando el test de nuevo, vemos que ahora nos pide TaskListTransformer. Primero movemos GetTaskListHandler a su lugar en App\TodoList\Application. Luego creamos TaskListTransformer.

1 class TaskListTransformer
2 {
3     
4 }

Comprobamos de nuevo el resultado del test, que ahora nos dice que nos falta un método execute en GetTaskListHandler. Igual que hicimos antes, movemos primero la clase TaskListTransformer a su lugar.

En principio yo lo introduciría en App\TodoList\Infrastructure\EntryPoint\Api puesto que la razón de ser del transformer es preparar una respuesta específica para la API. Pero eso sería para la implementación concreta que vayamos a usar. Si lo hacemos así tendremos una dependencia mal orientada, pues estaría apuntando de Aplicación a Infraestructura. Para invertirla, tendremos que poner TaskListTransformer en la capa de aplicación como interface. Su lugar sería: App\TodoList\Application\TaskListTransformer.

Una vez recolocado nos ocupamos de añadir el método execute en GetTaskListHandler.

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

Con este añadido, al ejecutar el test conseguimos que falle porque vemos que ha saltado la excepción que nos pide implementar getTaskList en el controlador:

1 RuntimeException : Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::getTaskList

Y podemos implementar lo necesario para que pase el test:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\Application\AddTaskHandler;
 5 use App\TodoList\Application\GetTaskListHandler;
 6 use App\TodoList\Application\TaskListTransformer;
 7 use Symfony\Component\HttpFoundation\JsonResponse;
 8 use Symfony\Component\HttpFoundation\Request;
 9 use Symfony\Component\HttpFoundation\Response;
10 
11 class TodoListController
12 {
13     private AddTaskHandler $addTaskHandler;
14     private GetTaskListHandler $getTaskListHandler;
15     private TaskListTransformer $taskListTransformer;
16 
17     public function __construct(
18         AddTaskHandler $addTaskHandler,
19         GetTaskListHandler $getTaskListHandler,
20         TaskListTransformer $taskListTransformer
21     ) {
22         $this->addTaskHandler = $addTaskHandler;
23         $this->getTaskListHandler = $getTaskListHandler;
24         $this->taskListTransformer = $taskListTransformer;
25     }
26 
27     public function addTask(Request $request): Response
28     {
29         $payload = $this->obtainPayload($request);
30 
31         $this->addTaskHandler->execute($payload['task']);
32 
33         return new JsonResponse('', Response::HTTP_CREATED);
34     }
35 
36     public function getTaskList(Request $request): Response
37     {
38         $taskList = $this->getTaskListHandler->execute($this->taskListT\
39 ransformer);
40 
41         return new JsonResponse($taskList, Response::HTTP_OK);
42     }
43 
44     private function obtainPayload(Request $request): array
45     {
46         return json_decode($request->getContent(), true, 512, JSON_THRO\
47 W_ON_ERROR);
48     }
49 }

Se puede observar que el controlador tiene muchas dependencias. Esto se puede solucionar con un bus de comandos o dividiendo la clase en otras más pequeñas, pero no lo vamos a hacer en este ejercicio para no perder el foco.

En cualquier caso, el test pasa, lo que nos indica que es el momento de moverse de nuevo al ciclo del test de aceptación.

Este seguirá fallando, como cabría esperar:

1 PHP Exception RuntimeException: "Implement App\TodoList\Application\Get\
2 TaskListHandler::execute" 

Fallo que nos dice que el siguiente paso es desarrollar con un test unitario el caso de uso GetTaskListHandler.

 1 namespace App\Tests\TodoList\Application;
 2 
 3 use App\TodoList\Application\GetTaskListHandler;
 4 use App\TodoList\Application\TaskListTransformer;
 5 use App\TodoList\Domain\Task;
 6 use App\TodoList\Domain\TaskRepository;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class GetTaskListHandlerTest extends TestCase
10 {
11     /** @test */
12     public function shouldGetExistingTasks(): void
13     {
14         $expectedList = [
15             '[ ] 1. Write a test that fails',
16             '[ ] 2. Write code to make the test pass',
17         ];
18 
19         $taskList = [
20             new Task(1, 'Write a test that fails'),
21             new Task(2, 'Write code to make the test pass'),
22         ];
23 
24         $tasksRepository = $this->createMock(TaskRepository::class);
25         $tasksRepository
26             ->method('findAll')
27             ->willReturn($taskList);
28 
29         $taskListTransformer = $this->createMock(TaskListTransformer::c\
30 lass);
31         $taskListTransformer
32             ->expects(self::once())
33             ->method('transform')
34             ->with($taskList)
35             ->willReturn($expectedList);
36 
37         $getTaskListHandler = new GetTaskListHandler($tasksRepository);
38         $list = $getTaskListHandler->execute($taskListTransformer);
39 
40         self::assertEquals($expectedList, $list);
41     }
42 }

Al lanzar este test, nos pide añadir el método findAll en el repositorio.

1 Trying to configure method "findAll" which cannot be configured because\
2  it does not exist, has not been specified, is final, or is static

Esto lo hacemos en la interfaz y en la implementación concreta:

 1 namespace App\TodoList\Domain;
 2 
 3 interface TaskRepository
 4 {
 5     public function store(Task $task): void;
 6 
 7     public function nextIdentity(): int;
 8 
 9     public function findAll(): array;
10 }
 1 namespace App\TodoList\Infrastructure\Persistence;
 2 
 3 
 4 use App\Lib\FileStorageEngine;
 5 use App\TodoList\Domain\Task;
 6 use App\TodoList\Domain\TaskRepository;
 7 
 8 class FileTaskRepository implements TaskRepository
 9 {
10 
11     /** @var FileStorageEngine */
12     private FileStorageEngine $fileStorageEngine;
13 
14     public function __construct(FileStorageEngine $fileStorageEngine)
15     {
16         $this->fileStorageEngine = $fileStorageEngine;
17     }
18 
19     public function store(Task $task): void
20     {
21        $tasks = $this->fileStorageEngine->loadObjects(Task::class);
22 
23        $tasks[$task->id()] = $task;
24 
25        $this->fileStorageEngine->persistObjects($tasks);
26     }
27 
28     public function nextIdentity(): int
29     {
30         $tasks = $this->fileStorageEngine->loadObjects(Task::class);
31 
32         return count($tasks) + 1;
33     }
34 
35     public function findAll(): array
36     {
37         throw new \RuntimeException('Implement findAll() method.');
38     }
39 }

Y lo mismo para el método transform en TaskListTransformer:

1 Trying to configure method "transform" which cannot be configured becau\
2 se it does not exist, 
3 has not been specified, is final, or is static

El cual quedará así, una vez redefinido como interfaz:

1 namespace App\TodoList\Application;
2 
3 interface TaskListTransformer
4 {
5     public function transform(array $taskList): array;
6 }

Con estos cambios, el test ahora fallará para decirnos que necesitamos implementar el método execute del caso de uso, que es justo donde queríamos estar:

1 RuntimeException : Implement App\TodoList\Application\GetTaskListHandle\
2 r::execute

Y he aquí la implementación que hace pasar el test.

 1 namespace App\TodoList\Application;
 2 
 3 use App\TodoList\Domain\TaskRepository;
 4 use App\TodoList\Application\TaskListTransformer;
 5 
 6 class GetTaskListHandler
 7 {
 8     /** @var TaskRepository */
 9     private TaskRepository $taskRepository;
10 
11     public function __construct(TaskRepository $taskRepository)
12     {
13         $this->taskRepository = $taskRepository;
14     }
15 
16     public function execute(TaskListTransformer $taskListTransformer): \
17 array
18     {
19         $tasks = $this->taskRepository->findAll();
20 
21         return $taskListTransformer->transform($tasks);
22     }
23 }

Ahora que hemos vuelto a verde, regresaremos al ciclo de aceptación. Al lanzar el test el resultado es un mensaje de error nuevo, que nos pide implementar findAll en FileTaskRepository.

1 RuntimeException: "Implement findAll() method."

Esto requiere un test unitario.

 1 namespace App\Tests\TodoList\Infrastructure\Persistence;
 2 
 3 use App\Lib\FileStorageEngine;
 4 use App\TodoList\Domain\Task;
 5 use App\TodoList\Domain\TaskRepository;
 6 use App\TodoList\Infrastructure\Persistence\FileTaskRepository;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class FileTaskRepositoryTest extends TestCase
10 {
11     private FileStorageEngine $fileStorageEngine;
12     private TaskRepository $taskRepository;
13 
14     public function setUp(): void
15     {
16         $this->fileStorageEngine = $this->createMock(FileStorageEngine:\
17 :class);
18         $this->taskRepository = new FileTaskRepository($this->fileStora\
19 geEngine);
20     }
21 
22     /** @test */
23     public function shouldProvideNextIdentityCountingExistingObjects():\
24  void
25     {
26         $this->fileStorageEngine
27             ->method('loadObjects')
28             ->willReturn(
29                 [],
30                 ['Task'],
31                 ['Task', 'Task']
32             );
33 
34         self::assertEquals(1, $this->taskRepository->nextIdentity());
35         self::assertEquals(2, $this->taskRepository->nextIdentity());
36         self::assertEquals(3, $this->taskRepository->nextIdentity());
37     }
38 
39     /** @test */
40     public function shouldStoreATask(): void
41     {
42         $task = new Task(1, 'Task Description');
43 
44         $this->fileStorageEngine
45             ->method('loadObjects')
46             ->willReturn([]);
47         $this->fileStorageEngine
48             ->expects(self::once())
49             ->method('persistObjects')
50             ->with([1 => $task]);
51 
52         $this->taskRepository->store($task);
53     }
54 
55     /** @test */
56     public function shouldGetStoredTasks(): void
57     {
58         $storedTasks = [
59             1 => new Task(1, 'Write a test that fails'),
60             2 => new Task(2, 'Write code to make the test pass'),
61         ];
62 
63         $this->fileStorageEngine
64             ->method('loadObjects')
65             ->willReturn(
66                 $storedTasks
67             );
68 
69         self::assertEquals($storedTasks, $this->taskRepository->findAll\
70 ());
71     }
72 }

Al ejecutarlo, nos pedirá:

1 RuntimeException : Implement findAll() method.

Así que vamos a ello:

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

Ahora el test unitario pasa, con lo cual tenemos implementado buena parte del repositorio. ¿Será suficiente para hacer pasar el test de aceptación?

No, todavía tenemos cosas pendientes. En este momento se nos reclama introducir una implementación concreta de TaskListTransformer.

Ahora nos toca introducir un nuevo test unitario para desarrollar el Transformer concreto, que ubicaremos en App\TodoList\Infrastructure\EntryPoint\Api, ya que es el controlador quien está interesado en usarlo. Lo denominaremos StringTaskListTransformer pues convierte a Task en una representación en forma de string.

Este nos va a suponer un pequeño reto de diseño. No disponemos todavía de formas de acceder a las propiedades de Task, una entidad que tampoco hemos tenido que desarrollar más hasta ahora, y lo cierto es que no deberíamos condicionar su implementación a este tipo de necesidades. En un sistema más real y sofisticado podríamos aplicar un patrón Visitor o similar. En este caso, lo que haremos será pasar una plantilla a Task para que nos la devuelva cubierta con sus datos.

Como Task es una entidad prefiero no mockearla, así que el test quedará de esta forma:

 1 namespace App\Tests\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 use App\TodoList\Domain\Task;
 4 use App\TodoList\Infrastructure\EntryPoint\Api\StringTaskListTransforme\
 5 r;
 6 use PHPUnit\Framework\TestCase;
 7 
 8 class StringTaskListTransformerTest extends TestCase
 9 {
10     /** @test
11      * @dataProvider examplesProvider
12      */
13     public function shouldTransformList($tasksList, $expected): void
14     {
15         $taskListTransformer = new StringTaskListTransformer();
16 
17         $result = $taskListTransformer->transform($tasksList);
18 
19         self::assertEquals($expected, $result);
20     }
21 
22     public function examplesProvider(): array
23     {
24         return [
25           [[], []],
26           [[new Task(1, 'Task Description')], ['[ ] 1. Task Description\
27 ']]
28         ];
29     }
30 }

Y el código de producción podría ser este:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 use App\TodoList\Application\TaskListTransformer;
 4 
 5 class StringTaskListTransformer implements TaskListTransformer
 6 {
 7     public function transform(array $taskList): array
 8     {
 9         $transformed = [];
10 
11         foreach ($taskList as $task) {
12             $transformed[] = $task->representedAs('[:check] :id. :descr\
13 iption');
14         }
15 
16         return $transformed;
17     }
18 }

El test lanzará un error para decirnos que no está implementado el método representedAs en Task, por lo que podemos añadirlo.

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

Salvando las distancias, podemos usar el test actual como test de aceptación. Si lo ejecutamos veremos que se lanza la excepción:

1 RuntimeException : Implement App\TodoList\Domain\Task::representedAs

Lo que nos indicaría la necesidad de pasar al siguiente nivel y crear un test unitario para desarrollar Task, o al menos el método representedAs. Otra opción, sería desarrollar Task bajo la cobertura del test actual, pero no es muy buena idea, ya que el test podría requerir de ejemplos que no aportan nada realmente al test y que son relevantes solo para task.

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

Por el momento esta implementación ya nos iría bien.

 1 namespace App\TodoList\Domain;
 2 
 3 class Task
 4 {
 5     private int $id;
 6     private string $description;
 7 
 8     public function __construct(int $id, string $description)
 9     {
10         $this->id = $id;
11         $this->description = $description;
12     }
13 
14     public function id(): int
15     {
16         return $this->id;
17     }
18 
19     public function representedAs(string $format): string
20     {
21         $values = [
22             ':check' => ' ',
23             ':id' => $this->id,
24             ':description' => $this->description
25         ];
26         return strtr($format, $values);
27 
28     }
29 }

Así que podríamos subir un nivel y volver al test anterior del Transformer, que pasa sin más problemas.

Con este test en verde, regresamos al nivel de aceptación, que también pasa, indicando que hemos terminado de desarrollar esta historia de usuario.

Marcar tareas completadas

US-3

  • As a User
  • I want to check a task when it is done
  • So that, I can see my progress

La tercera historia de usuario se construye fácilmente a partir de las dos anteriores, ya que nuestra aplicación ya permite introducir tareas y ver la lista. Por eso, antes de empezar con el desarrollo refactorizaremos el test de aceptación para que sea más sencillo extenderlo. De hecho, hasta podemos reutilizar algunas partes. Este es el resultado, ya con el nuevo test de aceptación.

  1 namespace App\Tests\Katas\TodoList;
  2 
  3 use App\Lib\FileStorageEngine;
  4 use App\TodoList\Domain\Task;
  5 use Symfony\Bundle\FrameworkBundle\Client;
  6 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  7 use Symfony\Component\HttpFoundation\Response;
  8 
  9 class TodoListAcceptanceTest extends WebTestCase
 10 {
 11     private Client $client;
 12 
 13     /** @test */
 14     public function asUserIWantToAddTaskToAToDoList(): void
 15     {
 16         $response = $this->whenWeRequestToCreateATaskWithDescription('W\
 17 rite a test that fails');
 18 
 19         $this->thenResponseShouldBeSuccesful($response);
 20 
 21         $this->thenTheTaskIsStored();
 22     }
 23 
 24     /** @test */
 25     public function asUserIWantToSeeTheTasksInMyTodoList(): void
 26     {
 27         $this->givenIHaveAddedTasks();
 28 
 29         $response = $this->whenIRequestTheListOfTasks();
 30 
 31         $this->thenICanSeeAddedTasksInTheList(
 32             [
 33                 '[ ] 1. Write a test tha fails',
 34                 '[ ] 2. Write code to make the test pass'
 35             ],
 36             $response
 37         );
 38     }
 39 
 40     /** @test */
 41     public function asUserIWantToMarkTasksAsCompleted(): void
 42     {
 43         $this->givenIHaveAddedTasks();
 44         
 45         $this->client->request(
 46             'PATCH',
 47             '/api/todo/1',
 48             [],
 49             [],
 50             ['CONTENT-TYPE' => 'json/application'],
 51             json_encode(['completed' => true], JSON_THROW_ON_ERROR)
 52 
 53         );
 54         
 55         $patchResponse = $this->client->getResponse();
 56 
 57         self::assertEquals(Response::HTTP_OK, $patchResponse->getStatus\
 58 Code());
 59                 
 60         $response = $this->whenIRequestTheListOfTasks();
 61 
 62         $this->thenICanSeeAddedTasksInTheList(
 63             [
 64                 '[√] 1. Write a test tha fails',
 65                 '[ ] 2. Write code to make the test pass'
 66             ],
 67             $response
 68         );
 69     }
 70 
 71     protected function setUp(): void
 72     {
 73         $this->resetRepositoryData();
 74 
 75         $this->client = self::createClient();
 76     }
 77 
 78     protected function tearDown(): void
 79     {
 80         $this->resetRepositoryData();
 81     }
 82 
 83     private function resetRepositoryData(): void
 84     {
 85         if (file_exists('repository.data')) {
 86             unlink('repository.data');
 87         }
 88     }
 89 
 90     private function whenWeRequestToCreateATaskWithDescription(string $\
 91 taskDescription): Response
 92     {
 93         return $this->apiCreateTaskWithDescription($taskDescription);
 94     }
 95 
 96     private function thenResponseShouldBeSuccesful(Response $response):\
 97  void
 98     {
 99         self::assertEquals(Response::HTTP_CREATED, $response->getStatus\
100 Code());
101     }
102 
103     private function thenTheTaskIsStored(): void
104     {
105         $storage = new FileStorageEngine('repository.data');
106         $tasks = $storage->loadObjects(Task::class);
107 
108         self::assertCount(1, $tasks);
109         self::assertEquals(1, $tasks[1]->id());
110     }
111 
112     private function apiCreateTaskWithDescription(string $taskDescripti\
113 on): Response
114     {
115         $this->client->request(
116             'POST',
117             '/api/todo',
118             [],
119             [],
120             ['CONTENT-TYPE' => 'json/application'],
121             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERR\
122 OR)
123         );
124 
125         return $this->client->getResponse();
126     }
127 
128     private function whenIRequestTheListOfTasks(): Response
129     {
130         $response = $this->apiGetTasksList();
131 
132         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
133 ));
134         return $response;
135     }
136 
137     private function apiGetTasksList(): Response
138     {
139         $this->client->request(
140             'GET',
141             '/api/todo'
142         );
143 
144         return $this->client->getResponse();
145     }
146 
147     private function givenIHaveAddedTasks(): void
148     {
149         $this->apiCreateTaskWithDescription('Write a test tha fails');
150         $this->apiCreateTaskWithDescription('Write code to make the tes\
151 t pass');
152     }
153 
154     private function thenICanSeeAddedTasksInTheList(array $expectedTask\
155 s, Response $response): void
156     {
157         $taskList = json_decode($response->getContent(), true);
158 
159         self::assertEquals(
160             $expectedTasks, $taskList);
161     }
162 }

Al lanzar el test, y como era de esperar, falla porque no se encuentra la ruta al endpoint:

1 "No route found for "PATCH /api/todo/1"

Y, como hemos hecho antes, tendremos que definirla y crear un controlador que la gestione. En primer lugar, la definición de la ruta en routes.yaml.

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
 4 ller::addTask
 5   methods: ['POST']
 6 
 7 api_get_task_list:
 8   path: /api/todo
 9   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
10 ller::getTaskList
11   methods: ['GET']
12 
13 api_mark_task_completed:
14   path: /api/todo/{taskId}
15   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
16 ller::markTaskCompleted
17   methods: ['PATCH']

Una nueva ejecución del test nos indica que falta un controlador:

1 "The controller for URI "/api/todo/1" is not callable. Expected method \
2 "markTaskCompleted"

Y añadimos uno vacío:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\Application\AddTaskHandler;
 5 use App\TodoList\Application\GetTaskListHandler;
 6 use App\TodoList\Application\TaskListTransformer;
 7 use Symfony\Component\HttpFoundation\JsonResponse;
 8 use Symfony\Component\HttpFoundation\Request;
 9 use Symfony\Component\HttpFoundation\Response;
10 
11 class TodoListController
12 {
13     private AddTaskHandler $addTaskHandler;
14     private GetTaskListHandler $getTaskListHandler;
15     private TaskListTransformer $taskListTransformer;
16 
17     public function __construct(
18         AddTaskHandler $addTaskHandler,
19         GetTaskListHandler $getTaskListHandler,
20         TaskListTransformer $taskListTransformer
21     ) {
22         $this->addTaskHandler = $addTaskHandler;
23         $this->getTaskListHandler = $getTaskListHandler;
24         $this->taskListTransformer = $taskListTransformer;
25     }
26 
27     public function addTask(Request $request): Response
28     {
29         $payload = $this->obtainPayload($request);
30 
31         $this->addTaskHandler->execute($payload['task']);
32 
33         return new JsonResponse('', Response::HTTP_CREATED);
34     }
35 
36     public function getTaskList(Request $request): Response
37     {
38         $taskList = $this->getTaskListHandler->execute($this->taskListT\
39 ransformer);
40 
41         return new JsonResponse($taskList, Response::HTTP_OK);
42     }
43 
44     public function markTaskCompleted(int $taskId): Response
45     {
46         throw new \RuntimeException(sprintf('Implement %s', __METHOD__)\
47 );
48     }
49 
50     private function obtainPayload(Request $request): array
51     {
52         return json_decode($request->getContent(), true, 512, JSON_THRO\
53 W_ON_ERROR);
54     }
55 }

El error ahora es:

1 RuntimeException: "Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::markTaskCompleted"

Y el test falla porque espera que ese endpoint esté funcionando como es debido y respondiendo, pero todavía está sin implementar. Por tanto, nos movemos al nivel unitario para definir la funcionalidad del controlador.

Como en los casos anteriores, implementar la funcionalidad require además del controlador un caso de uso y utilizar el repositorio para recuperar la tarea que se quiere marcar, y volver a guardarla. Por tanto, la clave del test será esperar que se ejecute el caso de uso con los parámetros adecuados.

Así que, el test quedaría más o menos así;

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

Una vez que tenemos el test, lo lanzamos. El resultado es que nos pide crear la clase MarkTaskCompletedHandler.

1 Cannot stub or mock class or interface "App\Tests\TodoList\Infrastructu\
2 re\EntryPoint\Api\MarkTaskCompletedHandler" which does not exist

La creamos en el propio test y luego la movemos a su ubicación en App\TodoList\Application. A continuación nos pedirá crear el método execute.

1 Trying to configure method "execute" which cannot be configured because\
2  it does not exist, has not been specified, is final, or is static

El cual prepararemos de esta forma:

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

Con esto ya tenemos lo necesario para implementar la acción del controlador, cosa que hacemos, porque el siguiente error nos lo indica:

1 RuntimeException : Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::markTaskCompleted

Este es el código que hará pasar el test del controlador.

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 
 4 use App\TodoList\Application\AddTaskHandler;
 5 use App\TodoList\Application\GetTaskListHandler;
 6 use App\TodoList\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     private AddTaskHandler $addTaskHandler;
14     private GetTaskListHandler $getTaskListHandler;
15     private TaskListTransformer $taskListTransformer;
16     private MarkTaskCompletedHandler $markTaskCompletedHandler;
17 
18     public function __construct(
19         AddTaskHandler $addTaskHandler,
20         GetTaskListHandler $getTaskListHandler,
21         TaskListTransformer $taskListTransformer,
22         MarkTaskCompletedHandler $markTaskCompletedHandler
23     ) {
24         $this->addTaskHandler = $addTaskHandler;
25         $this->getTaskListHandler = $getTaskListHandler;
26         $this->taskListTransformer = $taskListTransformer;
27         $this->markTaskCompletedHandler = $markTaskCompletedHandler;
28     }
29 
30     public function addTask(Request $request): Response
31     {
32         $payload = $this->obtainPayload($request);
33 
34         $this->addTaskHandler->execute($payload['task']);
35 
36         return new JsonResponse('', Response::HTTP_CREATED);
37     }
38 
39     public function getTaskList(Request $request): Response
40     {
41         $taskList = $this->getTaskListHandler->execute($this->taskListT\
42 ransformer);
43 
44         return new JsonResponse($taskList, Response::HTTP_OK);
45     }
46 
47     public function markTaskCompleted(int $taskId, Request $request): R\
48 esponse
49     {
50         $payload = $this->obtainPayload($request);
51 
52         $this->markTaskCompletedHandler->execute($taskId, $payload['com\
53 pleted']);
54 
55         return new JsonResponse('', Response::HTTP_OK);
56     }
57 
58     private function obtainPayload(Request $request): array
59     {
60         return json_decode($request->getContent(), true, 512, JSON_THRO\
61 W_ON_ERROR);
62     }
63 }

Una vez que el test del controlador pasa, tendremos que volver a lanzar el test de aceptación. Este nos indicará el siguiente paso:

1 RuntimeException: "Implement App\TodoList\Application\MarkTaskCompleted\
2 Handler::execute"

Nos requiere implementar el caso de uso. Por lo tanto, necesitamos un nuevo test unitario:

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

La ejecución del test arroja el siguiente error:

1 Trying to configure method "retrieve" which cannot be configured becaus\
2 e it does not exist, has not been specified, is final, or is static

Hasta ahora no habíamos requerido este método en el repositorio, por lo cual tendremos que añadirlo a la interfaz.

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

Esto será suficiente para poder seguir ejecutando el test y que nos pida implementar el método execute en el caso de uso.

1 RuntimeException : Implement App\TodoList\Application\MarkTaskCompleted\
2 Handler::execute

Así que vamos a ello. Es bastante sencillo:

 1 namespace App\TodoList\Application;
 2 
 3 
 4 use App\TodoList\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 $completed): void
16     {
17         $task = $this->taskRepository->retrieve($taskId);
18 
19         if ($completed) {
20             $task->markCompleted();
21         }
22         
23         $this->taskRepository->store($task);
24     }
25 }

Al volver a ejecutar el test fallará. Esto es porque no tenemos definido el método Task::markCompleted:

1 Error : Call to undefined method App\TodoList\Domain\Task::markComplete\
2 d()

Siempre que tenemos un error de este tipo, tendremos que profundizar y entrar en un nuevo test unitario. En este caso, para implementar este método en Task. No tenemos acceso directo a la propiedad complete, que aún no tenemos definida siquiera, pero podemos controlar su estado indirectamente gracias a su representación.

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

La implementación es bastante sencilla:

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

Con esto, el test de Task pasa y podemos volver al nivel del caso de uso. Al lanzar el test de nuevo, vemos que también pasa, por lo que podemos volver al nivel del test de aceptación.

Este test, en cambio, no pasará porque espera que implementemos el método retrieve en FileTaskRepository, que aún no lo tenemos. Nos vamos al test.

 1 namespace App\Tests\TodoList\Infrastructure\Persistence;
 2 
 3 use App\Lib\FileStorageEngine;
 4 use App\TodoList\Domain\Task;
 5 use App\TodoList\Domain\TaskRepository;
 6 use App\TodoList\Infrastructure\Persistence\FileTaskRepository;
 7 use PHPUnit\Framework\TestCase;
 8 
 9 class FileTaskRepositoryTest extends TestCase
10 {
11     private FileStorageEngine $fileStorageEngine;
12     private TaskRepository $taskRepository;
13 
14     public function setUp(): void
15     {
16         $this->fileStorageEngine = $this->createMock(FileStorageEngine:\
17 :class);
18         $this->taskRepository = new FileTaskRepository($this->fileStora\
19 geEngine);
20     }
21 
22     /** @test */
23     public function shouldProvideNextIdentityCountingExistingObjects():\
24  void
25     {
26         $this->fileStorageEngine
27             ->method('loadObjects')
28             ->willReturn(
29                 [],
30                 ['Task'],
31                 ['Task', 'Task']
32             );
33 
34         self::assertEquals(1, $this->taskRepository->nextIdentity());
35         self::assertEquals(2, $this->taskRepository->nextIdentity());
36         self::assertEquals(3, $this->taskRepository->nextIdentity());
37     }
38 
39     /** @test */
40     public function shouldStoreATask(): void
41     {
42         $task = new Task(1, 'Task Description');
43 
44         $this->fileStorageEngine
45             ->method('loadObjects')
46             ->willReturn([]);
47         $this->fileStorageEngine
48             ->expects(self::once())
49             ->method('persistObjects')
50             ->with([1 => $task]);
51 
52         $this->taskRepository->store($task);
53     }
54 
55     /** @test */
56     public function shouldGetStoredTasks(): void
57     {
58         $storedTasks = [
59             1 => new Task(1, 'Write a test that fails'),
60             2 => new Task(2, 'Write code to make the test pass'),
61         ];
62 
63         $this->fileStorageEngine
64             ->method('loadObjects')
65             ->willReturn(
66                 $storedTasks
67             );
68 
69         self::assertEquals($storedTasks, $this->taskRepository->findAll\
70 ());
71     }
72 
73     /** @test */
74     public function shouldRetrieveATaskByItsId(): void
75     {
76         $expectedTask = new Task(1, 'Write a test that fails');
77         
78         $storedTasks = [
79             1 => $expectedTask,
80             2 => new Task(2, 'Write code to make the test pass'),
81         ];
82 
83         $this->fileStorageEngine
84             ->method('loadObjects')
85             ->willReturn(
86                 $storedTasks
87             );
88 
89         self::assertEquals($expectedTask, $this->taskRepository->retrie\
90 ve(1));
91     }
92 }

Como era de esperar, el test nos reclamará escribir el método retrieve.

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

Y con este el test de FileTaskRepository está en verde. Aprovechamos para hacer un pequeño refactor, de modo que la dependencia esté controlada:

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

Y ahora volveremos a lanzar el test de aceptación, que esta vez pasa limpiamente.

Siguientes pasos

En este punto tenemos las tres historias de usuario implementadas. ¿Qué nos interesa hacer ahora?

Una de las mejoras que podemos hacer en este momento es arreglar el test de aceptación para que pueda usarse como test de QA. Ahora que hemos desarrollado todos los componentes implicados es posible hacer que el test sea más expresivo y más útil para describir el comportamiento implementado.

Los tests unitarios nos pueden valer tal como están. Una objeción típica es que al estar basados en mocks son frágiles por su acoplamiento a la implementación. Sin embargo, debemos recordar que básicamente hemos estado diseñando los componentes que necesitábamos y la forma en que queríamos hacerlos interactuar. En otras palabras: no es previsible que esta implementación vaya a cambiar demasiado hasta el punto de invalidar los test. Por otro lado, los tests unitarios que hemos usado, caracterizan el comportamiento concreto de cada unidad. En conjunto son rápidos y nos proporcionan la resolución necesaria como para ayudarnos a diagnosticar rápidamente los problemas que puedan surgir.

Así que vamos a retocar el test de aceptación para que tenga un mejor lenguaje de negocio:

  1 namespace App\Tests\Katas\TodoList;
  2 
  3 use Symfony\Bundle\FrameworkBundle\Client;
  4 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  5 use Symfony\Component\HttpFoundation\Response;
  6 
  7 class TodoListAcceptanceTest extends WebTestCase
  8 {
  9     private Client $client;
 10 
 11     /** @test */
 12     public function asUserIWantToAddTaskToAToDoList(): void
 13     {
 14         $this->givenIRequestToCreateATaskWithDescription('Write a test \
 15 that fails');
 16         $response = $this->whenIRequestTheListOfTasks();
 17         $this->thenICanSeeAddedTasksInTheList(
 18             [
 19                 '[ ] 1. Write a test that fails',
 20             ],
 21             $response
 22         );
 23     }
 24 
 25     /** @test */
 26     public function asUserIWantToSeeTheTasksInMyTodoList(): void
 27     {
 28         $this->givenIHaveAddedTasks(
 29             [
 30                 'Write a test that fails',
 31                 'Write code to make the test pass',
 32             ]
 33         );
 34         $response = $this->whenIRequestTheListOfTasks();
 35         $this->thenICanSeeAddedTasksInTheList(
 36             [
 37                 '[ ] 1. Write a test that fails',
 38                 '[ ] 2. Write code to make the test pass',
 39             ],
 40             $response
 41         );
 42     }
 43 
 44     /** @test */
 45     public function asUserIWantToMarkTasksAsCompleted(): void
 46     {
 47         $this->givenIHaveAddedTasks(
 48             [
 49                 'Write a test that fails',
 50                 'Write code to make the test pass',
 51             ]
 52         );
 53         $this->givenIMarkATaskAsCompleted(1);
 54         $response = $this->whenIRequestTheListOfTasks();
 55         $this->thenICanSeeAddedTasksInTheList(
 56             [
 57                 '[√] 1. Write a test that fails',
 58                 '[ ] 2. Write code to make the test pass',
 59             ],
 60             $response
 61         );
 62     }
 63 
 64     private function givenIRequestToCreateATaskWithDescription(string $\
 65 taskDescription): Response
 66     {
 67         return $this->apiCreateTaskWithDescription($taskDescription);
 68     }
 69 
 70     private function apiCreateTaskWithDescription(string $taskDescripti\
 71 on): Response
 72     {
 73         $this->client->request(
 74             'POST',
 75             '/api/todo',
 76             [],
 77             [],
 78             ['CONTENT-TYPE' => 'json/application'],
 79             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERR\
 80 OR)
 81         );
 82 
 83         return $this->client->getResponse();
 84     }
 85 
 86     private function whenIRequestTheListOfTasks(): Response
 87     {
 88         $response = $this->apiGetTasksList();
 89 
 90         self::assertEquals(Response::HTTP_OK, $response->getStatusCode(\
 91 ));
 92 
 93         return $response;
 94     }
 95 
 96     private function apiGetTasksList(): Response
 97     {
 98         $this->client->request(
 99             'GET',
100             '/api/todo'
101         );
102 
103         return $this->client->getResponse();
104     }
105 
106     private function thenICanSeeAddedTasksInTheList(array $expectedTask\
107 s, Response $response): void
108     {
109         $taskList = json_decode($response->getContent(), true);
110 
111         self::assertEquals($expectedTasks, $taskList);
112     }
113 
114     private function givenIHaveAddedTasks($tasks): void
115     {
116         foreach ($tasks as $task) {
117             $this->apiCreateTaskWithDescription($task);
118         }
119     }
120 
121     private function givenIMarkATaskAsCompleted(int $taskId): void
122     {
123         $patchResponse = $this->apiMarkTaskCompleted($taskId);
124 
125         self::assertEquals(Response::HTTP_OK, $patchResponse->getStatus\
126 Code());
127     }
128 
129     private function apiMarkTaskCompleted(int $taskId): Response
130     {
131         $this->client->request(
132             'PATCH',
133             '/api/todo/' . $taskId . '',
134             [],
135             [],
136             ['CONTENT-TYPE' => 'json/application'],
137             json_encode(['completed' => true], JSON_THROW_ON_ERROR)
138 
139         );
140 
141         return $this->client->getResponse();
142     }
143 
144     protected function setUp(): void
145     {
146         $this->resetRepositoryData();
147 
148         $this->client = self::createClient();
149     }
150 
151     private function resetRepositoryData(): void
152     {
153         if (file_exists('repository.data')) {
154             unlink('repository.data');
155         }
156     }
157 
158     protected function tearDown(): void
159     {
160         $this->resetRepositoryData();
161     }
162 }

Básicamente hemos reescrito el test usando un estilo Behavior Driven Development. No nos ha hecho falta hacer un Gherkin aquí, pero hubiésemos podido hacerlo.

Esto nos ha permitido desprendernos de la llamada directa al motor de almacenamiento que habíamos introducido al principio, y al hacerlo conseguimos que el test sea más portable, ya que solo usa las llamadas a los endpoints, por lo que puede funcionar en distintos entornos (local e integración contínua, por ejemplo).