Adding new features

In the previous chapter we talked about how, from the point of view of TDD-based development, all of the defects can almost be considered as features that just weren’t defined initially. Another way of looking at it is that they’re called features if we’re asked for them explicitly, and defects if they’re implicit in another feature, but haven’t been developed.

That is, when we say that we want to be able to mark a task as completed, to follow up with out to-do list project, we can assume that the system mustn’t break if we try to mark an inexistent task. For this reason, we’d say that this feature had a bug, and that’s precisely what we fixed in the previous chapter.

But in this chapter, we’ll tackle how to add new features to existing software by following a TDD approach. And, as it could be expected, we’re not actually going to make any change to our methodology. We’ll still be starting with an acceptance test, and delving deeper into the application and the necessary changes.

All in all, it’s a different scenario. A new behavior might require us to modify existing software units, and we need to make sure that the changes don’t break any of the already created functionality.

New user story

The next business requirement is to allow to edit an existing task.

US-4

  • As a user
  • I want to modify a existing task in the list
  • So that, I can express my ideas better

Initially, this story requires the creation of a new endpoint with which to change a task’s information.

1 PUT /api/todo/{taskId}

If our application has a front-end, we might need an endpoint to recover the information of the task that we wish to edit, with the purpose of filling out the form with the current data. In this case, it would be:

1 GET /api/todo/{taskId}

In both cases, the procedure will be the same: we’ll begin by creating an acceptance test, initiating the development process. What we will find is that some of the necessary components are already created.

 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 test pass'], J\
29 SON_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 }

So, we run the test to see what it tells us. As expected, the endpoint cannot be found because we don’t have the route, so we start by defining it.

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListController::addTask
 4   methods: ['POST']
 5 
 6 api_get_task_list:
 7   path: /api/todo
 8   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListController::getTask\
 9 List
10   methods: ['GET']
11 
12 api_mark_task_completed:
13   path: /api/todo/{taskId}
14   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListController::markTas\
15 kCompleted
16   methods: ['PATCH']
17 
18 api_edit_task:
19   path: /api/todo/{taskId}
20   controller: App\TodoList\Infrastructure\EntryPoint\Api\TodoListController::modifyT\
21 ask
22   methods: ['PUT']

When rerunning the test after this change, it’ll tell us that there isn’t any action in the controller that’s able to respond to this route.

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

So we’ll have to add a new empty action.

 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 }

In the new execution of the test, the error will be:

1 RuntimeException: "Implement App\TodoList\Infrastructure\EntryPoint\Api\TodoListCont\
2 roller::modifyTask"

Which tells us that we have to dive into the unit level to implement this action in the controller. This cycle will ring a bell, because it’s what we’ve been doing in all this part of the book.

But the truth is that this routine is something positive. We always have a concrete task to tackle at every moment, be it creating a test or writing production code, and we don’t have to worry about anything else. The acceptance test tells us what to do, and at each level we just have to think about the specific component that we’re working on.

It’s time for us to implement the controller. As we already know, at this stage we have to design. Basically, it’s a similar action to adding a task, but in this case we’ll receive the id of the task that we’re going to modify, as well as its new description.

We’ll need a use case that expresses this user intention, to which we’ll pass the two pìeces of data that we need. If everything goes as planned, we’ll return the 204 response (no content).

We add a test that encompasses all this:

 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         $this->getTaskListHandler = $this->createMock(GetTaskListHandler::class);
30         $this->taskListTransformer = $this->createMock(TaskListTransformer::class);
31         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCompletedHandler\
32 ::class);
33         $this->updateTaskHandler = $this->createMock(UpdateTaskHandler::class);
34 
35         $this->todoListController = new TodoListController(
36             $this->addTaskHandler,
37             $this->getTaskListHandler,
38             $this->taskListTransformer,
39             $this->markTaskCompletedHandler,
40             $this->updateTaskHandler
41         );
42     }
43 
44     # ...
45     
46     /** @test */
47     public function shouldModifyATask(): void
48     {
49         $this->updateTaskHandler
50             ->expects(self::once())
51             ->method('execute')
52             ->with(self::TASK_ID, self::TASK_DESCRIPTION);
53 
54         $request = new Request(
55             [],
56             [],
57             [],
58             [],
59             [],
60             ['CONTENT-TYPE' => 'json/application'],
61             json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_ON_ERROR)
62         );
63 
64         $response = $this->todoListController->modifyTask(self::TASK_ID,$request);
65 
66         self::assertEquals(204, $response->getStatusCode());
67     }
68 
69     # ...
70 }

If we run the test, it will ask us to create the UpdateTaskHandler use case.

1 namespace App\TodoList\Application;
2 
3 class UpdateTaskHandler
4 {
5 }

And next, it will ask for the execute method.

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 }

Once we have that, it again asks us to implement the controller’s action. So we get to it:

 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 }

And the controller’s unit test passes. If we return to the acceptance test, as we should be doing now, it’ll tell us what we have to do next:

1 RuntimeException: "Implement App\TodoList\Application\UpdateTaskHandler::execute"

So, it’s time to get into the application layer. Again, we have to design this level, which poses an interesting problem.

In principle, we have defined that the field of the task that can be changed is just its description. Therefore, this action has to respect the current stated of the completed flag. So what we want is to recover the stored task, modify its description, and save it.

Therefore, we’ll ask the repository for the task, we’ll change it, and we’ll store it again.

 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 Description');
29     }
30 }

When we run the test, it will ask us to implement the use case, as the repository had already been defined previously.

The implementation will surely force us to introduce some new method in Task, making a way for the description to be updated. This one, for example:

 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): void
16     {
17         $task = $this->taskRepository->retrieve($taskId);
18 
19         $task->updateDescription($newTaskDescription);
20 
21         $this->taskRepository->store($task);
22     }
23 }

I’ve chosen this implementation to simplify. However, as I write this, I can come up some ideas that could be interesting in a realistic use case. One of them could be to apply a certain immutability, that is, instead of updating the Task object, we’d create a new one filled out with new values.

But we’ll leave those refinements for another occasion. If the run the test, it’ll tell us that Task is lacking the updateDescription method, which we’ll have to develop with the help of a unit 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. :description');
24 
25         self::assertEquals($expected, $representation);
26     }
27 
28     /** @test */
29     public function shouldMarkTaskCompleted(): void
30     {
31         $expected = '[√] 1. Task Description';
32         $task = new Task(1, 'Task Description');
33         $task->markCompleted();
34 
35         $representation = $task->representedAs('[:check] :id. :description');
36 
37         self::assertEquals($expected, $representation);
38     }
39 
40     /** @test */
41     public function shouldUpdateDescription(): void
42     {
43         $expected = '[ ] 1. New Task Description';
44         $task = new Task(1, 'Task Description');
45         $task->updateDescription('New Task Description');
46 
47         $representation = $task->representedAs('[:check] :id. :description');
48 
49         self::assertEquals($expected, $representation);
50     }
51 
52 }

To make the test pass, we have to introduce the method.

 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 }

The test passes, but we’ve noticed a problem. A few moments ago we had implemented a validation in order to prevent Task::description from being an empty string. To ensure that we’re fulfilling this business rule, we should introduce another test that verifies it and implement the answer that we want to give to this case.

However, we haven’t covered this in either the acceptance or the controller level. What should we do then? Solve it now and add tests at the other levels later, or wait and add this protection in a new iteration?

Personally, I think that the best option is to take note of this and solve it in a new cycle. It’s important to focus in the feature that we’re developing right now and finish the cycle.

Therefore, when we make the Task unit test, we first return to the UpdateTaskHandler and verify that it passes, which is exactly what happens.

And having this level in green, we try the acceptance one again, which also passes without any trouble.

The result is that the new story is now implemented, although as we’ve discovered, we need to do an extra iteration to prevent the problem of trying to update the description of a task with an invalid value.

Could we have prevented this earlier? Well it might be so. However, we’d still have needed to introduce tests at the different levels, just as we did in the previous chapter. The value of using TDD lies, precisely, in developing a series of thought habits and a certain automatization. In other words, to reach a degree of discipline and methodically reach every objective step by step.

Complete the story

In any case, every new behavior of the system should be covered by a test. So we’ll need a test to include the fulfillment of the business rule, which takes us back to the acceptance level.

As this is a business rule, we’ll be keeping this test afterwards.

 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(): void
15     {
16         $this->givenIHaveAddedTasks(
17             [
18                 'Write a test that fails',
19                 'Write code to make the test pass',
20             ]
21         );
22 
23         $this->client->request(
24             'PUT',
25             '/api/todo/1',
26             [],
27             [],
28             ['CONTENT-TYPE' => 'json/application'],
29             json_encode(['task' => ''], JSON_THROW_ON_ERROR)
30         );
31 
32         $response = $this->client->getResponse();
33 
34         self::assertEquals(400, $response->getStatusCode());
35 
36         $body = json_decode($response->getContent(), true);
37 
38         self::assertEquals('Task description should not be empty', $body['error']);
39     }
40 
41     # ...
42 }

The test fails:

1 Failed asserting that 204 matches expected 400.

Which indicates that, right now, tasks can be created and updated with an empty description.

Now let’s see how to fix this. With the available information, we don’t have a clue about where to intervene.

I mean: we obviously know that we need to add a validation to the updateDescription that we’ve included in Task. However, skipping steps would only lead us to generating blind spots in the development. It’s not enough to throw an exception from Task, we have to make sure that the appropriate captures it and reacts adequately. Proceeding systematically will help us prevent this risks.

In fact, the component that has the responsibility of communicating with the acceptance test in the first place is the controller, and as we’ve already seen, is the one who produces the response code that we evaluate in the acceptance test. Therefore, it’s the first place where we’re going to intervene. Of course, by defining a test with the expected behavior.

 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         $this->getTaskListHandler = $this->createMock(GetTaskListHandler::class);
31         $this->taskListTransformer = $this->createMock(TaskListTransformer::class);
32         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCompletedHandler\
33 ::class);
34         $this->updateTaskHandler = $this->createMock(UpdateTaskHandler::class);
35 
36         $this->todoListController = new TodoListController(
37             $this->addTaskHandler,
38             $this->getTaskListHandler,
39             $this->taskListTransformer,
40             $this->markTaskCompletedHandler,
41             $this->updateTaskHandler
42         );
43     }
44 
45     # ...
46     
47     /** @test */
48     public function shouldFailWithBadRequestIfTaskDescriptionIsEmptyWhenUpdating(): \
49 void
50     {
51         $exceptionMessage = 'Task description should not be empty';
52         $exception = new InvalidArgumentException($exceptionMessage);
53 
54         $this->updateTaskHandler
55             ->method('execute')
56             ->willThrowException($exception)
57             ->with(1, '');
58 
59 
60         $request = new Request(
61             [],
62             [],
63             [],
64             [],
65             [],
66             ['CONTENT-TYPE' => 'json/application'],
67             json_encode(['task' => ''], JSON_THROW_ON_ERROR)
68         );
69 
70         $response = $this->todoListController->modifyTask(1, $request);
71 
72         self::assertEquals(400, $response->getStatusCode());
73 
74         $body = json_decode($response->getContent(), true);
75 
76         self::assertEquals($exceptionMessage, $body['error']);
77     }
78 
79 }

When we run the test at this level, we see it fail because the exception is thrown but not controlled. We implement the exception handling just like the creation action.

 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): Response
40     {
41         $payload = $this->obtainPayload($request);
42 
43         try {
44             $this->markTaskCompletedHandler->execute($taskId, $payload['completed']);
45         } catch (OutOfBoundsException $taskNotFound) {
46             return new JsonResponse(['error' => $taskNotFound->getMessage()], Respon\
47 se::HTTP_NOT_FOUND);
48         }
49 
50         return new JsonResponse('', Response::HTTP_OK);
51     }
52 
53     public function modifyTask(int $taskId, Request $request): Response
54     {
55         $payload = $this->obtainPayload($request);
56 
57         try {
58             $this->updateTaskHandler->execute($taskId, $payload['task']);
59         } catch (\InvalidArgumentException $invalidTaskDescription) {
60             return new JsonResponse(['error' => $invalidTaskDescription->getMessage(\
61 )], Response::HTTP_BAD_REQUEST);
62         }
63 
64         return new JsonResponse('', Response::HTTP_NO_CONTENT);
65     }
66 
67     # ...
68 }

This passes the controller test. If we check the acceptance test, we see that it keeps returning the same error.

The next level is the use case, which as we’ve seen previously, is irrelevant because it will simply allow the exception to rise. As we know, it’s Task who must take responsibility, so now’s the time to tackle that change, defining the desired behavior in the 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. :description');
24 
25         self::assertEquals($expected, $representation);
26     }
27 
28     /** @test */
29     public function shouldMarkTaskCompleted(): void
30     {
31         $expected = '[√] 1. Task Description';
32         $task = new Task(1, 'Task Description');
33         $task->markCompleted();
34 
35         $representation = $task->representedAs('[:check] :id. :description');
36 
37         self::assertEquals($expected, $representation);
38     }
39 
40     /** @test */
41     public function shouldUpdateDescription(): void
42     {
43         $expected = '[ ] 1. New Task Description';
44         $task = new Task(1, 'Task Description');
45         $task->updateDescription('New Task Description');
46 
47         $representation = $task->representedAs('[:check] :id. :description');
48 
49         self::assertEquals($expected, $representation);
50     }
51 
52     /** @test */
53     public function shouldFailUpdatingWithInvalidDescription(): void
54     {
55         $this->expectException(InvalidArgumentException::class);
56 
57         $task = new Task(1, 'Task Description');
58         $task->updateDescription('');
59     }
60 }

As there’s nothing implement, the test will fail.

We begin with a pretty obvious implementation:

 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 }

The Task unit test is already in green. Before anything else, we rerun the acceptance test to se if we’ve solved he problem and we haven’t missed any loose ends. And indeed everything works!

Nonetheless, we could refactor our solution a bit, since we’re trying to maintain the same business rule in two places at once. We should unify it. To do so, we’ll use auto-encapsulation, that is, we’ll create a private method with which to assign and validate the description’s value. This is how Task looks after this change.

 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 }

And with this, we’ve implemented the new user story. You’ve probably noticed that in every case, be them new user stories, modification of features, or correction of defects, our procedure is always the same. Define the desired behavior with a test, and add the necessary production code to make it pass.