Introducción de nuevas características
En el capítulo anterior comentábamos que desde el punto del desarrollo basado en TDD los defectos pueden considerarse casi como features no definidas inicialmente. Otra forma de verlo es que son features cuando nos las piden explícitamente y son defecto cuando van implícitas en otra feature, pero no las hemos desarrollado.
Es decir, cuando decimos que queremos poder marcar una tarea como completada, por seguir con nuestro proyecto de lista de tareas, se asume que debería evitarse que el sistema se rompa si intentamos marcar una tarea inexistente. Por eso diríamos que esa feature tenía un defecto y es lo que hemos arreglado en el capítulo anterior.
Pero en este capítulo vamos a tratar sobre cómo añadir nuevas prestaciones a un software existente utilizando una aproximación TDD. Y, como cabe esperar, en realidad no vamos a introducir cambios en nuestra metodología. Seguiremos empezando con un test de aceptación y profundizando en la aplicación y los cambios necesarios.
Con todo se trata de un escenario distinto. Un nuevo comportamiento puede requerir modificar unidades de software existentes y necesitamos que los cambios no rompan funcionalidad ya creada.
Nueva historia de usuario
La siguiente petición de negocio es permitir editar una tarea existente.
US-4
- As a user
- I want to modify an existing task in the list
- So that, I can express my ideas better
Inicialmente, esta historia requiere crear un nuevo endpoint con el que cambiar la información de una tarea.
1 PUT /api/todo/{taskId}
Si nuestra aplicación tiene un front-end es posible que necesitemos un endpoint para recuperar la información de la tarea que queremos editar, a fin de poder rellenar el formulario con los datos actuales. En ese caso, sería:
1 GET /api/todo/{taskId}
En ambos casos, el procedimiento será el mismo: empezaremos creando un test de aceptación, iniciando el proceso de desarrollo. Lo que sí nos encontraremos es que algunos componentes necesarios están ya creados.
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 # ...
12
13 public function asUserIWantToModifyAnExistingTask(): void
14 {
15 $this->givenIHaveAddedTasks(
16 [
17 'Write a test that fails',
18 'Write code to make the test pass',
19 ]
20 );
21
22 $this->client->request(
23 'PUT',
24 '/api/todo/2',
25 [],
26 [],
27 ['CONTENT-TYPE' => 'json/application'],
28 json_encode(['task' => 'Write production code to make the t\
29 est pass'], JSON_THROW_ON_ERROR)
30 );
31
32 $putResponse = $this->client->getResponse();
33
34 self::assertEquals(204, $putResponse->getStatusCode());
35
36 $response = $this->whenIRequestTheListOfTasks();
37 $this->thenICanSeeAddedTasksInTheList(
38 [
39 '[ ] 1. Write a test that fails',
40 '[ ] 2. Write production code to make the test pass',
41 ],
42 $response
43 );
44 }
45
46 # ...
47 }
Así que ejecutamos el test para ver qué nos dice. Como era de esperar, el endpoint no se puede encontrar porque no tenemos la ruta, así que empezamos por definirla.
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']
18
19 api_edit_task:
20 path: /api/todo/{taskId}
21 controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListContro\
22 ller::modifyTask
23 methods: ['PUT']
Al volver a lanzar el test tras este cambio, nos indicará que no una acción en el controlador para responder a esta ruta.
1 "The controller for URI "/api/todo/2" is not callable. Expected method \
2 "modifyTask"
Así que tendremos que añadir una nueva acción vacía.
1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
2
3 use App\TodoList\Application\AddTaskHandler;
4 use App\TodoList\Application\GetTaskListHandler;
5 use App\TodoList\Application\MarkTaskCompletedHandler;
6 use App\TodoList\Application\TaskListTransformer;
7 use InvalidArgumentException;
8 use OutOfBoundsException;
9 use Symfony\Component\HttpFoundation\JsonResponse;
10 use Symfony\Component\HttpFoundation\Request;
11 use Symfony\Component\HttpFoundation\Response;
12 use function is_string;
13
14 class TodoListController
15 {
16 private AddTaskHandler $addTaskHandler;
17 private GetTaskListHandler $getTaskListHandler;
18 private TaskListTransformer $taskListTransformer;
19 private MarkTaskCompletedHandler $markTaskCompletedHandler;
20
21 public function __construct(
22 AddTaskHandler $addTaskHandler,
23 GetTaskListHandler $getTaskListHandler,
24 TaskListTransformer $taskListTransformer,
25 MarkTaskCompletedHandler $markTaskCompletedHandler
26 ) {
27 $this->addTaskHandler = $addTaskHandler;
28 $this->getTaskListHandler = $getTaskListHandler;
29 $this->taskListTransformer = $taskListTransformer;
30 $this->markTaskCompletedHandler = $markTaskCompletedHandler;
31 }
32
33 # ...
34
35 public function modifyTask(int $taskId, Request $request): Response
36 {
37 throw new \RuntimeException(sprintf('Implement %s', __METHOD__)\
38 );
39 }
40
41 # ...
42 }
En la nueva ejecución del test, el error será:
1 RuntimeException: "Implement App\TodoList\Infrastructure\EntryPoint\Api\
2 \TodoListController::modifyTask"
Lo que nos dice que tenemos que entrar al nivel unitario para implementar esta acción en el controlador. Todo este ciclo te sonará porque es lo que hemos estado haciendo en toda esta parte del libro.
Pero lo cierto es que esta rutina es algo positivo. En cada momento siempre tenemos una tarea concreta que afrontar, ya sea crear un test, ya sea código de producción, y no tenemos que preocuparnos de ninguna otra cosa. El test de aceptación nos va diciendo qué hacer, y en cada nivel solo tenemos que pensar en ese componente concreto.
A nosotras ahora nos toca implementar el controlador. Como ya sabemos, en esta fase tenemos que diseñar. Básicamente, es una acción similar a la de añadir una tarea, pero en este caso recibimos el ID de la tarea que vamos a cambiar y su nueva descripción.
Necesitaremos un caso de uso que expresa esta intención de las usuarias al que le pasaremos los dos datos que necesitamos. Si todo va como es debido, devolvemos la respuesta 204 (no content).
Añadimos un test que recoge todo esto:
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\MarkTaskCompletedHandler;
6 use App\TodoList\Application\TaskListTransformer;
7 use App\TodoList\Infrastructure\EntryPoint\Api\TodoListController;
8 use InvalidArgumentException;
9 use PHPUnit\Framework\TestCase;
10 use OutOfBoundsException;
11 use Symfony\Component\HttpFoundation\Request;
12
13 class TodoListControllerTest extends TestCase
14 {
15 private const TASK_DESCRIPTION = 'Task Description';
16 private const COMPLETED_TASK_ID = 1;
17 private const TASK_ID = 1;
18
19 private AddTaskHandler $addTaskHandler;
20 private TodoListController $todoListController;
21 private GetTaskListHandler $getTaskListHandler;
22 private TaskListTransformer $taskListTransformer;
23 private MarkTaskCompletedHandler $markTaskCompletedHandler;
24 private UpdateTaskHandler $updateTaskHandler;
25
26 protected function setUp(): void
27 {
28 $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
29 );
30 $this->getTaskListHandler = $this->createMock(GetTaskListHandle\
31 r::class);
32 $this->taskListTransformer = $this->createMock(TaskListTransfor\
33 mer::class);
34 $this->markTaskCompletedHandler = $this->createMock(MarkTaskCom\
35 pletedHandler::class);
36 $this->updateTaskHandler = $this->createMock(UpdateTaskHandler:\
37 :class);
38
39 $this->todoListController = new TodoListController(
40 $this->addTaskHandler,
41 $this->getTaskListHandler,
42 $this->taskListTransformer,
43 $this->markTaskCompletedHandler,
44 $this->updateTaskHandler
45 );
46 }
47
48 # ...
49
50 /** @test */
51 public function shouldModifyATask(): void
52 {
53 $this->updateTaskHandler
54 ->expects(self::once())
55 ->method('execute')
56 ->with(self::TASK_ID, self::TASK_DESCRIPTION);
57
58 $request = new Request(
59 [],
60 [],
61 [],
62 [],
63 [],
64 ['CONTENT-TYPE' => 'json/application'],
65 json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_\
66 ON_ERROR)
67 );
68
69 $response = $this->todoListController->modifyTask(self::TASK_ID\
70 ,$request);
71
72 self::assertEquals(204, $response->getStatusCode());
73 }
74
75 # ...
76 }
Si ejecutamos el test nos pedirá crear el caso de uso UpdateTaskHandler.
1 namespace App\TodoList\Application;
2
3 class UpdateTaskHandler
4 {
5 }
Y seguidamente nos pedirá introducir el método execute.
1 namespace App\TodoList\Application;
2
3 class UpdateTaskHandler
4 {
5 public function execute()
6 {
7 throw new \RuntimeException(sprintf('Implement %s', __METHOD__)\
8 );
9 }
10 }
Una vez que tenemos eso ya nos vuelve a pedir implementar la acción del controlador. Así que vamos a ello:
1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
2
3 use App\TodoList\Application\AddTaskHandler;
4 use App\TodoList\Application\GetTaskListHandler;
5 use App\TodoList\Application\MarkTaskCompletedHandler;
6 use App\TodoList\Application\UpdateTaskHandler;
7 use App\TodoList\Application\TaskListTransformer;
8 use InvalidArgumentException;
9 use OutOfBoundsException;
10 use Symfony\Component\HttpFoundation\JsonResponse;
11 use Symfony\Component\HttpFoundation\Request;
12 use Symfony\Component\HttpFoundation\Response;
13 use function is_string;
14
15 class TodoListController
16 {
17 private AddTaskHandler $addTaskHandler;
18 private GetTaskListHandler $getTaskListHandler;
19 private TaskListTransformer $taskListTransformer;
20 private MarkTaskCompletedHandler $markTaskCompletedHandler;
21 private UpdateTaskHandler $updateTaskHandler;
22
23 public function __construct(
24 AddTaskHandler $addTaskHandler,
25 GetTaskListHandler $getTaskListHandler,
26 TaskListTransformer $taskListTransformer,
27 MarkTaskCompletedHandler $markTaskCompletedHandler,
28 UpdateTaskHandler $updateTaskHandler
29 ) {
30 $this->addTaskHandler = $addTaskHandler;
31 $this->getTaskListHandler = $getTaskListHandler;
32 $this->taskListTransformer = $taskListTransformer;
33 $this->markTaskCompletedHandler = $markTaskCompletedHandler;
34 $this->updateTaskHandler = $updateTaskHandler;
35 }
36
37 # ...
38
39 public function modifyTask(int $taskId, Request $request): Response
40 {
41 $payload = $this->obtainPayload($request);
42
43 $this->updateTaskHandler->execute($taskId, $payload['task']);
44
45 return new JsonResponse('', Response::HTTP_NO_CONTENT);
46 }
47
48 # ...
49 }
Y el test unitario del controlador ya pasa. Si volvemos al test de aceptación, como corresponde ahora, nos dirá que es lo que tenemos que hacer a continuación:
1 RuntimeException: "Implement App\TodoList\Application\UpdateTaskHandler\
2 ::execute"
Así que nos toca meternos en la capa de Aplicación. De nuevo, tenemos que diseñar este nivel, que nos plantea un problema interesante.
En principio hemos definido que lo que se puede cambiar en la tarea es su descripción, por lo que esta acción tiene que respetar el estado actual del flag de completado. Así que queremos obtener la tarea guardada, modificar su descripción y guardarla.
Por tanto, pediremos la tarea al repositorio, la cambiaremos y la guardaremos de nuevo.
1 namespace App\Tests\TodoList\Application;
2
3 use App\TodoList\Application\UpdateTaskHandler;
4 use App\TodoList\Domain\Task;
5 use App\TodoList\Domain\TaskRepository;
6 use PHPUnit\Framework\TestCase;
7
8 class UpdateTaskHandlerTest extends TestCase
9 {
10
11 private const TASK_ID = 1;
12
13 public function testShouldUpdateATask(): void
14 {
15 $task = new Task(self::TASK_ID, 'Task Description');
16 $taskRepository = $this->createMock(TaskRepository::class);
17 $taskRepository
18 ->method('retrieve')
19 ->with(self::TASK_ID)
20 ->willReturn($task);
21 $taskRepository
22 ->expects(self::once())
23 ->method('store')
24 ->with(new Task(self::TASK_ID, 'New Task Description'));
25
26 $updateTaskHandler = new UpdateTaskHandler($taskRepository);
27
28 $updateTaskHandler->execute(self::TASK_ID, 'New Task Descriptio\
29 n');
30 }
31 }
Cuando ejecutamos el test, nos pedirá implementar el caso de uso, puesto que el repositorio ya está definido con anterioridad.
La implementación seguramente nos forzará a introducir algún nuevo método en Task, de modo que se pueda actualizar la descripción. Esta implementación, por ejemplo:
1 namespace App\TodoList\Application;
2
3 use App\TodoList\Domain\TaskRepository;
4
5 class UpdateTaskHandler
6 {
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, string $newTaskDescription): v\
16 oid
17 {
18 $task = $this->taskRepository->retrieve($taskId);
19
20 $task->updateDescription($newTaskDescription);
21
22 $this->taskRepository->store($task);
23 }
24 }
He elegido esta implementación para simplificar, sin embargo, a medida que hago esta prueba se me ocurren algunas ideas que podrían ser interesantes en un caso de uso realista, como podría ser aplicar cierta inmutabilidad. Es decir, en lugar de actualizar el objeto Task, crearíamos uno nuevo con nuevos valores.
Pero dejaremos estos refinamientos para otra ocasión. Si ejecutamos el test, nos dirá que Task carece del método updateDescription, que tendremos que desarrollar con ayuda de un test unitario.
1 namespace App\Tests\TodoList\Domain;
2
3 use App\TodoList\Domain\Task;
4 use PHPUnit\Framework\TestCase;
5 use InvalidArgumentException;
6
7 class TaskTest extends TestCase
8 {
9 /** @test */
10 public function shouldNotAllowEmptyDescription(): void
11 {
12 $this->expectException(InvalidArgumentException::class);
13
14 new Task(1, '');
15 }
16
17 /** @test */
18 public function shouldProvideRepresentation(): void
19 {
20 $expected = '[ ] 1. Task Description';
21 $task = new Task(1, 'Task Description');
22
23 $representation = $task->representedAs('[:check] :id. :descript\
24 ion');
25
26 self::assertEquals($expected, $representation);
27 }
28
29 /** @test */
30 public function shouldMarkTaskCompleted(): void
31 {
32 $expected = '[√] 1. Task Description';
33 $task = new Task(1, 'Task Description');
34 $task->markCompleted();
35
36 $representation = $task->representedAs('[:check] :id. :descript\
37 ion');
38
39 self::assertEquals($expected, $representation);
40 }
41
42 /** @test */
43 public function shouldUpdateDescription(): void
44 {
45 $expected = '[ ] 1. New Task Description';
46 $task = new Task(1, 'Task Description');
47 $task->updateDescription('New Task Description');
48
49 $representation = $task->representedAs('[:check] :id. :descript\
50 ion');
51
52 self::assertEquals($expected, $representation);
53 }
54
55 }
Para hacer pasar el test tenemos que introducir el método.
1 namespace App\TodoList\Domain;
2
3 use InvalidArgumentException;
4
5 class Task
6 {
7 private int $id;
8 private string $description;
9 private bool $completed;
10
11 public function __construct(int $id, string $description)
12 {
13 $this->id = $id;
14
15 if ($description === '') {
16 $exceptionMessage = 'Task description should not be empty';
17 throw new InvalidArgumentException($exceptionMessage);
18 }
19
20 $this->description = $description;
21 $this->completed = false;
22 }
23
24 public function id(): int
25 {
26 return $this->id;
27 }
28
29 public function representedAs(string $format): string
30 {
31 $values = [
32 ':check' => $this->completed ? '√' : ' ',
33 ':id' => $this->id,
34 ':description' => $this->description
35 ];
36 return strtr($format, $values);
37
38 }
39
40 public function markCompleted(): void
41 {
42 $this->completed = true;
43 }
44
45 public function updateDescription(string $newTaskDescription): void
46 {
47 $this->description = $newTaskDescription;
48 }
49 }
El test pasa, pero nos hemos dado cuenta un problema. Hace nada hemos implementado una validación para impedir que Task::description pueda ser una cadena vacía. Para asegurar que cumplimos esta regla de negocio, deberíamos introducir otro test que lo verifique e implementar la respuesta que queramos dar a este caso.
Sin embargo, esto no lo hemos cubierto en el nivel de aceptación o en el del controlador. ¿Qué deberíamos hacer entonces? ¿Resolverlo ahora y añadir tests en los otros niveles después o esperar y añadir esa protección en una nueva iteración?
En este caso, creo que la mejor respuesta es tomar nota de esto y resolverlo en un nuevo ciclo. Es importante centrarnos ahora en la característica que estamos desarrollando y terminar este ciclo.
Por tanto, al hacer pasar el test unitario de Task, volvemos primero al test de UpdateTaskHandler y comprobamos si ya pasa, cosa que ocurre.
Y con este nivel en verde, probamos de nuevo en el de aceptación, que también pasa sin más problemas.
El resultado es que la nueva historia está implementada, aunque como hemos descubierto necesitamos hacer una iteración para prevenir el problema de intentar cambiar la descripción de una historia con un valor no válido.
¿Lo hubiésemos podido prevenir antes? Puede ser, sin embargo, igualmente necesitaríamos introducir tests en los distintos niveles, al igual que hicimos en el capítulo anterior. El valor de usar TDD es justamente desarrollar una serie de hábitos de pensamiento y una cierta automatización. En otras palabras, desarrollar una disciplina y llegar a todos los objetivos paso a paso.
Completar la historia
En cualquier caso, todo nuevo comportamiento del sistema tendría que estar definido mediante un test. Así que necesitaremos un test para incluir el cumplimiento de la regla de negocio, lo que nos lleva de nuevo al nivel de aceptación.
Puesto que es una regla de negocio, este test lo conservaremos después.
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 # ..
12
13 /** @test */
14 public function asUserITryToUpdateTaskWithAnEmptyTaskDescription():\
15 void
16 {
17 $this->givenIHaveAddedTasks(
18 [
19 'Write a test that fails',
20 'Write code to make the test pass',
21 ]
22 );
23
24 $this->client->request(
25 'PUT',
26 '/api/todo/1',
27 [],
28 [],
29 ['CONTENT-TYPE' => 'json/application'],
30 json_encode(['task' => ''], JSON_THROW_ON_ERROR)
31 );
32
33 $response = $this->client->getResponse();
34
35 self::assertEquals(400, $response->getStatusCode());
36
37 $body = json_decode($response->getContent(), true);
38
39 self::assertEquals('Task description should not be empty', $bod\
40 y['error']);
41 }
42
43 # ...
44 }
El test falla:
1 Failed asserting that 204 matches expected 400.
Lo que nos indica que se pueden crear tareas y modificarlas vaciando la descripción.
Ahora veamos cómo solucionar esto. Con la información disponible no tenemos una pista sobre dónde hay que intervenir.
Matizo: obviamente sabemos que hay que añadir una validación en el método updateDescription que hemos añadido en Task. Sin embargo, saltarnos los pasos solo nos llevaría a generar puntos ciegos en el desarrollo. No basta con lanzar una excepción desde Task, tenemos que asegurarnos de que el componente adecuado la captura y reacciona de la forma adecuada. Proceder sistemáticamente nos ayudará a evitar estos riesgos.
De hecho, el componente que tiene la responsabilidad de comunicarse en primera instancia con el test de aceptación es el controlador y, como ya hemos visto, es quien produce el código de respuesta que evaluamos en el test de aceptación. Por tanto, es el primer lugar en el que vamos a intervenir. Por supuesto, definiendo con un test el comportamiento que esperamos.
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\MarkTaskCompletedHandler;
6 use App\TodoList\Application\UpdateTaskHandler;
7 use App\TodoList\Application\TaskListTransformer;
8 use App\TodoList\Infrastructure\EntryPoint\Api\TodoListController;
9 use InvalidArgumentException;
10 use PHPUnit\Framework\TestCase;
11 use OutOfBoundsException;
12 use Symfony\Component\HttpFoundation\Request;
13
14 class TodoListControllerTest extends TestCase
15 {
16 private const TASK_DESCRIPTION = 'Task Description';
17 private const COMPLETED_TASK_ID = 1;
18 private const TASK_ID = 1;
19
20 private AddTaskHandler $addTaskHandler;
21 private TodoListController $todoListController;
22 private GetTaskListHandler $getTaskListHandler;
23 private TaskListTransformer $taskListTransformer;
24 private MarkTaskCompletedHandler $markTaskCompletedHandler;
25 private UpdateTaskHandler $updateTaskHandler;
26
27 protected function setUp(): void
28 {
29 $this->addTaskHandler = $this->createMock(AddTaskHandler::class\
30 );
31 $this->getTaskListHandler = $this->createMock(GetTaskListHandle\
32 r::class);
33 $this->taskListTransformer = $this->createMock(TaskListTransfor\
34 mer::class);
35 $this->markTaskCompletedHandler = $this->createMock(MarkTaskCom\
36 pletedHandler::class);
37 $this->updateTaskHandler = $this->createMock(UpdateTaskHandler:\
38 :class);
39
40 $this->todoListController = new TodoListController(
41 $this->addTaskHandler,
42 $this->getTaskListHandler,
43 $this->taskListTransformer,
44 $this->markTaskCompletedHandler,
45 $this->updateTaskHandler
46 );
47 }
48
49 # ...
50
51 /** @test */
52 public function shouldFailWithBadRequestIfTaskDescriptionIsEmptyWhe\
53 nUpdating(): void
54 {
55 $exceptionMessage = 'Task description should not be empty';
56 $exception = new InvalidArgumentException($exceptionMessage);
57
58 $this->updateTaskHandler
59 ->method('execute')
60 ->willThrowException($exception)
61 ->with(1, '');
62
63
64 $request = new Request(
65 [],
66 [],
67 [],
68 [],
69 [],
70 ['CONTENT-TYPE' => 'json/application'],
71 json_encode(['task' => ''], JSON_THROW_ON_ERROR)
72 );
73
74 $response = $this->todoListController->modifyTask(1, $request);
75
76 self::assertEquals(400, $response->getStatusCode());
77
78 $body = json_decode($response->getContent(), true);
79
80 self::assertEquals($exceptionMessage, $body['error']);
81 }
82
83 }
Al ejecutar el test en este nivel, vemos que falla porque se tira la excepción y no se controla. Implementamos la gestión de excepciones exactamente igual que en la acción de crear.
1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
2
3 use App\TodoList\Application\AddTaskHandler;
4 use App\TodoList\Application\GetTaskListHandler;
5 use App\TodoList\Application\MarkTaskCompletedHandler;
6 use App\TodoList\Application\UpdateTaskHandler;
7 use App\TodoList\Application\TaskListTransformer;
8 use InvalidArgumentException;
9 use OutOfBoundsException;
10 use Symfony\Component\HttpFoundation\JsonResponse;
11 use Symfony\Component\HttpFoundation\Request;
12 use Symfony\Component\HttpFoundation\Response;
13 use function is_string;
14
15 class TodoListController
16 {
17 private AddTaskHandler $addTaskHandler;
18 private GetTaskListHandler $getTaskListHandler;
19 private TaskListTransformer $taskListTransformer;
20 private MarkTaskCompletedHandler $markTaskCompletedHandler;
21 private UpdateTaskHandler $updateTaskHandler;
22
23 public function __construct(
24 AddTaskHandler $addTaskHandler,
25 GetTaskListHandler $getTaskListHandler,
26 TaskListTransformer $taskListTransformer,
27 MarkTaskCompletedHandler $markTaskCompletedHandler,
28 UpdateTaskHandler $updateTaskHandler
29 ) {
30 $this->addTaskHandler = $addTaskHandler;
31 $this->getTaskListHandler = $getTaskListHandler;
32 $this->taskListTransformer = $taskListTransformer;
33 $this->markTaskCompletedHandler = $markTaskCompletedHandler;
34 $this->updateTaskHandler = $updateTaskHandler;
35 }
36
37 # ...
38
39 public function markTaskCompleted(int $taskId, Request $request): R\
40 esponse
41 {
42 $payload = $this->obtainPayload($request);
43
44 try {
45 $this->markTaskCompletedHandler->execute($taskId, $payload[\
46 'completed']);
47 } catch (OutOfBoundsException $taskNotFound) {
48 return new JsonResponse(['error' => $taskNotFound->getMessa\
49 ge()], Response::HTTP_NOT_FOUND);
50 }
51
52 return new JsonResponse('', Response::HTTP_OK);
53 }
54
55 public function modifyTask(int $taskId, Request $request): Response
56 {
57 $payload = $this->obtainPayload($request);
58
59 try {
60 $this->updateTaskHandler->execute($taskId, $payload['task']\
61 );
62 } catch (\InvalidArgumentException $invalidTaskDescription) {
63 return new JsonResponse(['error' => $invalidTaskDescription\
64 ->getMessage()], Response::HTTP_BAD_REQUEST);
65 }
66
67 return new JsonResponse('', Response::HTTP_NO_CONTENT);
68 }
69
70 # ...
71 }
Esto hace que el test de controlador pase. Si chequeamos el test de aceptación vemos que sigue dando el mismo error.
El siguiente nivel es el caso de uso, que como hemos visto antes, es irrelevante porque simplemente dejará subir la excepción. Como ya sabemos, es Task quien se debe responsabilizar, así que ahora es el momento de abordar ese cambio, definiendo el comportamiento deseado en el test:
1 namespace App\Tests\TodoList\Domain;
2
3 use App\TodoList\Domain\Task;
4 use PHPUnit\Framework\TestCase;
5 use InvalidArgumentException;
6
7 class TaskTest extends TestCase
8 {
9 /** @test */
10 public function shouldNotAllowEmptyDescription(): void
11 {
12 $this->expectException(InvalidArgumentException::class);
13
14 new Task(1, '');
15 }
16
17 /** @test */
18 public function shouldProvideRepresentation(): void
19 {
20 $expected = '[ ] 1. Task Description';
21 $task = new Task(1, 'Task Description');
22
23 $representation = $task->representedAs('[:check] :id. :descript\
24 ion');
25
26 self::assertEquals($expected, $representation);
27 }
28
29 /** @test */
30 public function shouldMarkTaskCompleted(): void
31 {
32 $expected = '[√] 1. Task Description';
33 $task = new Task(1, 'Task Description');
34 $task->markCompleted();
35
36 $representation = $task->representedAs('[:check] :id. :descript\
37 ion');
38
39 self::assertEquals($expected, $representation);
40 }
41
42 /** @test */
43 public function shouldUpdateDescription(): void
44 {
45 $expected = '[ ] 1. New Task Description';
46 $task = new Task(1, 'Task Description');
47 $task->updateDescription('New Task Description');
48
49 $representation = $task->representedAs('[:check] :id. :descript\
50 ion');
51
52 self::assertEquals($expected, $representation);
53 }
54
55 /** @test */
56 public function shouldFailUpdatingWithInvalidDescription(): void
57 {
58 $this->expectException(InvalidArgumentException::class);
59
60 $task = new Task(1, 'Task Description');
61 $task->updateDescription('');
62 }
63 }
Al no haber nada implementado, el test fallará.
Empezamos con una implementación bastante obvia:
1 namespace App\TodoList\Domain;
2
3 use InvalidArgumentException;
4
5 class Task
6 {
7 private int $id;
8 private string $description;
9 private bool $completed;
10
11 public function __construct(int $id, string $description)
12 {
13 $this->id = $id;
14
15 if ($description === '') {
16 $exceptionMessage = 'Task description should not be empty';
17 throw new InvalidArgumentException($exceptionMessage);
18 }
19
20 $this->description = $description;
21 $this->completed = false;
22 }
23
24 public function id(): int
25 {
26 return $this->id;
27 }
28
29 public function representedAs(string $format): string
30 {
31 $values = [
32 ':check' => $this->completed ? '√' : ' ',
33 ':id' => $this->id,
34 ':description' => $this->description
35 ];
36 return strtr($format, $values);
37
38 }
39
40 public function markCompleted(): void
41 {
42 $this->completed = true;
43 }
44
45 public function updateDescription(string $newTaskDescription): void
46 {
47 if ($newTaskDescription === '') {
48 $exceptionMessage = 'Task description should not be empty';
49 throw new InvalidArgumentException($exceptionMessage);
50 }
51
52 $this->description = $newTaskDescription;
53 }
54 }
El test unitario de Task ya está en verde. Antes de nada, volvemos a lanzar el test de aceptación para ver si hemos resuelto el problema y no nos hemos dejado ningún cabo suelto. Y todo funciona.
Sin embargo, podríamos refactorizar un poco nuestra solución, ya que estamos intentando mantener la misma regla de negocio en dos lugares. Deberíamos unificarlo. Para ello utilizaremos auto-encapsulación. Es decir, crearemos un método privado con el cual asignar el valor de la descripción y validarlo. Así queda Task con este cambio.
1 namespace App\TodoList\Domain;
2
3 use InvalidArgumentException;
4
5 class Task
6 {
7 private int $id;
8 private string $description;
9 private bool $completed;
10
11 public function __construct(int $id, string $description)
12 {
13 $this->id = $id;
14
15 $this->setDescription($description);
16
17 $this->completed = false;
18 }
19
20 public function id(): int
21 {
22 return $this->id;
23 }
24
25 public function representedAs(string $format): string
26 {
27 $values = [
28 ':check' => $this->completed ? '√' : ' ',
29 ':id' => $this->id,
30 ':description' => $this->description
31 ];
32 return strtr($format, $values);
33
34 }
35
36 public function markCompleted(): void
37 {
38 $this->completed = true;
39 }
40
41 public function updateDescription(string $newTaskDescription): void
42 {
43 $this->setDescription($newTaskDescription);
44 }
45
46 private function setDescription(string $description): void
47 {
48 if ($description === '') {
49 $exceptionMessage = 'Task description should not be empty';
50 throw new InvalidArgumentException($exceptionMessage);
51 }
52
53 $this->description = $description;
54 }
55 }
Y con esto, hemos implementado la nueva historia de usuario. Te habrás dado cuenta de que en todos los casos, ya sean nuevas historias de usuario, modificación de prestaciones o corrección de defectos, nuestro procedimiento es siempre el mismo. Definir el comportamiento deseado del sistema mediante un test y añadir el código de producción que sea necesario para hacerlo pasar.