Task list, outside-in TDD sliced in user stories

In this version of the same exercise about creating an application using TDD, we’ll work with the project organized in user stories. That is: we have divided the project into features that provide value. Our goal is to show a work methodology that we could put into practice inside real projects.

This project will be done in PHP, with PHPUnit and some components from Symfony framework. The solution is a bit different from that in the previous chapter because this time we are going to limit the scope of our job to the user story, and that imposes some constraints that we didn’t have before.

Adding task to a list

Let’s review the definition:

US 1

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

To complete this user story we will need, apart from an endpoint to which we can call and a controller that manages it, a use case to add tasks to the list, and a repository to store them. Our use case will be a command, so the effect of the action will be a call to the repository storing every new task.

To be able to verify this with a test we don’t want to write code that won’t be needed in production. For example, we are not going to write methods (yet) to retrieve information from the repository. Strictly speaking, at this moment we don’t even know if we are going to need them (spoiler: yes, but that will be coding for a future that we don’t know). So, at first, we will use a mock of repository and check that the right calls are made.

Once we have this clarified, we write a test that will send a POST request to the endpoint to create a new task and will verify that at some time, we are calling to a task repository, trusting that the real implementation will manage it correctly when available.

It is a good idea to start the test from the end, stating what we expect, and build the rest with the needed actions. In this case, we expect the existence of a TaskRepository that will be an interface. Also, we will introduce the concept of 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, $taskRepository);
20 
21         $client->request(
22             'POST',
23             '/api/todo',
24             [],
25             [],
26             ['CONTENT-TYPE' => 'json/application'],
27             json_encode(['task' => 'Write a test that fails'], JSON_THROW_ON_ERROR)
28         );
29     }
30 
31     protected function setUp(): void
32     {
33         $this->resetRepositoryData();
34     }
35 
36     protected function tearDown(): void
37     {
38         $this->resetRepositoryData();
39     }
40 
41     private function resetRepositoryData(): void
42     {
43         if (file_exists('repository.data')) {
44             unlink('repository.data');
45         }
46     }
47 }

We will have to run the test and implement all the things that it will be asking until we get it failing because the right reason.

The first error message is that we haven’t defined TaskRepository, so we start from there:

1 Cannot stub or mock class or interface "App\Tests\Katas\TodoList\TaskRepository" whi\
2 ch does not exist

This error happens in PHP and PHPUnit. In other languages, you could find a different one.

For the moment, my solution is to create it in the same file as the test. If the error message changes, then I will move it to its file.

1 interface TaskRepository
2 {
3 
4 }

Now, the test fails because of a different reason, so we have passed this obstacle. We use the Move Class refactoring to move TaskRepository to App\TodoList\Domain\TaskRepository and then we run again the tests, getting the following error:

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

That is telling us that we have not defined the class Task. At the moment, we will create Taskin the same file, re-running the test to see if the error message changes.

1 class Task
2 {
3     
4 }

The error is saying that there is not a method store in TaskRepository, so we can’t mock it. We have to introduce it, but we will move Task to its place in App\TodoList\Domain before. As you can see, we are organizing code using a layered architecture.

After moving Task, we add the store method to TaskRepository:

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

The next error is a bit weirder:

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

It has to do with the Symfony framework configuration that we are using for this exercise. This message is telling us that there are no files that contain controllers in the given path and namespace. I don’t want them there, instead, I want to put them into App\TodoList\Infrastructure\EntryPoint\Api. This is because I want to keep a clean architecture, with components organized in layers. Controllers and other entry points to the application are in the infrastructure layer, inside a category EntryPoint that, in this case, has a port, related to the communication using the API.

To achieve this, we only have to go to the file config/services.yaml and change what is required:

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

When we run the test, we get a similar error:

1 Symfony\Component\Config\Exception\LoaderLoadException : The file "../src/TodoList/I\
2 nfrastructure/EntryPoint/Api" 
3  does not exist (in: /application/config) in /application/config/services.yaml
4  (which is loaded in resource "/application/config/services.yaml").

This is positive because it reflects that we have made the change in services.yaml right, but we haven’t added a controller in the desired location so that it can be loaded and avoid the error. So we add a TodoListController.php file to the folder with this code:

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

Running this test throws to new error message. This is the first:

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

And that’s a framework problem because the HTTP client is calling to an endpoint that is not defined anywhere yet. We solve this by configuring it in routes.yaml.

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

As we always do after a change, we run the test, that now will shout that there is not a method in the controller in charge of managing the response to this end point.

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

We can implement it this way:

 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 }

It is a single line that throws an exception to mark that the method is not implemented. We do it this way so the test itself stated that we have something not implemented. An empty method body would not tell us anything and, in many situations, it would be easy to lose track of the thing we have left pending to write.

If we run the test, it throws exactly that error:

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

But also this one, from the test itself.

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

This is the error which we will be expecting from the test as we wrote it. There are no framework configuration errors. I tell us that a Task is never stored in the repository. In other words: no production code executes the desired behavior.

Those two errors tell as that is the time to implement.

And to do that, we need to go one step into the application. In our example, this is TodoListController. At this point, we leave the acceptance test loop and we enter in a cycle of unitary tests to develop TodoListController::addTask.

Designing in red

The acceptance test is not passing and it is asking us to implement something in TodoListController. To do so, we are going to think about how we want the controller to be and it will delegate the job to other objects.

In particular, we want the controller to be a very tiny layer in charge of:

  • Getting the necessary information from the request
  • Pass it to a use case to do what is needed
  • Get the response from the use case and send it back to the endpoint

In a classic approach, we would implement the complete solution in the controller and, then, we would be moving logic to the required components.

Instead of that, in the mockist approach, we design how this implementation level would look like and we use doubles for collaborations we could need. For example, this is our 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_ERROR)
29         );
30         
31         $response = $todoListController->addTask($request);
32 
33         self::assertEquals(201, $response->getStatusCode());
34     }
35 }

In this test, two things are verified. On the one hand, that we return a response with a status code of 201 (created). On the other, that we will have a use case named AddTaskHandler in charge to process the creation of the task provided its descriptions, that it’s received as a payload in the request.

When we run the test, we start getting the expected error. The first on is that we don’t have any AddTaskHandler. Again, I’ll start by adding it to the test fail, and I’ll move it in the next step. In fact, it’s literally what the error says:

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

So, we add:

1 class AddTaskHandler
2 {
3 
4 }

Running the test now will ask us to implement the method execute that is not defined. Before doing so, we are going to move AddTaskHandler, that is the use case, to its place in the application layer: App\TodoList\Application. Next, we add the method including our “not implemented” exception.

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 }

This way, what will happen is as follows: once we’ve implemented the controller, we’ll see that its unitary test passes, because we are using the AddTaskHandler double and we are not calling to the real code. This will happen when running the acceptance test, which will be pointing us to implement AddTaskHandler and go one lever deeper in the application.

The next error is well known:

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

This indicates that the test is calling the addTask method, not implemented yet. It is just where we want to be. We will implement logic in TodoListController::addTask to make the test pass:

 1 namespace App\TodoList\Infrastructure\EntryPoint\Api;
 2 
 3 use App\TodoList\Application\AddTaskHandler;
 4 use Symfony\Component\HttpFoundation\JsonResponse;
 5 use Symfony\Component\HttpFoundation\Request;
 6 use Symfony\Component\HttpFoundation\Response;
 7 
 8 class TodoListController
 9 {
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 = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERRO\
22 R);
23 
24         $this->addTaskHandler->execute($payload['task']);
25         return new JsonResponse('', Response::HTTP_CREATED);
26     }
27 }

The test passes!

We could go slower here to drive the implementation with smaller steps, but I think that it’s better to do it in only one because the logic is not too complex, and this way we’re not too dispersed. The important thing, however, is that we have achieved the goal of developing this controller with a unitary test that is passing right now.

Given that the unitary test is passing, we don’t have any more work to do at this level. Nevertheless, I’m going to make a little refactoring to hide the details of getting the payload from the request, leaving the body of the controller a bit cleaner and easy to follow.

 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_THROW_ON_ERROR);
31     }
32 }

Returning to the acceptance test

Once we’ve made the unit test pass, we have to return to the acceptance level so that it tells us how to proceed. We execute it and we get the following:

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

Now it’s time to go a little bit deeper in the application and move on to the AddTaskHandler use case. What we expect from this UseCase is that it uses the information it receives to create a task, and stores it in TaskRepository.

To create a task, we’ll need to give it an ID, which we’re going to ask from the repository itself. The repository will have an appropriate method.

We can express this with the following unit test.

 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 }

We run the test. We get this error first:

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

We add the method to the interface:

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

Which generates this error:

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

And we’re ready to implement the use case. This code should suffice:

 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 }

This code is enough to pass the test, so we can go back to the acceptance level.

New cycle

When rerunning the acceptance test we find out that it passes. However, the use story isn’t implemented yet, as we don’t have a concrete repository in which Task objects are being saved. In fact, our Task classes don’t have any code yet.

The reason is that we’re using a TaskRepository mock in the acceptance test. We’d like to stop using it so that TodoList uses a concrete implementation. The problem that we’d have if we did so, would be that we wouldn’t have any method to explore the content of the repository and verify the test. We’re going to do this in two stages.

In the first one, we simple remove the usage of the mock and verify that the API response returns the code 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_THROW_ON_ERROR)
22         );
23 
24         $response = $client->getResponse();
25 
26         self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
27     }
28 
29 
30     protected function setUp(): void
31     {
32         $this->resetRepositoryData();
33     }
34 
35     protected function tearDown(): void
36     {
37         $this->resetRepositoryData();
38     }
39 
40     private function resetRepositoryData(): void
41     {
42         if (file_exists('repository.data')) {
43             unlink('repository.data');
44         }
45     }
46 }

Before continuing, we have to erase the service definition that we made earlier in services_test.yaml. As it’s the only service that we had declared there, we can just delete the file without any problem.

And when we execute the test, the following framework error appears:

1 Cannot autowire service "App\TodoList\Application\AddTaskHandler": argument "$taskRe\
2 pository" of method "__construct()"

This happens because we just have an interface of TaskRepository, and we would need a concrete implementation that we could use. This way, we have an error that lets us move on with the development. We’ll need a test to implement FileTaskRepository, a repository based on a simple text file to store the serialize objects:

 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' => [$class]]);
21         fclose($file);
22 
23         return $objects;
24     }
25 
26     public function persistObjects(array $objects): void
27     {
28         $file = fopen($this->filePath, 'wb');
29         fwrite($file, serialize($objects));
30         fclose($file);
31     }
32 }

In the first place we’re going to create a default implementation for FileTaskRepository in the right place, which will be 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 }

When we execute the acceptance test again two errors happen. One tells us that we have to implement the repositories’ nextIdentity method. The other, which is an error of the test itself, informs us that the endpoint returns the 500 code instead of 201. This is reasonable, as out current FileTaskRepository implementation will fail fatally.

But it’s good news, because it tells us how to proceed. So, we’ll create a new unit test to guide the development of FileTaskRepository. In this test, we simulate a different number of objects in storage to ensure the correct implementation.

 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(): void
11     {
12         $storageEngine = $this->createMock(FileStorageEngine::class);
13 
14         $taskRepository = new FileTaskRepository($storageEngine);
15         $storageEngine
16             ->method('loadObjects')
17             ->willReturn(
18                 [],
19                 ['Task'],
20                 ['Task', 'Task']
21             );
22 
23         self::assertEquals(1, $taskRepository->nextIdentity());
24         self::assertEquals(2, $taskRepository->nextIdentity());
25         self::assertEquals(3, $taskRepository->nextIdentity());
26     }
27 }

With this test passing, we return to the acceptance test, which fails again. The endpoint returns an error 500 because we don’t have an implementation of the store method in FileTaskRepository.

We’ll introduce a new test, although we’ve refactored it a bit beforehand in order to make introducing changes easier:

 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::class);
17         $this->taskRepository = new FileTaskRepository($this->fileStorageEngine);
18     }
19 
20     /** @test */
21     public function shouldProvideNextIdentityCountingExistingObjects(): void
22     {
23         $this->fileStorageEngine
24             ->method('loadObjects')
25             ->willReturn(
26                 [],
27                 ['Task'],
28                 ['Task', 'Task']
29             );
30 
31         self::assertEquals(1, $this->taskRepository->nextIdentity());
32         self::assertEquals(2, $this->taskRepository->nextIdentity());
33         self::assertEquals(3, $this->taskRepository->nextIdentity());
34     }
35 
36     /** @test */
37     public function shouldStoreATask(): void
38     {
39         $task = new Task(1, 'Task Description');
40 
41         $this->fileStorageEngine
42             ->method('loadObjects')
43             ->willReturn([]);
44         $this->fileStorageEngine
45             ->expects(self::once())
46             ->method('persistObjects')
47             ->with([1 => $task]);
48 
49         $this->taskRepository->store($task);
50     }
51 }

This is our implementation to pass the 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 }

We have to implement the Task::id method, which makes us also introduce a 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 }

The implementation passes the test. To not lose track I won’t introduce any more examples, which would be appropriate to grow more confidence about the behavior of the test. But, for now, it’s enough to understand the process.

As we’re in green, we go back to the acceptance test to check how far we’ve come. And when we run it, the acceptance test passes, indicating that the feature is complete. Or almost, as at the moment we don’t have any way to know if the tasks have been stored or not.

One possibility is to obtain the contents of FileStorageEngine and see if our tasks are there. It doesn’t force us to implement anything in the production code:

 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_THROW_ON_ERROR)
22         );
23 
24         $response = $client->getResponse();
25 
26         self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
27 
28         $storage = new FileStorageEngine('repository.data');
29         $tasks = $storage->loadObjects(Task::class);
30 
31         self::assertCount(1, $tasks);
32         self::assertEquals(1, $tasks[1]->id());
33     }
34 
35 
36     protected function setUp(): void
37     {
38         $this->resetRepositoryData();
39     }
40 
41     protected function tearDown(): void
42     {
43         $this->resetRepositoryData();
44     }
45 
46     private function resetRepositoryData(): void
47     {
48         if (file_exists('repository.data')) {
49             unlink('repository.data');
50         }
51     }
52 }

The test verifies that we’ve stored a task in the repository, confirming that the first user story is implemented. It may be a good time to examine what we’ve done so far and see if we can do any refactoring that may facilitate the next development steps.

Let’s start with the acceptance test:

 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('Write a test t\
17 hat fails');
18 
19         $this->thenResponseShouldBeSuccessful($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 $taskDescripti\
45 on): 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_ERROR)
54         );
55 
56         return $this->client->getResponse();
57     }
58 
59     private function thenResponseShouldBeSuccessful(Response $response): void
60     {
61         self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
62     }
63 
64     private function thenTheTaskIsStored(): void
65     {
66         $storage = new FileStorageEngine('repository.data');
67         $tasks = $storage->loadObjects(Task::class);
68 
69         self::assertCount(1, $tasks);
70         self::assertEquals(1, $tasks[1]->id());
71     }
72 }

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         $this->todoListController = new TodoListController($this->addTaskHandler);
18     }
19 
20 
21     /** @test */
22     public function shouldAddTask(): void
23     {
24         $this->addTaskHandler
25             ->expects(self::once())
26             ->method('execute')
27             ->with(self::TASK_DESCRIPTION);
28 
29         $request = new Request(
30             [],
31             [],
32             [],
33             [],
34             [],
35             ['CONTENT-TYPE' => 'json/application'],
36             json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_ON_ERROR)
37         );
38 
39         $response = $this->todoListController->addTask($request);
40 
41         self::assertEquals(201, $response->getStatusCode());
42     }
43 }

There are other small changes in some files, but we’re not going to go into detail here.

See the tasks on the list

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

Our second user story requires its own endpoint, controller, and use case. We already have a task repository, to which we’ll have to add a method to retrieve the full list.

Since we have a real implementation of the repository, we no longer have to use a mock, as we had done earlier to be able to kickstart the development. In a situation in which we were using database persistence, or something similar, we’d probably need a fake implementation, such an in-memory repository or even the simple file repository that we’re using, which we need because of the persistence problem between PHP requests.

This is the first version of the acceptance test for this user story:

  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('Write a test t\
 17 hat fails');
 18 
 19         $this->thenResponseShouldBeSuccessful($response);
 20 
 21         $this->thenTheTaskIsStored();
 22     }
 23 
 24     /** @test */
 25     public function asUserIWantToSeeTheTasksInMyTodoList(): void
 26     {
 27         $expectedList = [
 28             '[ ] 1. Write a test that fails',
 29             '[ ] 2. Write code to make the test pass'
 30         ];
 31         
 32         $this->apiCreateTaskWithDescription('Write a test that fails');
 33         $this->apiCreateTaskWithDescription('Write code to make the test pass');
 34         
 35         $this->client->request(
 36             'GET',
 37             '/api/todo'
 38         );
 39 
 40         $response =  $this->client->getResponse();
 41         
 42         self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
 43         
 44         $taskList = json_decode($response->getContent(), true);
 45         
 46         self::assertEquals($expectedList, $taskList);
 47     }
 48 
 49     protected function setUp(): void
 50     {
 51         $this->resetRepositoryData();
 52 
 53         $this->client = self::createClient();
 54     }
 55 
 56     protected function tearDown(): void
 57     {
 58         $this->resetRepositoryData();
 59     }
 60 
 61     private function resetRepositoryData(): void
 62     {
 63         if (file_exists('repository.data')) {
 64             unlink('repository.data');
 65         }
 66     }
 67 
 68     private function whenWeRequestToCreateATaskWithDescription(string $taskDescripti\
 69 on): Response
 70     {
 71         return $this->apiCreateTaskWithDescription($taskDescription);
 72     }
 73 
 74     private function thenResponseShouldBeSuccessful(Response $response): void
 75     {
 76         self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
 77     }
 78 
 79     private function thenTheTaskIsStored(): void
 80     {
 81         $storage = new FileStorageEngine('repository.data');
 82         $tasks = $storage->loadObjects(Task::class);
 83 
 84         self::assertCount(1, $tasks);
 85         self::assertEquals(1, $tasks[1]->id());
 86     }
 87 
 88     private function apiCreateTaskWithDescription(string $taskDescription): Response
 89     {
 90         $this->client->request(
 91             'POST',
 92             '/api/todo',
 93             [],
 94             [],
 95             ['CONTENT-TYPE' => 'json/application'],
 96             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERROR)
 97         );
 98 
 99         return $this->client->getResponse();
100     }
101 }

So we run it and, as before, we take note of the error that it throws and fix them until the test fails for the proper reasons. In this case we can see two related errors.

The first one is that there isn’t an appropriate route for the endpoint.

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

Which, of course, causes the error in the test when verifying the state code:

1 Failed asserting that 405 matches expected 200.

We configure the route in routes.yaml:

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

We run the test. The error is different, which indicates that we’ve correctly made the change, but now it tells us that we’re missing the specific controller:

1 "The controller for URI "/api/todo" is not callable. Expected method "getTaskList" o\
2 n class "App\TodoList\Infrastructure\EntryPoint\Api\TodoListController"

So we add our initial empty implementation:

 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     private function obtainPayload(Request $request): array
34     {
35         return json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
36     }
37 }

When we run the test again, an exception is thrown indicating that we need to implement something. It’s time to go back to the TodoListController test. It’s important to learn to identify when to move between the acceptance test cycle and the unit tests cycle.

The new test helps us introduce the new use case GetTaskListHandler, but it also poses an interesting problem: what should GetTaskListHandler, Task objects, or a representation of them?

In this case, the most adequate option would be to use some kind of DataTransformer and apply a Strategy pattern so that TodoListController tells the use case which DataTransformer it wants to use. This transformer can be passed to the controller as a dependency, and it will send it to the use case as a parameter.

As you can see, now we’re literally designing. So we’re going to see how the test ends up.

 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         $this->getTaskListHandler = $this->createMock(GetTaskListHandler::class);
20         $this->taskListTransformer = $this->createMock(TaskListTransformer::class);
21         $this->todoListController = new TodoListController(
22             $this->addTaskHandler,
23             $this->getTaskListHandler,
24             $this->taskListTransformer
25         );
26     }
27 
28     /** @test */
29     public function shouldAddTask(): void
30     {
31         $this->addTaskHandler
32             ->expects(self::once())
33             ->method('execute')
34             ->with(self::TASK_DESCRIPTION);
35 
36         $request = new Request(
37             [],
38             [],
39             [],
40             [],
41             [],
42             ['CONTENT-TYPE' => 'json/application'],
43             json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_ON_ERROR)
44         );
45 
46         $response = $this->todoListController->addTask($request);
47 
48         self::assertEquals(201, $response->getStatusCode());
49     }
50 
51     /** @test */
52     public function shouldGetTaskList(): void
53     {
54         $expectedList = [
55             '[ ] 1. Task Description',
56             '[ ] 2. Task Description',
57         ];
58         $this->getTaskListHandler
59             ->expects(self::once())
60             ->method('execute')
61             ->with($this->taskListTransformer)
62             ->willReturn($expectedList);
63 
64         $response = $this->todoListController->getTaskList(new Request());
65 
66         self::assertEquals(200, $response->getStatusCode());
67 
68         $list = json_decode($response->getContent(), true);
69 
70         self::assertEquals($expectedList, $list);
71     }
72 }

At this point, we only need to TaskListTransformer so that the controller can pass it to the use case. If we run the test, it’ll fail because we haven’t defined the GetTaskListHandler class yet. We introduce an initial implementation.

1 class GetTaskListHandler
2 {
3     
4 }

Running the test again we see that now it’s asking for TaskListTransformer. First we move GetTaskListHandler to its location in App\TodoList\Application. Then we create TaskListTransformer.

1 class TaskListTransformer
2 {
3     
4 }

We check the result of the test again, which now tells us that we’re missing an execute method in GetTaskListHandler. Same as before, we first move the TaskListTransformer to its place.

In principle, I would put it in App\TodoList\Infrastructure\EntryPoint\Api, as the purpose of the transformer is to prepare a specific response for the API. But this would be correct for the concrete implementation that we end up using. If we do it this way we’d have a badly oriented dependency, as it would be pointing from Application towards Infrastructure. To invert it, we’ll have to put TaskListTransformer in the application layer as an interface. It’s place would be: App\TodoList\Application\TaskListTransformer.

Once relocated, we add the execute method to GetTaskListHandler.

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

Having added this, when we run the test we see that it fails because we’ve managed to trigger the exception that asks us to implement getTaskList in the controller:

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

And we can implement whatever the test needs to pass:

 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->taskListTransformer);
39 
40         return new JsonResponse($taskList, Response::HTTP_OK);
41     }
42 
43     private function obtainPayload(Request $request): array
44     {
45         return json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
46     }
47 }

We may observe that the controller has many dependencies. This can be solved with a command bus or by dividing the class in several smaller ones, but we’re not going to do it in this exercise to avoid losing focus.

In any case, the test passes, which indicates that it’s time to move back to the acceptance test cycle.

It will keep failing, as we might have expected:

1 PHP Exception RuntimeException: "Implement App\TodoList\Application\GetTaskListHandl\
2 er::execute" 

An error that tells us that the next step is to use a unit test to develop the GetTaskListHandler use case.

 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::class);
30         $taskListTransformer
31             ->expects(self::once())
32             ->method('transform')
33             ->with($taskList)
34             ->willReturn($expectedList);
35 
36         $getTaskListHandler = new GetTaskListHandler($tasksRepository);
37         $list = $getTaskListHandler->execute($taskListTransformer);
38 
39         self::assertEquals($expectedList, $list);
40     }
41 }

When we run this test, it asks us to add the findAll method to the repository.

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

We do this both in the interface and the concrete implementation:

 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 }

And the same for the transform method in TaskListTransformer:

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

Which will end up like this, once it’s been redefined as an interface:

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

With these changes, the test will now fail to tell us that we need to implement the execute method of the use case, which is just where we wanted to be:

1 RuntimeException : Implement App\TodoList\Application\GetTaskListHandler::execute

And here’s the implementation that makes the test pass.

 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): array
17     {
18         $tasks = $this->taskRepository->findAll();
19 
20         return $taskListTransformer->transform($tasks);
21     }
22 }

Now that we’ve gone back to green, we’ll return to the acceptance cycle. When we run the test, the result is a new error message, which asks us to implement findAll in FileTaskRepository.

1 RuntimeException: "Implement findAll() method."

This requires a unit 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::class);
17         $this->taskRepository = new FileTaskRepository($this->fileStorageEngine);
18     }
19 
20     /** @test */
21     public function shouldProvideNextIdentityCountingExistingObjects(): void
22     {
23         $this->fileStorageEngine
24             ->method('loadObjects')
25             ->willReturn(
26                 [],
27                 ['Task'],
28                 ['Task', 'Task']
29             );
30 
31         self::assertEquals(1, $this->taskRepository->nextIdentity());
32         self::assertEquals(2, $this->taskRepository->nextIdentity());
33         self::assertEquals(3, $this->taskRepository->nextIdentity());
34     }
35 
36     /** @test */
37     public function shouldStoreATask(): void
38     {
39         $task = new Task(1, 'Task Description');
40 
41         $this->fileStorageEngine
42             ->method('loadObjects')
43             ->willReturn([]);
44         $this->fileStorageEngine
45             ->expects(self::once())
46             ->method('persistObjects')
47             ->with([1 => $task]);
48 
49         $this->taskRepository->store($task);
50     }
51 
52     /** @test */
53     public function shouldGetStoredTasks(): void
54     {
55         $storedTasks = [
56             1 => new Task(1, 'Write a test that fails'),
57             2 => new Task(2, 'Write code to make the test pass'),
58         ];
59 
60         $this->fileStorageEngine
61             ->method('loadObjects')
62             ->willReturn(
63                 $storedTasks
64             );
65 
66         self::assertEquals($storedTasks, $this->taskRepository->findAll());
67     }
68 }

When we run it, it will ask:

1 RuntimeException : Implement findAll() method.

So we get to it:

 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 }

Now the unit test passes, with which we’ve implemented a good part of the repository. Will it be enough to make the acceptance test pass?

No, we still have work to do. At this moment, we’re asked to introduce a concrete implementation of TaskListTransformer.

Now we have to introduce a new unit test to develop the concrete Transformer, which we’ll locate in App\TodoList\Infrastructure\EntryPoint\Api, as it’s the controller the one that’s interested in using it. We’ll call it StringTaskListTransformer, as it converts a Task into a string representation.

This is going to pose a small design challenge. We don’t have any way to access the properties of Task yet, an entity that we also hadn’t had to develop further until now, and the truth is that we shouldn’t condition its implementation to this kind of necessity. In a more realistic and sophisticated system we could apply a Visitor pattern or something similar. In this case, what we’ll do is we’ll pass a template to Task, and Task will return it all filled out with its data.

Since Task is an entity I prefer not to mock it, so the test will end up this way:

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

And the production code could be this one:

 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. :description');
13         }
14 
15         return $transformed;
16     }
17 }

The test will throw an error to tell us that the representedAs method is not implemented in Task, so we can add it.

 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 }

Saving the distance, we can use the current test as an acceptance test. If we execute it, we’ll see that this exception is thrown:

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

Which would indicate the necessity to advance to the next level and create a unit test to develop Task, or at least its representedAs method. Another option would be to develop Task under the coverage of the current test, but this is not a very good idea, as the test could require examples that don’t actually provide anything useful to the matter at hand and are only relevant to 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. :description');
15         
16         self::assertEquals($expected, $representation);
17     }
18 }

For the time-being, this implementation would already suffice.

 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 }

So we could go up one level and go back to the previous Transformer test, which passes with no problem.

With this test in green, we return to the acceptance level, which also passes, indicating that we’ve finished the development of this user story.

Checking completed tasks

US-3

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

The third user story is easily built from the two previous ones, as our application already allows to introduce tasks and see the list. For this reason, before beginning the development, let’s refactor the acceptance test so it’s easier to extend. In fact, we can even reuse some of its parts. This is the results, already with the new acceptance test added.

  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('Write a test t\
 17 hat fails');
 18 
 19         $this->thenResponseShouldBeSuccessful($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 that 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->getStatusCode());
 58                 
 59         $response = $this->whenIRequestTheListOfTasks();
 60 
 61         $this->thenICanSeeAddedTasksInTheList(
 62             [
 63                 '[√] 1. Write a test that fails',
 64                 '[ ] 2. Write code to make the test pass'
 65             ],
 66             $response
 67         );
 68     }
 69 
 70     protected function setUp(): void
 71     {
 72         $this->resetRepositoryData();
 73 
 74         $this->client = self::createClient();
 75     }
 76 
 77     protected function tearDown(): void
 78     {
 79         $this->resetRepositoryData();
 80     }
 81 
 82     private function resetRepositoryData(): void
 83     {
 84         if (file_exists('repository.data')) {
 85             unlink('repository.data');
 86         }
 87     }
 88 
 89     private function whenWeRequestToCreateATaskWithDescription(string $taskDescripti\
 90 on): Response
 91     {
 92         return $this->apiCreateTaskWithDescription($taskDescription);
 93     }
 94 
 95     private function thenResponseShouldBeSuccessful(Response $response): void
 96     {
 97         self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
 98     }
 99 
100     private function thenTheTaskIsStored(): void
101     {
102         $storage = new FileStorageEngine('repository.data');
103         $tasks = $storage->loadObjects(Task::class);
104 
105         self::assertCount(1, $tasks);
106         self::assertEquals(1, $tasks[1]->id());
107     }
108 
109     private function apiCreateTaskWithDescription(string $taskDescription): Response
110     {
111         $this->client->request(
112             'POST',
113             '/api/todo',
114             [],
115             [],
116             ['CONTENT-TYPE' => 'json/application'],
117             json_encode(['task' => $taskDescription], JSON_THROW_ON_ERROR)
118         );
119 
120         return $this->client->getResponse();
121     }
122 
123     private function whenIRequestTheListOfTasks(): Response
124     {
125         $response = $this->apiGetTasksList();
126 
127         self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
128         return $response;
129     }
130 
131     private function apiGetTasksList(): Response
132     {
133         $this->client->request(
134             'GET',
135             '/api/todo'
136         );
137 
138         return $this->client->getResponse();
139     }
140 
141     private function givenIHaveAddedTasks(): void
142     {
143         $this->apiCreateTaskWithDescription('Write a test that fails');
144         $this->apiCreateTaskWithDescription('Write code to make the test pass');
145     }
146 
147     private function thenICanSeeAddedTasksInTheList(array $expectedTasks, Response $\
148 response): void
149     {
150         $taskList = json_decode($response->getContent(), true);
151 
152         self::assertEquals(
153             $expectedTasks, $taskList);
154     }
155 }

When we run the test, it fails -as expected- because it doesn’t find the route to the endpoint:

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

And, as we’ve done before, we’ll have to define it and create a controller that handles it. First, the route definition in routes.yaml.

 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']

A new execution of the test indicates that there’s a controller missing:

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

And we add an empty one:

 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->taskListTransformer);
39 
40         return new JsonResponse($taskList, Response::HTTP_OK);
41     }
42 
43     public function markTaskCompleted(int $taskId): Response
44     {
45         throw new \RuntimeException(sprintf('Implement %s', __METHOD__));
46     }
47 
48     private function obtainPayload(Request $request): array
49     {
50         return json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
51     }
52 }

The error now is:

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

And the test fails because it expects that endpoint to be properly running and responding, but it’s not implemented yet. Therefore, we move to the unit level to define the functionality of the controller.

As in the previous cases, implementing the functionality requires apart from the controller, a use case that utilizes the repository to recover and store back the task we wish to mark. Therefore, the key of the test will be to expect the use case to be executed with the right parameters.

So, the test will end up more or less like 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\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         $this->getTaskListHandler = $this->createMock(GetTaskListHandler::class);
 24         $this->taskListTransformer = $this->createMock(TaskListTransformer::class);
 25         $this->markTaskCompletedHandler = $this->createMock(MarkTaskCompletedHandler\
 26 ::class);
 27         $this->todoListController = new TodoListController(
 28             $this->addTaskHandler,
 29             $this->getTaskListHandler,
 30             $this->taskListTransformer,
 31             $this->markTaskCompletedHandler
 32         );
 33     }
 34 
 35 
 36     /** @test */
 37     public function shouldAddTask(): void
 38     {
 39         $this->addTaskHandler
 40             ->expects(self::once())
 41             ->method('execute')
 42             ->with(self::TASK_DESCRIPTION);
 43 
 44         $request = new Request(
 45             [],
 46             [],
 47             [],
 48             [],
 49             [],
 50             ['CONTENT-TYPE' => 'json/application'],
 51             json_encode(['task' => self::TASK_DESCRIPTION], JSON_THROW_ON_ERROR)
 52         );
 53 
 54         $response = $this->todoListController->addTask($request);
 55 
 56         self::assertEquals(201, $response->getStatusCode());
 57     }
 58 
 59     /** @test */
 60     public function shouldGetTaskList(): void
 61     {
 62         $expectedList = [
 63             '[ ] 1. Task Description',
 64             '[ ] 2. Task Description',
 65         ];
 66         $this->getTaskListHandler
 67             ->expects(self::once())
 68             ->method('execute')
 69             ->with($this->taskListTransformer)
 70             ->willReturn($expectedList);
 71 
 72         $response = $this->todoListController->getTaskList(new Request());
 73 
 74         self::assertEquals(200, $response->getStatusCode());
 75 
 76         $list = json_decode($response->getContent(), true);
 77 
 78         self::assertEquals($expectedList, $list);
 79     }
 80 
 81     /** @test */
 82     public function shouldMarkTaskCompleted(): void
 83     {
 84         $this->markTaskCompletedHandler
 85             ->expects(self::once())
 86             ->method('execute')
 87             ->with(self::COMPLETED_TASK_ID, true);
 88 
 89         $request = new Request(
 90             [],
 91             [],
 92             [],
 93             [],
 94             [],
 95             ['CONTENT-TYPE' => 'json/application'],
 96             json_encode(['completed' => true], JSON_THROW_ON_ERROR)
 97         );
 98 
 99         $response = $this->todoListController->markTaskCompleted(self::COMPLETED_TAS\
100 K_ID, $request);
101 
102         self::assertEquals(200, $response->getStatusCode());
103     }
104 }

Once we have the test, we run it. The result is that it asks us to create the MarkTaskCompletedHandler class.

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

We create it in the test itself, and then we move it to its location in App\TodoList\Application Next, it will ask us to create the execute method.

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

Which we’ll prepare this way:

 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 }

With this we’ll already have all that we need to implement the controller’s action, something we do because the following error says so:

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

This is the code that will pass the controller 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\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->taskListTransformer);
42 
43         return new JsonResponse($taskList, Response::HTTP_OK);
44     }
45 
46     public function markTaskCompleted(int $taskId, Request $request): Response
47     {
48         $payload = $this->obtainPayload($request);
49 
50         $this->markTaskCompletedHandler->execute($taskId, $payload['completed']);
51 
52         return new JsonResponse('', Response::HTTP_OK);
53     }
54 
55     private function obtainPayload(Request $request): array
56     {
57         return json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
58     }
59 }

Once the controller test passes, we’ll have to rerun the acceptance test. It will reveal the next step.

1 RuntimeException: "Implement App\TodoList\Application\MarkTaskCompletedHandler::exec\
2 ute"

It’s asking us to implement the use case. Therefore, we need a new unit test:

 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($taskRepository);
29 
30         $markTaskCompletedHandler->execute(self::COMPLETED_TASK_ID, true);
31     }
32 }

The execution of the test throws the following error:

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

Up until now we hadn’t needed this method in the repository, so we’ll have to add it to the interface.

 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 }

This will be enough to be able to continue executing the test, and reach the point where it asks us to implement the execute method of the use case.

1 RuntimeException : Implement App\TodoList\Application\MarkTaskCompletedHandler::exec\
2 ute

So we get to it. It’s pretty simple:

 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 }

When we execute the test again, it’ll fail. This is because we haven’t defined the Task::markCompleted method:

1 Error : Call to undefined method App\TodoList\Domain\Task::markCompleted()

Every time we get an error of this kind, we’ll have to dig deeper and enter a new unit test cycle. In this case, to implement this method in Task. We don’t have direct access to the complete property, which we haven’t even defined yet, but we can indirectly control its state thanks to its representation.

 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. :description');
15 
16         self::assertEquals($expected, $representation);
17     }
18 
19     /** @test */
20     public function shouldMarkTaskCompleted(): void
21     {
22         $expected = '[√] 1. Task Description';
23         $task = new Task(1, 'Task Description');
24         $task->markCompleted();
25         
26         $representation = $task->representedAs('[:check] :id. :description');
27 
28         self::assertEquals($expected, $representation);
29     }
30 }

The implementation is quite simple:

 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 }

With this, the Task test passes, and we can return to the use case level. When running the test again, we see that it also passes, so we can also go back to the acceptance test.

This test, however, will not pass, as it expects that we implement the retrieve method in FileTaskRepository, which we don’t have yet. We go to the 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::class);
17         $this->taskRepository = new FileTaskRepository($this->fileStorageEngine);
18     }
19 
20     /** @test */
21     public function shouldProvideNextIdentityCountingExistingObjects(): void
22     {
23         $this->fileStorageEngine
24             ->method('loadObjects')
25             ->willReturn(
26                 [],
27                 ['Task'],
28                 ['Task', 'Task']
29             );
30 
31         self::assertEquals(1, $this->taskRepository->nextIdentity());
32         self::assertEquals(2, $this->taskRepository->nextIdentity());
33         self::assertEquals(3, $this->taskRepository->nextIdentity());
34     }
35 
36     /** @test */
37     public function shouldStoreATask(): void
38     {
39         $task = new Task(1, 'Task Description');
40 
41         $this->fileStorageEngine
42             ->method('loadObjects')
43             ->willReturn([]);
44         $this->fileStorageEngine
45             ->expects(self::once())
46             ->method('persistObjects')
47             ->with([1 => $task]);
48 
49         $this->taskRepository->store($task);
50     }
51 
52     /** @test */
53     public function shouldGetStoredTasks(): void
54     {
55         $storedTasks = [
56             1 => new Task(1, 'Write a test that fails'),
57             2 => new Task(2, 'Write code to make the test pass'),
58         ];
59 
60         $this->fileStorageEngine
61             ->method('loadObjects')
62             ->willReturn(
63                 $storedTasks
64             );
65 
66         self::assertEquals($storedTasks, $this->taskRepository->findAll());
67     }
68 
69     /** @test */
70     public function shouldRetrieveATaskByItsId(): void
71     {
72         $expectedTask = new Task(1, 'Write a test that fails');
73         
74         $storedTasks = [
75             1 => $expectedTask,
76             2 => new Task(2, 'Write code to make the test pass'),
77         ];
78 
79         $this->fileStorageEngine
80             ->method('loadObjects')
81             ->willReturn(
82                 $storedTasks
83             );
84 
85         self::assertEquals($expectedTask, $this->taskRepository->retrieve(1));
86     }
87 }

As it we could have expected, the test will demand us to write the retrieve method.

 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 }

And with this, the FileTaskRepository test turns green. We take the opportunity to do a small refactoring, so that the dependency is controlled:

 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 }

And we launch the acceptance test one more time, which now passes cleanly.

Next steps

At this point, all three user stories have been implemented. What do we want to do now?

One of the improvements that we can do at the moment is fix the acceptance test so it can be used as a QA test. Now that we’ve developed all of the involved components, it’s possible to make the test more expressive and useful to describe the implemented behavior.

The unit tests may be used as they are. A common objection is that, as they’re based on mocks, they’re fragile due to their coupling to the implementation. However, we must remember that we’ve basically been designing each of the components that we needed, as well as the way in which we wanted them to interact. In other words: it’s not foreseeable that this implementation is going to change so much that it invalidates the tests. On the other hand, the unit tests that we’ve been using characterize the specific behavior of each unit. Altogether they’re fast and they provide us the necessary resolution to help us quickly diagnose any problem that might arise.

So, we’re going to retouch the acceptance test so that it has better business language:

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

Basically, we’ve rewritten the test using a Behavior Driven Development style. We didn’t need to make a Gherkin here, but we could have.

This has allowed us to get rid of the direct call to the storage engine that we had introduced at the beginning, and by doing it, we make the test more portable. Now it only uses endpoint calls, so it can work in different environments such as, for example, a local and a continuous integration one.