Mockist outside-in

Outside-in TDD, also known as mockist or London school, is a TDD approach that seeks to implement software features starting from an acceptance test and advancing towards the interior of the software.

Instead of designing the system in the refactoring phase, as the classic approach would, the outside-in approach does it during the red phase, that is, when the acceptance test is still failing. Development will end when the acceptance test finally passes. As the need to implement new components arises, they’re developed in a classic style.

Thus, for example, in the development of an API, first an acceptance test against the API would be written, as if it were another of its consumers. The next step would be to design and test the controller, then the use case, and then the services and entities handled by that use case, until reaching the application domain. In all cases we would mock the dependencies, so that we’d be testing the messages between the application’s objects.

The methodology to do this is based on two cycles:

  • Acceptance test cycle. It’s a test that described the complete feature at the end to end level, using real implementation of the system’s components, except for those that define its limits. At this level, the test failures serve as a guide to know what we have to develop next.
  • Unit test cycle. Once we have a failure in the acceptance test that tells us what to develop, we’ll take a step towards the inside of the system and use unit test to develop the corresponding component, mocking those collaborators or dependencies that it may need. When we’re finished, we return to the acceptance test cycle in order to find our next objective.
The mockist outside-in cycle

Development

This time we’ll develop the kata in PHP, using this repository since it comes with PHP and Symfony already installed, which provides us with an HTTP framework with which to start developing.

https://github.com/franiglesias/tb

We already have a basic test in the repository that we’ll use as a starting point:

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

Designing the acceptance test

We need an acceptance test that describes how the application has to work. We have an example for that. These are the tasks that we’re going to put in our list:

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

Therefore, the steps that the test has to execute are to annotate the three tasks, mark the first one as done, and be able to show us the list. These operations are:

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

For the sake of simplicity, the response will be a representation of each task in one line of text with the above format.

Starting at the end: what will the expected result be?

To start designing our test we begin at the end, that is, from the call to recover the task list that represents the result that we expect to achieve at the end of the process. From there, we will reproduce the previous steps that would have been needed to reach that state.

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

To reach this point, we would have needed to make one petition to the API for each of the tasks, and one more to mark a task as completed. Thereby, the complete test would look like this:

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

If we execute it we’ll start seeing errors about framework configuration problems. The first thing we have to do is get the test to fail for the right reason, which is none other than, when asking for the task list, receiving a $list response that’s not the one that we expect. Therefore, we’ll start by addressing these problems until we get the test to run.

Solving the necessary details in the framework

The first error tells us that there ins’t any controller in the location expected by the framework. In our case, on top of that, we want to build a solution with a clean architecture. According to that, the API controllers should be in the Infrastructure layer, so we’ll change the configuration of Symfony’s services.yaml so that it expects to find the controllers in another path. Specifically, I prefer to put them in:

src/Infrastructure/EntryPoint/Api/Controller

Therefore, services.yaml will look like this:

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

If we run the test again, we will see that the error message has change, which indicates a good intervention on our part. Now it tells us that there aren’t any controllers in the newly defined location, so we’ll create a TodoListController in the path: \App\Infrastructure\EntryPoint\Api\Controller\TodoListController.

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

And for now, we leave it like this. We run the test to see what it says. We have two kinds of messages. On the one hand, several exceptions that indicate us that the endpoint routes can’t be found, which we haven’t defined yet.

On the other hand, the test tells us that the call to the endpoint returns null and, therefore, we don’t have the task list yet.

So we need out controller to be able to handle these routes before anything else. The first route that it cannot find is POST /api/todo, which we would use to add new tasks to the list. To solve this, we will introduce an entry in the routes.yaml file.

1 api_add_task:
2   path: /api/todo
3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::addTa\
4 sk
5   methods: ['POST']

Once this route has been added, we run the acceptance test again. The appropriate thing is to run the test after each change †o confirm that if fails for the expected reason. In this case, we expect it to tell us that we don’t have an addTask method in TodoListController, and we have to add it in order to advance.

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

As you can see, in the method I throw an exception that will allow me to see when the real controller is being called. This way, I will be sure about whether it is what I have to implement next. I’ve gotten this technique from Sandro Mancuso in his Outside-in video, and I think it’s really useful. In some occasions, the compiler or interpreter could point this lack of implementation itself, but doing it explicitly will make things easier for us.

When re-running the test, the first error literally tells us that we have to implement addTask.

And this leads us to the unit test cycle.

First unit test

The first unit test introduces us a step further towards the interior of the application. The acceptance test executes the code from the outside of the application, while the controller is located in the Infrastructure layer. What we are going to do is develop the controller as a unit test, but instead of using the classic approach, which consists in implementing a solution and then using the refactoring stage to design the components, we’ll start by this latter point.

That is, what we want to do is to design which components we want the controller to use in order to return a response, mock them in the test, implementing only the controller’s own code.

In this example, I will assume that each controller invokes a use case in the application layer. So that it’s more easily understood, I won’t be using a command bus as I would in a real application, but I’ll invoke the use cases directly instead.

This is my first unit test:

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

On the one hand, in the test we simulate a request with a JSON payload, which will be the one that provides us with the necessary data. The AddTaskHandler mock simulates that we simply call its execute method, passing it -as a parameter- the description of the task provided in the endpoint call.

Thanks to the use of mocks we don’t need to worry about what’s happening further inside the application. What we are testing is the way in which the controller obtains the relevant data and passes them to the to the use case so it does whatever it must. If there isn’t any problem, the controller will return a 201 response, indicating that the resource has been created. We won’t deal with all of the possible errors that could occur in this example, but you can get an idea of how it could be handled.

Now we run the TodoListController test to ensure that it fails for the expected reasons: that AddTaskHandler is not called and that the HTTP 201 code is not returned.

In this case, the first error is that we don’t have an AddTaskHandler class which to mock, so we create it. We’ll put it in App\Application.

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

We run the test again, which will indicate us that there isn’t any execute method that can be mocked. We add it, but we let it throw an exception to tell us that it’s not implemented. We’ll see the usefulness of this in a while, because it’s not actually going to be executed in this test.

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

Instead, if everything has gone well, at this point the test will asks us to implement the controller’s addTask method, which is the step we were trying to reach.

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

This code makes the test pass. Given that it’s relatively simple, we won’t do it in very small steps in order to move with the explanation faster.

We’re going to take advantage of the fact that the test is green in order to refactor it a bit. We know that we’re going to have to add more tests in this TestCase and instantiate the controller several times, so we’re going to make our life easier for the near future. After making sure it’s still passing, the test looks like this:

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

It’s time to run the acceptance test again.

Back to the acceptance cycle

Now that the TodoListController test is passing, we no longer have any work to perform in this level, so we go back to the acceptance test to check whether anything is still failing, and what is it that fails.

At this point, what it tells us is that AddTaskHandler::execute is not implemented. Remember the exception we added earlier? Well, that tells us that we have to move one level deeper and get to the Application layer to develop the use case. Of course, with a unit test.

Like we said earlier, in outside-in we design during the red test phase and mock the components that the current unit can use as collaborators. We normally wouldn’t make entity doubles. In this case, what we expect of the use case is:

  • That it creates a new task, modeled as a domain entity Task.
  • That it makes it persist in a repository.

+ The task has to get an Id, which will be provided by the repository.

This indicates that the use case will have one dependency, the TaskRepository repository, and that we’ll start modeling the tasks with a Taskentity. This is the test.

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

We execute it, and it will tell us what to do.

The first thing will be to create TaskRepository so that we can mock it In this case, the repository is defined as interface in the domain layer, as we know already. So we start by doing that.

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

The next thing will be the Task entity, which is also in the domain.

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

For now I limit myself to creating to creating the basics, we’ll see what the development asks of us.

The next error indicates us that we don’t have a nextId method in TaskRepository, so we introduce it in the interface.

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

We’re also missing a store method. Same thing:

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

Last, when invoking the execute method it throws the well-known exception that it’s laking code, indicating that we’ve already prepared everything that we needed up until this point. So, let’s finally implement.

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

With this code the test passes. We don’t have anything else to do here, expect to see if there’s anything that we can refactor. In the test we see some details that can be improved to make everything easier to understand:

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

Let’s go back to the acceptance test and see what happens.

New visit to the acceptance test

When we run the acceptance test again it indicates us that, although we have an interface for TaskRepository, we haven’t defined any concrete implementation, so the test isn’t executed. It’s time to develop one.

Taking into account that we’re creating a REST API, we need that the tasks that we store persist between calls, so in principle, an in-memory repository won’t work for us. In our case we’ll use a vendor, which is located in the repository that we’re using as a base for this development. It’s the FileStorageEngine class. It simply saves the objects to a file, so that we simulate a real database whose persistence is sufficient to run the test.

 1 namespace App\Lib;
 2 
 3 
 4 class FileStorageEngine
 5 {
 6     private string $filePath;
 7 
 8     public function __construct($filePath)
 9     {
10         $this->filePath = $filePath;
11     }
12 
13     public function loadObjects(string $class): array
14     {
15         if (!file_exists($this->filePath)) {
16             return [];
17         }
18 
19         $file = fopen($this->filePath, 'rb');
20         $objects = unserialize(fgets($file), ['allowed_classes' => [$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 
33 }

So, let’s write unit tests to develop a task repository that uses FileStorageEngine.

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

Executing the test tells us that we don’t have a FileTaskRespository, so we start building it. When it fails, the test will tell us what we have to do. And this is the result:

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

Again, we have skipped some baby steps to reach the desired implementation. Once the test passes, we return to the acceptance test.

The test now tells us that we’re missing the implementation of the nextId method in FileTaskRepository. So we come back to the unit test.

In principle, what we’re going to do is simply return the number of saved tasks -plus one- as a new id. This won’t work properly in the event that we end up deleting tasks, but it will suffice for now. This is the test:

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

And this is the implementation:

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

It would be necessary to add a few more cases to verify it, but we leave it as is in order to move faster now.

Finishing the first user story

If we run the acceptance test now, we’ll see that the error that shows up says that we don’t have a route for the endpoint in which we mark a task as completed. This means that the first of our User Stories is finished: tasks can now be added to the list.

We’ve gone from the exterior of the application to the details of the implementation, and every step was already covered by tests. The truth is that we’ve been able to get a lot of work done, but there’s still a long way to go.

And the first step should sound familiar to us. We have to define the route to the endpoint, the controller, a new use case, and the interaction with the task repository. To routes.yaml we add the route:

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::addTa\
 4 sk
 5   methods: ['POST']
 6 
 7 api_mark_task_completed:
 8   path: /api/todo/{taskid}
 9   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::markT\
10 askCompleted
11   methods: ['PATCH']

We add a method to TodoListController:

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

When we add this code and execute the acceptance test, the error messages asks us to implement the new method. So we go to TodoListControllerTest and add the following test:

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

This test will fail because we haven’t defined MarkTaskCompletedHandler yet, so we will be running the test and solving the different errors until it fails for the right reasons and, after that, we’ll implement whatever it needs to pass.

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

Once we’ve added the basic code of the use case we can start implementing the controller, which will look like this:

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

And with this we make TodoListControllerTest pass. It’s time to run the acceptance test once again so that it tells us what we need to do now.

And basically, what it says is that we must implement MarkTaskCompletedHandler, which doesn’t have any code yet. For that purpose we will need a unit test.

The use case will depend on the repository to obtain and update the desired task. That will be what we mock.

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

As a somewhat striking detail, I should note that we’re going to mock an entity. This is necessary to be able to test that something that interests us happens: that we call its markCompleted method. This will force us to implement it. I would usually avoid mocking entities.

When we run the test it asks us for a retrieve method, which we don’t have in the repository yet.

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

As well as markCompleted in Task:

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

Finally, we have to implement the execute method of the use case, which will look like this:

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

And we’re done here for now.

We’ll run the acceptance test again. Let’s see what it tells us.

The first thing that it indicates us is that we don’t have the retrieve method in the FileTaskRepository repository. We have to implement it in order to continue. To do this, we’ll use the same FileTaskRepositoryTestCase that we had already started.

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

It will ask us to implement retrieve. This would be enough:

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

And it does suffice. Now that we’re in green, we can take the opportunity to fix the test up a little bit.

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

Once this has been done, we can run the acceptance test again and see how far we’ve come.

When we do this, the exception that we had left in Task::markCompleted is thrown. For now we’ll implement it without doing anything else. We’ll wait until other tests force us to do it, since we don’t actually have any way of verifying it without specifically creating a method to check its status in a test.

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

This allows the test to reach the next interesting point: we don’t have a route to recover the task list. In routes.yaml we add the definition:

 1 api_add_task:
 2   path: /api/todo
 3   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::addTa\
 4 sk
 5   methods: ['POST']
 6 
 7 api_mark_task_completed:
 8   path: /api/todo/{taskid}
 9   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::markT\
10 askCompleted
11   methods: ['PATCH']
12 
13 api_get_tasks_list:
14   path: /api/todo
15   controller: App\Infrastructure\EntryPoint\Api\Controller\TodoListController::getTa\
16 sksList
17   methods: ['GET']

We run the acceptance test to see that it’s no longer asking for the route, but rather for the implementation of a controller. And we add a skeleton to TodoListController.

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

So we have to go back to TodoListControllerTestCase to develop this method:

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

The test will fail since we need to implement GetTasksListHandler.

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

When we are able to run the whole test, we start implementing. This is our attempt:

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

The problem here is that we have to introduce a way of converting the list -as the GetTaskListHandler returns it- to the format that the endpoint consumer requires. It’s a representation of the task in the form of a text string.

There are several ways of solving this, and all of them require Task to give us some kind of usable representation:

  • The simplest one would be to perform the conversion in the controller itself, going through the list and generating their representations. In order to do that we would need a method that took care of it.
  • Another one would be to create a service that does the conversion. It would be a dependency of the controller.
  • And a third alternative would be to use that same service, but passing it to GetTaskListHandler as an strategy. This way, the controller decides how it wants to get the list, although it’s GetTaskListHandler the one that prepares it.

This last option will be the one that we use. But to do that we’ll need to modify tests. Not a lot, fortunately, only TodoListControllerTest actually needs changing.

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

And the controller will end up like this:

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

And the use case will be this one:

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

And, for now, our formatter implementation will be like this:

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

We’re back to green, and in this case, as we’ll see, it means that we’ve already finished with TodoListController. Let’s see what the acceptance test has to say.

The acceptance test asks us to implement the use case. So we’ll have to create a new unit test.

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

Running the test show us the necessity to implement a findAll method in the repository.
Once this is solved, we will have to implement the execute method of the use case:

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

This simple implementation takes us to green, and we can re-run the acceptance test. We’re very close to the end! But we have to add the findAll method to the specific repository. First the test:

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

Test that is swiftly solved with:

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

And we run the acceptance test once again to see where to go next. This time the test tells us that we have to implement the TaskListFormatter::format method. We are really two steps away, but we have to create a unit test.

At this point we could propose different designs that avoid dealing with presentation issues in a domain entity, but for simplicity we’ll make Task able to provide its text representation by adding an asString method.

It’s worth wondering if it would be appropriate to use a double of Task here -something that we already did in another test- and wait until the acceptance test ask us to develop Task, or if it would be preferable to just use the entity as is, and that the test forced us to introduce the necessary methods.

In practice, having reached this point, I think that it all comes down to the complexity that this may entail. In this exercise, the behavior of Task is pretty trivial, so we could just move forward with the entity without further complications. But if the behavior is complex, it might be better to slow down, work with the mock, and spend the necessary time afterwards.

So here we’ll use mocks for that as well.

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

We run the test to see it fail because we don’t have the asString method in Task. So we introduce it. Notice that we haven’t implemented markCompleted yet.

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

When re-running the test it soon complains about the format method not being implemented, so we get down to it:

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

And we’re already in green. Time go back to the acceptance test loop.

Last steps

The acceptance test, as we might have expected, fails because Task::asString is not implemented. We had also left Task:markCompleted unimplemented doing nothing. It could be a good idea to let it complain again, and that way making sure that it’s being called and not forgetting to handle it as well.

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

And when running the acceptance test again, we see that it complains exactly about that, and that this is where we want to be now.

We have to move forward with the development of Task, using a unit test. As we don’t want to add methods, for now, we will verify the state of done through asString.

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

This test passes. So we have to go back to the acceptance test.

Now the test message has changed. It’s asking us to implement markCompleted in Task, but the test itself is failing because the responses don’t match. It expects this:

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

and it obtains this:

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

By now, the reason is obvious. There is nothing that’s implemented in Task that takes care of keeping the done state.

Let’s add one more case to the test:

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

Now we implement it:

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

With the test in green, we run the acceptance test again, and… Yes! The test passes without any more problems: we have finished the development of our application.

What have we learned in this kata

  • The mockist outside-in modality seems to contravene TDD rules. In spite of that, the whole process has been guided by what tests indicate.
  • The acceptance test will fail as long as everything that’s necessary to run the application has not been implemented.
  • We always move between the acceptance test loop, and each of the acceptance test that we’ll have to use to develop the components.
  • Once the acceptance test passes the feature is complete, at least in the terms in which we have defined the test.
  • In unit tests we use mocks to define the public interface of each component according to the needs of its consumers, which helps us to maintain the principle of interface segregation.