Classic outside-in TDD
It’s possible to follow an outside-in methodology while we keep the classic TDD cycle. As you might already know, in this approach the design is applied during the refactoring phase, so once that we’ve developed a rough version of the desired functionality, we start identifying responsibilities and extracting them to different objects with which to compose the system.
In the classic style kata that we’ve presented in the second part of the book we haven’t reached this stage of extraction to collaborators, although we have suggested it several times, and it would be a perfectly feasible thing to do. In fact, it’s a recommended exercise.
However, when we talk about outside-in, it’s frequent that we rather think about more complex projects than the simple problems proposed in the kata. That is to say, the development of a real-world software product as seen by its consumers.
Our to-do list application backend example would fit this category. In the previous chapter we’ve developed the project using the mockist approach, whose main feature is that we start from an acceptance test and we then enter each application component, which which develop with the help of a unit test, mocking the innermost components that we’ve not yet developed.
In classic TDD, it’s usual to make an up-front design to get a rough idea about the necessary components, each of which is then developed and integrated later.
But classic outside-in is a little bit different. We would also start with a test at the acceptance level and with the goal of writing the login that makes it pass. In the refactoring phases, we would start extracting objects capable of handling the various identified responsibilities.
For this example we will write a new version of our to-do list application, this time in Ruby. The HTTP framework will be Sinatra, and the testing framework RSpec.
Posing the problem
Our starting point will also be an acceptance test as consumers of the API. In a way, we could consider the system as one big object with which we communicate via requests to its endpoints.
It being classic TDD, we won’t be using mocks unless we need to define an architecture boundary. Obviously, in order to define these kinds of things, we need to have some minimum amount of up-front design, so we expect that at some point we’ll have use cases, domain entities, and repositories.
In our example, the architecture boundary will be the repository. As we won’t define the specific persistence technology yet, we will mock it when the time comes. Then we’ll see how we can develop an implementation.
Kicking off development
My first test proposal is the following:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7
8 RSpec.describe 'As a user I want to' do
9
10 it "add a new task to the list" do
11 TodoListApp.new
12 end
13 end
This test tries to instantiate a TodoListApp object, which is the class in which we will define the sinatra application that will respond in the first instance. It requires installing rspec if we don’t already have it, and it will fail with this error:
1 NameError:
2 uninitialized constant TodoListApp
3 # ./spec/todo_list_acceptance_spec.rb:10:in `block (2 levels) in <top (required\
4 )>'
Which tells us that the class isn’t defined anywhere. To make it pass, I will introduce the class in the same file as the test, and when I manage to turn it green, I’ll move it to its proper location.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7
8 class TodoListApp
9
10 end
11
12 RSpec.describe 'As a user I want to' do
13
14 it "add a new task to the list" do
15 TodoListApp.new
16 end
17 end
This is enough to pass the test, so I will make the most obvious refactoring, which is to move TodoListApp to a more adequate place in the project.
The refactoring phase is the stage in which we make design decisions within the classic approach. The controllers belong to the infrastructure layer, so it will be there where I place this class. With that, the test looks like this:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7
8 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
9
10 RSpec.describe 'As a user I want to' do
11
12 it "add a new task to the list" do
13 TodoListApp.new
14 end
15 end
And we verify that it still passes.
For the next point I need to take a bit of a longer leap and prepare the client that will execute the requests against the endpoints. Using rack-test, I can create an API client. Since I’m green, I will introduce it and start it. We’ll have to install rack-test first.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 RSpec.describe 'As a user I want to' do
12
13 it "add a new task to the list" do
14 todo_application = TodoListApp.new
15
16 @client = Rack::Test::Session.new(
17 Rack::MockSession.new(
18 todo_application
19 )
20 )
21
22 end
23 end
This refactoring doesn’t change the test result, so we’re doing pretty fine.
Now we’re going to make sure that we can make a POST /api/todo call, and that someone answers.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 RSpec.describe 'As a user I want to' do
12
13 it "add a new task to the list" do
14 todo_application = TodoListApp.new
15
16 @client = Rack::Test::Session.new(
17 Rack::MockSession.new(
18 todo_application
19 )
20 )
21
22 @client.post '/api/todo'
23
24 end
25 end
Now the test fails because the application is not able to route the call to any method. It’s time to work on the implementation in TodoListApp until we manage to make the test pass. This will require introducing and installing sinatra.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6
7 end
The truth is that this is enough to pass the test, since we don’t have any expectation about the answer. We need a bit more resolution to force us to implement an action associated to the endpoint, so we change the test to be more precise and explicit.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 RSpec.describe 'As a user I want to' do
12
13 it "add a new task to the list" do
14 todo_application = TodoListApp.new
15
16 @client = Rack::Test::Session.new(
17 Rack::MockSession.new(
18 todo_application
19 )
20 )
21
22 @client.post '/api/todo'
23
24 expect(@client.last_response.status).to eq(201)
25 end
26 end
And this test, which is already a real test, shows us that the desired route isn’t found:
1 1) As a user I want to add a new task to the list
2 Failure/Error: expect(@client.last_response.status).to eq(201)
3
4 expected: 201
5 got: 404
With which we can already implement an action that responds.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6 post '/api/todo' do
7 [201]
8 end
9 end
Now we’ve made the test pass, returning a fixed response, and we now have the assurance that our application is answering to the endpoint. It would be time to introduce the call with its payload, which will be the description of the new task.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 RSpec.describe 'As a user I want to' do
12
13 it "add a new task to the list" do
14 todo_application = TodoListApp.new
15
16 @client = Rack::Test::Session.new(
17 Rack::MockSession.new(
18 todo_application
19 )
20 )
21
22 @client.post '/api/todo',
23 {task: 'Write a test that fails'}.to_json,
24 { 'CONTENT_TYPE' => 'application/json' }
25
26 expect(@client.last_response.status).to eq(201)
27 end
28 end
The test doesn’t add any new information. If we want to move forward with the development we’ll have to introduce another test that questions the current implementation, forcing us to make a change in the direction of achieving whatever the test is expected to do.
This endpoint is use to create tasks and save them to the list, which means that an effect (side effect) is produced in the system. It’s a command and doesn’t offer any response. In order to test it, we have to check the effect by verifying that there’s a created task somewhere.
One possibility is to assume that the task will persist in a TaskRespository, which would be a TodoListApp collaborator. Repositories are objects in the architecture boundaries and they are based on a specific technology. This assumes a certain level of prior design, but I think that it’s an acceptable compromise within the classic approach.
This implies modifying the way in which TodoListApp is instantiated so we can pass collaborators to it. Therefore, before anything else, we’re going to refactor the test so that the creation of new examples is easier and the test becomes more expressive.
It would end up looking like this:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 def todo_application
12 TodoListApp.new
13 end
14
15 def build_client
16 Rack::Test::Session.new(
17 Rack::MockSession.new(
18 todo_application
19 )
20 )
21 end
22
23 RSpec.describe 'As a user I want to' do
24
25 before do
26 @client = build_client
27 end
28
29 it "add a new task to the list" do
30 @client.post '/api/todo',
31 {task: 'Write a test that fails'}.to_json,
32 { 'CONTENT_TYPE' => 'application/json' }
33
34 expect(@client.last_response.status).to eq(201)
35 end
36 end
After this redesign the test keeps passing. Now, we have to introduce a double of the repository. The minimum that we need to force ourselves to create something is:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 def todo_application
12 double(TaskRepository)
13
14 TodoListApp.new
15 end
16
17 def build_client
18 Rack::Test::Session.new(
19 Rack::MockSession.new(
20 todo_application
21 )
22 )
23 end
24
25 RSpec.describe 'As a user I want to' do
26
27 before do
28 @client = build_client
29 end
30
31 it "add a new task to the list" do
32 @client.post '/api/todo',
33 {task: 'Write a test that fails'}.to_json,
34 { 'CONTENT_TYPE' => 'application/json' }
35
36 expect(@client.last_response.status).to eq(201)
37 end
38 end
With which we’d have to introduce the definition of the class. By now, we’ll do it in the same file.
1 # ...
2
3 class TaskRepository
4
5 end
6
7 def todo_application
8 double(TaskRepository)
9
10 TodoListApp.new
11 end
12
13 # ...
And we pass it to TodoListApp as a construction parameter.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10
11 class TaskRepository
12
13 end
14
15 def todo_application
16 @task_repository = double(TaskRepository)
17
18 TodoListApp.new @task_repository
19 end
20
21 def build_client
22 Rack::Test::Session.new(
23 Rack::MockSession.new(
24 todo_application
25 )
26 )
27 end
28
29 RSpec.describe 'As a user I want to' do
30
31 before do
32 @client = build_client
33 end
34
35 it "add a new task to the list" do
36 @client.post '/api/todo',
37 {task: 'Write a test that fails'}.to_json,
38 { 'CONTENT_TYPE' => 'application/json' }
39
40 expect(@client.last_response.status).to eq(201)
41 end
42 end
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6 def initialize(task_repository)
7 @task_repository = task_repository
8 end
9
10 post '/api/todo' do
11 [201]
12 end
13 end
In principle, these changes don’t affect the test result. So, let’s move TaskRepository to where it belongs, the domain layer.
Then, we need to define the effect that we expect to obtain, which we do by setting an expectation about the message that we’re going to send to the task repository.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10 require_relative '../src/domain/task_repository'
11
12 def todo_application
13 @task_repository = double(TaskRepository)
14
15 TodoListApp.new @task_repository
16 end
17
18 def build_client
19 Rack::Test::Session.new(
20 Rack::MockSession.new(
21 todo_application
22 )
23 )
24 end
25
26 RSpec.describe 'As a user I want to' do
27
28 before do
29 @client = build_client
30 end
31
32 it "add a new task to the list" do
33
34 expect(@task_repository)
35 .to receive(:store)
36 .with(instance_of(Task))
37
38 @client.post '/api/todo',
39 {task: 'Write a test that fails'}.to_json,
40 { 'CONTENT_TYPE' => 'application/json' }
41
42 expect(@client.last_response.status).to eq(201)
43 end
44 end
45 end
The test initially fails because we have introduced Task, so we add it now to its place in the domain layer: we’ll need it soon. By doing so, we get the test to fail for the right reason.
1 1) As a user I want to add a new task to the list
2 Failure/Error:
3 expect(@task_repository)
4 .to receive(:store)
5 .with(instance_of(Task))
6
7 (Double TaskRepository).store(an_instance_of(Task))
8 expected: 1 time with arguments: (an_instance_of(Task))
9 received: 0 times
By adding this code to TodoListApp we get the test to pass.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6 def initialize(task_repository)
7 @task_repository = task_repository
8 end
9
10 post '/api/todo' do
11 task = Task.new
12 @task_repository.store(task)
13 [201]
14 end
15 end
Now we need a new test to ask us to implement the instantiation of a Task with the desired values. That is, we want Task to be initialized with the id 1 and our specified description. In order for the test to work, we have to implement a initialization in Task, which we don’t have yet, and some way to compare Task objects.
On the other hand, we have to implement a way of initializing Task. This creation may be covered by the acceptance test itself. Another way to do it would be to develop Task with a unit test, but to be honest I don’t think it’s necessary at the time.
When we insert this in the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10 require_relative '../src/domain/task_repository'
11 require_relative '../src/domain/task'
12
13 def todo_application
14 @task_repository = double(TaskRepository)
15
16 TodoListApp.new @task_repository
17 end
18
19 def build_client
20 Rack::Test::Session.new(
21 Rack::MockSession.new(
22 todo_application
23 )
24 )
25 end
26
27 RSpec.describe 'As a user I want to' do
28
29 before do
30 @client = build_client
31 end
32
33 it "add a new task to the list" do
34
35 task = Task.new 1, 'Write a test that fails'
36
37 expect(@task_repository)
38 .to receive(:store)
39 .with(instance_of(Task))
40
41 @client.post '/api/todo',
42 { task: 'Write a test that fails' }.to_json,
43 { 'CONTENT_TYPE' => 'application/json' }
44
45 expect(@client.last_response.status).to eq(201)
46 end
47 end
It will start to fail, so we have to implement the initialization.
1 class Task
2 def initialize(id, description)
3
4 @id = id
5 @description = description
6 end
7 end
Now the test fails because we weren’t initializing Task properly in TodoListApp, as we weren’t passing it any arguments. With this small change, the test starts passing.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 task = Task.new 1, 'Write a test that fails'
14 @task_repository.store(task)
15 [201]
16 end
17 end
We could say that we’re using constants here in order to satisfy the test, so we have to evolve the code and obtain a more flexible implementation. I’ll start with a small refactor that reveals what we have to achieve next.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 task_id = 1
14 task_description = 'Write a test that fails'
15 task = Task.new task_id, task_description
16 @task_repository.store(task)
17 [201]
18 end
19 end
It’s that simple, we have to obtain values for the variables that we’ve just introduced. But right now we aren’t checking. It’s time to introduce a matcher.
1 RSpec::Matchers.define :has_same_data do |expected|
2 match do |actual|
3 expected.id == actual.id && expected.description == actual.description
4 end
5 end
To use it, we’ll change the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8 require 'json'
9
10 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
11 require_relative '../src/domain/task_repository'
12 require_relative '../src/domain/task'
13
14 def todo_application
15 @task_repository = double(TaskRepository)
16
17 TodoListApp.new @task_repository
18 end
19
20 def build_client
21 Rack::Test::Session.new(
22 Rack::MockSession.new(
23 todo_application
24 )
25 )
26 end
27
28 RSpec::Matchers.define :has_same_data do |expected|
29 match do |actual|
30 expected.id == actual.id && expected.description == actual.description
31 end
32 end
33
34
35 RSpec.describe 'As a user I want to' do
36
37 before do
38 @client = build_client
39 end
40
41 it "add a new task to the list" do
42
43 task = Task.new 1, 'Write a test that fails'
44
45 expect(@task_repository)
46 .to receive(:store)
47 .with(has_same_data(task))
48
49 @client.post '/api/todo',
50 { task: 'Write a test that fails' }.to_json,
51 { 'CONTENT_TYPE' => 'application/json' }
52
53 expect(@client.last_response.status).to eq(201)
54 end
55 end
At this moment the test won’t pass because Task doesn’t expose any methods that allow us to access its attributes, so we’ll add attr_reader:
1 class Task
2 attr_reader :description, :id
3 def initialize(id, description)
4
5 @id = id
6 @description = description
7 end
8 end
And with this, the test passes.
task_description comes in the request payload. Since it’s already defined in the test, we could simply use it right now.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 payload = JSON.parse request.body.read.to_s
14 task_description = payload['task']
15
16 task_id = 1
17 task = Task.new task_id, task_description
18 @task_repository.store(task)
19 [201]
20 end
21 end
As for the task id, we’ll need an identity generator. In our design, we have placed this responsibility on TaskRepository, which would have a next_id method. In this case, we’ll have to specify it in the test by using a stub.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8
9 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
10 require_relative '../src/domain/task_repository'
11 require_relative '../src/domain/task'
12
13 def todo_application
14 @task_repository = double(TaskRepository)
15
16 TodoListApp.new @task_repository
17 end
18
19 def build_client
20 Rack::Test::Session.new(
21 Rack::MockSession.new(
22 todo_application
23 )
24 )
25 end
26
27 RSpec.describe 'As a user I want to' do
28
29 before do
30 @client = build_client
31 end
32
33 it "add a new task to the list" do
34
35 task = Task.new 1, 'Write a test that fails'
36
37 allow(@task_repository)
38 .to receive(:next_id)
39 .and_return(1)
40
41 expect(@task_repository)
42 .to receive(:store)
43 .with(instance_of(Task))
44
45 @client.post '/api/todo',
46 { task: 'Write a test that fails' }.to_json,
47 { 'CONTENT_TYPE' => 'application/json' }
48
49 expect(@client.last_response.status).to eq(201)
50 end
51 end
With the production code just as it is now, the test passes, so it doesn’t tell us what we would have to do next. So, I’m going to cheat a little and force a test failure:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 payload = JSON.parse request.body.read.to_s
14 task_description = payload['task']
15
16 task_id = 0
17 task = Task.new task_id, task_description
18 @task_repository.store(task)
19 [201]
20 end
21 end
Now, introducing the call to next_id finally makes sense:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 payload = JSON.parse request.body.read.to_s
14 task_description = payload['task']
15
16 task_id = @task_repository.next_id
17
18 task = Task.new task_id, task_description
19 @task_repository.store(task)
20 [201]
21 end
22 end
Extraction of the use case
Now the test is passing and we could say that the endpoint implementation is complete. However, we face many problems:
-
TaskRepositoryis a mock. We know which interface it should have, but we don’t have any concrete implementation that can work in production. - There’s a lot of business logic in the controller that shouldn’t be there.
- In fact, we have domain objects in the controller:
TaskandTaskRepository.
Summarizing, right now, the controller is doing more things that it should. On top of its job as a controller, which is handling the requests that arrive from the outside, it’s performing tasks that belong to the application layer, coordinating domain objects.
Therefore, we would have to extract this part of the implementation to a new object, which will be the use case AddTaskHandler.
The first thing that I do is extract the functionality to a private method.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6
7 class TodoListApp < Sinatra::Base
8 def initialize(task_repository)
9 @task_repository = task_repository
10 end
11
12 post '/api/todo' do
13 payload = JSON.parse request.body.read.to_s
14 task_description = payload['task']
15
16 add_task(task_description)
17 [201]
18 end
19
20 private
21
22 def add_task(task_description)
23 task_id = @task_repository.next_id
24 task = Task.new task_id, task_description
25 @task_repository.store(task)
26 end
27 end
I will create an AddTaskHandler class in the application layer that encapsulates the same functionality:
1 class AddTaskHandler
2 def initialize(task_repository)
3
4 @task_repository = task_repository
5 end
6
7 def execute(task_description)
8 task_id = @task_repository.next_id
9 task = Task.new task_id, task_description
10 @task_repository.store(task)
11 end
12 end
And I replace the method implementation for a call:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(task_repository)
10 @task_repository = task_repository
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15 task_description = payload['task']
16
17 add_task(task_description)
18 [201]
19 end
20
21 private
22
23 def add_task(task_description)
24 @add_task_handler = AddTaskHandler.new @task_repository
25 @add_task_handler.execute task_description
26 end
27 end
I make a method inline:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(task_repository)
10 @task_repository = task_repository
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15 task_description = payload['task']
16
17 @add_task_handler = AddTaskHandler.new @task_repository
18 @add_task_handler.execute task_description
19 [201]
20 end
21 end
And I refactor the solution a bit, moving the initialization to the constructor and removing some temporal variables:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(task_repository)
10 @task_repository = task_repository
11 @add_task_handler = AddTaskHandler.new @task_repository
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21 end
The next step is to inject the AddTaskHandler dependency in place of the repository one. To do that, I first change the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 ENV['APP_ENV'] = 'test'
5
6 require 'rspec'
7 require 'rack/test'
8 require 'json'
9
10 require_relative '../src/infrastructure/entry_point/todo_list_app.rb'
11 require_relative '../src/domain/task_repository'
12 require_relative '../src/domain/task'
13
14 def todo_application
15 @task_repository = double(TaskRepository)
16 @add_task_handler = AddTaskHandler.new @task_repository
17 TodoListApp.new @add_task_handler
18 end
19
20 def build_client
21 Rack::Test::Session.new(
22 Rack::MockSession.new(
23 todo_application
24 )
25 )
26 end
27
28 RSpec::Matchers.define :has_same_data do |expected|
29 match do |actual|
30 expected.id == actual.id && expected.description == actual.description
31 end
32 end
33
34
35 RSpec.describe 'As a user I want to' do
36
37 before do
38 @client = build_client
39 end
40
41 it "add a new task to the list" do
42
43 task = Task.new 1, 'Write a test that fails'
44
45 allow(@task_repository)
46 .to receive(:next_id)
47 .and_return(1)
48
49 expect(@task_repository)
50 .to receive(:store)
51 .with(has_same_data(task))
52
53 @client.post '/api/todo',
54 { task: 'Write a test that fails' }.to_json,
55 { 'CONTENT_TYPE' => 'application/json' }
56
57 expect(@client.last_response.status).to eq(201)
58 end
59 end
This will cause the test to fail because the production code is still expecting the repository as a dependency, so we change it in the following way:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler)
10 @add_task_handler = add_task_handler
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15
16 @add_task_handler.execute payload['task']
17
18 [201]
19 end
20 end
And we may now consider this part solved.
Implementing a repository
To kick off the design we have started with a mock TaskRepository. We have introduced an empty class to be able to double it, but this real version can’t even receive messages. This is a liberty I’ve taken in order to avoid having to start developing from the inside, creating domain layer components -like this repository- before knowing how they were going to be used.
The repository is one of those objects that live in the architecture boundary, so to speak, so using a double is acceptable enough. However, now we’re going to try to implement a version that can be used for testing.
This poses a little problem if we consider TaskRepository to be a domain object, so we don’t want to have concrete implementations of this layer. A simple way to do it is by using composition: we would have a TaskRepository class in domain that would simply delegate to the concrete implementation that we injected. This is the approach that we’re going to adopt in this case, implementing the versions of the repository that may be needed, starting from a unit test extracting the implementations from a generic one.
This time, we start by the repositories’ ability to attend a next_id message, which should be one when the repository is empty.
1 require 'rspec'
2
3 describe 'TaskRepository' do
4 it 'should provide empty collection of tasks' do
5 task_repository = TaskRepository.new
6
7 result = task_repository.next_id
8
9 expect(result).to eq(1)
10 end
11 end
This method doesn’t exist yet and the test will fail. We implement a first version.
1 class TaskRepository
2 def next_id
3 1
4 end
5 end
With a green test, we’re going to perform a refactoring. next_id should provide us with a number: the result of adding one to the amount of stored tasks. So we’re going to represent this using code first.
1 class TaskRepository
2 def initialize
3 @tasks = {}
4 end
5 def next_id
6 @tasks.count + 1
7 end
8 end
It would be nice to be able to add elements and check if things are really working, so we’re going to allow the repository to be initialized with some contents.
1 class TaskRepository
2 def initialize(*tasks)
3 tasks.empty? ? @tasks = {} : (@tasks = Hash[tasks.collect { |task| [task.id, tas\
4 k] }] unless tasks.empty?)
5 end
6
7 def next_id
8 @tasks.count + 1
9 end
10 end
With this, we can test that if we initialize the repository with some element, it returns the correct identifier. For example, like this:
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5
6 describe 'TaskRepository' do
7 it 'first identity should be 1' do
8 task_repository = TaskRepository.new
9
10 result = task_repository.next_id
11
12 expect(result).to eq(1)
13 end
14
15 it 'should have next_id = n+1 if contains n tasks' do
16 task = Task.new 1, 'Description'
17
18 task_repository = TaskRepository.new task
19
20 expect(task_repository.next_id).to eq(2)
21 end
22 end
This should be enough for us to trust next_id. You might be thinking that generating identities using this algorithm isn’t precisely robust, but it’s sufficient and satisfies our example for now. In any case, we could implement any other strategy.
Now we could use next_id as an indirect way of knowing if we’ve added tasks to the repository, so we can already test the store method.
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5
6 describe 'TaskRepository' do
7 it 'first identity should be 1' do
8 task_repository = TaskRepository.new
9
10 result = task_repository.next_id
11
12 expect(result).to eq(1)
13 end
14
15 it 'should have next_id = n+1 if contains n tasks' do
16 task = Task.new 1, 'Description'
17
18 task_repository = TaskRepository.new task
19
20 expect(task_repository.next_id).to eq(2)
21 end
22
23
24 it 'should add a Task' do
25 task_repository = TaskRepository.new
26
27 task = Task.new 1, 'Task Description'
28
29 task_repository.store task
30
31 expect(task_repository.next_id).to eq(2)
32 end
33 end
For now, the test fails because we don’t have a method that handles the store message, so we add it and implement the simplest solution:
1 class TaskRepository
2 def initialize(*tasks)
3 tasks.empty? ? @tasks = {} : (@tasks = Hash[tasks.collect { |task| [task.id, tas\
4 k] }] unless tasks.empty?)
5 end
6
7 def next_id
8 @tasks.count + 1
9 end
10
11 def store(task)
12 @tasks.store task.id, task
13 end
14 end
Which, moreover, is enough to get the test to pass. The last test overlaps the previous next_id test, so we’re going to remove it.
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5
6 describe 'TaskRepository' do
7 it 'should add a Task' do
8 task_repository = TaskRepository.new
9
10 task = Task.new 1, 'Task Description'
11
12 task_repository.store task
13
14 expect(task_repository.next_id).to eq(2)
15 end
16 end
And we can also remove the initialization, since we don’t really need it.
1 class TaskRepository
2 def initialize
3 @tasks = {}
4 end
5
6 def next_id
7 @tasks.count + 1
8 end
9
10 def store(task)
11 @tasks.store task.id, task
12 end
13 end
We could make sure that we’re able to introduce more tasks:
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5
6 describe 'TaskRepository' do
7 it 'first identity should be 1' do
8 task_repository = TaskRepository.new
9
10 result = task_repository.next_id
11
12 expect(result).to eq(1)
13 end
14
15 it 'should add a Task' do
16 task_repository = TaskRepository.new
17
18 task = Task.new 1, 'Task Description'
19
20 task_repository.store task
21
22 expect(task_repository.next_id).to eq(2)
23 end
24
25 it 'should add several tasks' do
26 task_repository = TaskRepository.new
27
28 @task_repository.store Task.new(1, 'Task Description')
29 @task_repository.store Task.new(2, 'Another Task')
30 @task_repository.store Task.new(3, 'Third Task')
31
32 expect(task_repository.next_id).to eq(4)
33 end
34 end
Since we want to separate the specific persistence technology, I will use these tests to extract an in-memory repository. It ends up looking like this:
1 require_relative '../infrastructure/persistence/memory_storage'
2
3 class TaskRepository
4 def initialize
5 @storage = MemoryStorage.new
6 end
7
8 def next_id
9 @storage.next_id
10 end
11
12 def store(task)
13 @storage.store task
14 end
15 end
1 class MemoryStorage
2 def initialize
3 @objects = {}
4 end
5
6 def next_id
7 @objects.count + 1
8 end
9
10 def store(object)
11 @objects.store object.id, object
12 end
13 end
Now we can inject it, to do it we modify the test first:
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5
6 describe 'TaskRepository' do
7 before() do
8 @task_repository = TaskRepository.new
9 end
10
11 it 'first identity should be 1' do
12
13 result = @task_repository.next_id
14
15 expect(result).to eq(1)
16 end
17
18 it 'should add a Task' do
19 task = Task.new 1, 'Task Description'
20
21 @task_repository.store task
22
23 expect(@task_repository.next_id).to eq(2)
24 end
25
26 it 'should add several tasks' do
27
28 @task_repository.store Task.new(@task_repository.next_id, 'Task Description')
29 @task_repository.store Task.new(@task_repository.next_id, 'Another Task')
30 @task_repository.store Task.new(@task_repository.next_id, 'Third Task')
31
32 expect(@task_repository.next_id).to eq(4)
33 end
34 end
And now that we only have one place to initialize the repository…
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5 require_relative '../../src/infrastructure/persistence/memory_storage'
6
7 describe 'TaskRepository' do
8 before() do
9 memory_storage = MemoryStorage.new
10 @task_repository = TaskRepository.new memory_storage
11 end
12
13 # ...
14 end
The test will fail, but now we only need to make this change:
1 class TaskRepository
2 def initialize(storage)
3 @storage = storage
4 end
5
6 def next_id
7 @storage.next_id
8 end
9
10 def store(task)
11 @storage.store task
12 end
13 end
With which we have a TaskRepository that we will be able to configure so it uses different persistence technologies, and that we could start using in our acceptance test.
A possible change is this one, although we’ll continue to evolve it later:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 RSpec.describe 'As a user I want to' do
7
8 before do
9 @client = build_client
10 end
11
12 it "add a new task to the list" do
13
14 @client.post '/api/todo',
15 { task: 'Write a test that fails' }.to_json,
16 { 'CONTENT_TYPE' => 'application/json' }
17
18 expect(@client.last_response.status).to eq(201)
19 expect(@task_repository.next_id).to eq(2)
20 end
21 end
Obtaining the to-do list
Once we’re able to add tasks, it would be interesting to also be able to access them. Our next acceptance test would describe this action, introducing one or more tasks and obtaining a list with all that we have.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 RSpec.describe 'As a user I want to' do
7
8 before do
9 @client = build_client
10 end
11
12 it "add a new task to the list" do
13
14 @client.post '/api/todo',
15 { task: 'Write a test that fails' }.to_json,
16 { 'CONTENT_TYPE' => 'application/json' }
17
18 expect(@client.last_response.status).to eq(201)
19 expect(@task_repository.next_id).to eq(2)
20 end
21
22 it 'get a list with all the tasks I\'ve introduced' do
23 @client.post '/api/todo',
24 { task: 'Write a test that fails' }.to_json,
25 { 'CONTENT_TYPE' => 'application/json' }
26
27 @client.get '/api/todo'
28
29 expect(@client.last_response.status).to eq(200)
30
31 expected_list = [
32 '[ ] 1. Write a test that fails'
33 ]
34 expect(@client.last_response.body).to eq(expected_list.to_json)
35
36 end
37 end
We run this test and see that it fails, since there isn’t any controller handling this route.
1 1) As a user I want to get a list with all the tasks I've introduced
2 Failure/Error: expect(@client.last_response.status).to eq(200)
3
4 expected: 200
5 got: 404
6 ````
7
8 So we add one:
9
10 ```ruby
11 # frozen_string_literal: true
12
13 require 'sinatra'
14 require_relative '../../domain/task'
15 require_relative '../../domain/task_repository'
16 require_relative '../../application/add_task_handler'
17
18 class TodoListApp < Sinatra::Base
19 def initialize(add_task_handler)
20 @add_task_handler = add_task_handler
21 end
22
23 post '/api/todo' do
24 payload = JSON.parse request.body.read.to_s
25
26 @add_task_handler.execute payload['task']
27
28 [201]
29 end
30
31 get '/api/todo' do
32
33 end
34
35 end
This time the error is that it doesn’t return anything. We can easily fix it with this constant implementation:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler)
10 @add_task_handler = add_task_handler
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15
16 @add_task_handler.execute payload['task']
17
18 [201]
19 end
20
21 get '/api/todo' do
22 tasks = [
23 '[ ] 1. Write a test that fails'
24 ]
25 [200, tasks.to_json]
26 end
27
28 end
Of course, it would be best to recover the tasks from the repository and generate the response from there. To do it we’re going to change the test a bit, introducing an extra task and expecting a longer list as a result.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 RSpec.describe 'As a user I want to' do
7
8 before do
9 @client = build_client
10 end
11
12 it "add a new task to the list" do
13
14 @client.post '/api/todo',
15 { task: 'Write a test that fails' }.to_json,
16 { 'CONTENT_TYPE' => 'application/json' }
17
18 expect(@client.last_response.status).to eq(201)
19 expect(@task_repository.next_id).to eq(2)
20 end
21
22 it 'get a list with all the tasks I\'ve introduced' do
23 @client.post '/api/todo',
24 { task: 'Write a test that fails' }.to_json,
25 { 'CONTENT_TYPE' => 'application/json' }
26
27 @client.post '/api/todo',
28 { task: 'Write Production code that makes the test pass' }.to_json,
29 { 'CONTENT_TYPE' => 'application/json' }
30
31
32 @client.get '/api/todo'
33
34 expect(@client.last_response.status).to eq(200)
35
36 expected_list = [
37 '[ ] 1. Write a test that fails',
38 '[ ] 2. Write Production code that makes the test pass'
39 ]
40 expect(@client.last_response.body).to eq(expected_list.to_json)
41
42 end
43 end
The test will fail as the generated and expected lists don’t match. To get it to pass we would need to inject the repository again, so that we can recover the saved tasks.
For now we can do it in the test, but first we would have to cancel this second test to go back to green, and then make the changes that we needed. This is the test that would remain:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 RSpec.describe 'As a user I want to' do
7
8 before do
9 @client = build_client
10 end
11
12 it "add a new task to the list" do
13
14 @client.post '/api/todo',
15 { task: 'Write a test that fails' }.to_json,
16 { 'CONTENT_TYPE' => 'application/json' }
17
18 expect(@client.last_response.status).to eq(201)
19 expect(@task_repository.next_id).to eq(2)
20 end
21
22 it 'get a list with all the tasks I\'ve introduced' do
23 @client.post '/api/todo',
24 { task: 'Write a test that fails' }.to_json,
25 { 'CONTENT_TYPE' => 'application/json' }
26
27 # @client.post '/api/todo',
28 # { task: 'Write Production code that makes the test pass' }.to_jso\
29 n,
30 # { 'CONTENT_TYPE' => 'application/json' }
31
32
33 @client.get '/api/todo'
34
35 expect(@client.last_response.status).to eq(200)
36
37 expected_list = [
38 '[ ] 1. Write a test that fails',
39 # '[ ] 2. Write Production code that makes the test pass'
40 ]
41 expect(@client.last_response.body).to eq(expected_list.to_json)
42
43 end
44 end
The production code:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler, task_repository)
10 @add_task_handler = add_task_handler
11 @task_repository = task_repository
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21
22 get '/api/todo' do
23 tasks = [
24 '[ ] 1. Write a test that fails'
25 ]
26 [200, tasks.to_json]
27 end
28 end
Now we run into a couple of problems:
- We don’t have a method in the repository to obtain the tasks
- We have to handle the transformation of
Taskinto its representation
Personally, I like I’m interested in tackling the latter first. Set to return a hard-coded answer, I can start with the transformation from the Task object, and then I’ll resume the development of TaskRepository.
In fact, this makes sense as a refactoring in the current situation, while the test is still green. So we get to it:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler, task_repository)
10 @add_task_handler = add_task_handler
11 @task_repository = task_repository
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21
22 get '/api/todo' do
23 tasks = {
24 1 => Task.new(1, 'Write a test that fails')
25 }
26 representation = tasks.map do |key, task|
27 "[ ] #{task.id}. #{task.description}"
28 end
29
30 [200, representation.to_json]
31 end
32
33 end
This solution is very simple in Ruby and lets us pass the test.
For the next step we’ll need to implement the find_all method in the repository, so we have to change focus and move to its test. For now, we start with a simple test:
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5 require_relative '../../src/infrastructure/persistence/memory_storage'
6
7 describe 'TaskRepository' do
8 before() do
9 memory_storage = MemoryStorage.new
10 @task_repository = TaskRepository.new memory_storage
11 end
12
13 it 'first identity should be 1' do
14
15 result = @task_repository.next_id
16
17 expect(result).to eq(1)
18 end
19
20 it 'should add a Task' do
21 task = Task.new 1, 'Task Description'
22
23 @task_repository.store task
24
25 expect(@task_repository.next_id).to eq(2)
26 end
27
28 it 'should add several tasks' do
29
30 @task_repository.store Task.new(1, 'Task Description')
31 @task_repository.store Task.new(2, 'Another Task')
32 @task_repository.store Task.new(3, 'Third Task')
33
34 expect(@task_repository.next_id).to eq(4)
35 end
36
37 it 'should find all tasks stored' do
38 @task_repository.store Task.new(1, 'Task Description')
39 @task_repository.store Task.new(2, 'Another Task')
40 @task_repository.store Task.new(3, 'Third Task')
41
42 expect(@task_repository.find_all.count).to eq(3)
43 end
44 end
To make it pass we need:
1 class TaskRepository
2 def initialize(storage)
3 @storage = storage
4 end
5
6 def next_id
7 @storage.next_id
8 end
9
10 def store(task)
11 @storage.store task
12 end
13
14 def find_all
15 @storage.find_all
16 end
17 end
And as it’s not implemented in memort_storage, we add it to it:
1 class MemoryStorage
2 def initialize
3 @objects = {}
4 end
5
6 def next_id
7 @objects.count + 1
8 end
9
10 def store(object)
11 @objects.store object.id, object
12 end
13
14 def find_all
15 @objects
16 end
17 end
This passes the test. We could add some tests here to verify that the stored tasks are, in fact, the ones that we have saved. After tinkering a bit:
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5 require_relative '../../src/infrastructure/persistence/memory_storage'
6
7 describe 'TaskRepository' do
8 before() do
9 memory_storage = MemoryStorage.new
10 @task_repository = TaskRepository.new memory_storage
11 end
12
13 it 'first identity should be 1' do
14
15 result = @task_repository.next_id
16
17 expect(result).to eq(1)
18 end
19
20 it 'should add a Task' do
21 task = Task.new 1, 'Task Description'
22
23 @task_repository.store task
24
25 expect(@task_repository.next_id).to eq(2)
26 end
27
28 it 'should add several tasks' do
29
30 @task_repository.store Task.new(1, 'Task Description')
31 @task_repository.store Task.new(2, 'Another Task')
32 @task_repository.store Task.new(3, 'Third Task')
33
34 expect(@task_repository.next_id).to eq(4)
35 end
36
37 it 'should find all tasks stored' do
38 examples = [
39 Task.new(1, 'Task Description'),
40 Task.new(2, 'Another Task'),
41 Task.new(3, 'Third Task')
42 ].each { |task| @task_repository.store task }
43
44 tasks = @task_repository.find_all
45
46 expect(tasks.count).to eq(3)
47 expect(tasks[1]).to eq(examples[0])
48 expect(tasks[2]).to eq(examples[1])
49 expect(tasks[3]).to eq(examples[2])
50 end
51
52 end
With which we’d have everything we need in the repository. Therefore, we can introduce its use in the production code after recovering the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 RSpec.describe 'As a user I want to' do
7
8 before do
9 @client = build_client
10 end
11
12 it "add a new task to the list" do
13
14 @client.post '/api/todo',
15 { task: 'Write a test that fails' }.to_json,
16 { 'CONTENT_TYPE' => 'application/json' }
17
18 expect(@client.last_response.status).to eq(201)
19 expect(@task_repository.next_id).to eq(2)
20 end
21
22 it 'get a list with all the tasks I\'ve introduced' do
23 @client.post '/api/todo',
24 { task: 'Write a test that fails' }.to_json,
25 { 'CONTENT_TYPE' => 'application/json' }
26
27 @client.post '/api/todo',
28 { task: 'Write Production code that makes the test pass' }.to_json,
29 { 'CONTENT_TYPE' => 'application/json' }
30
31
32 @client.get '/api/todo'
33
34 expect(@client.last_response.status).to eq(200)
35
36 expected_list = [
37 '[ ] 1. Write a test that fails',
38 '[ ] 2. Write Production code that makes the test pass'
39 ]
40 expect(@client.last_response.body).to eq(expected_list.to_json)
41
42 end
43 end
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler, task_repository)
10 @add_task_handler = add_task_handler
11 @task_repository = task_repository
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21
22 get '/api/todo' do
23 tasks = @task_repository.find_all
24
25 representation = tasks.map do |key, task|
26 "[ ] #{task.id}. #{task.description}"
27 end
28
29 [200, representation.to_json]
30 end
31
32 end
Similarly to how we did in the previous story, now would be the moment to extract the business logic that the controller contains to a use case. We have to remember that the rule is to let the controller be the one who decides which representation it needs.
We’ll follow the same procedure as before, extracting a private method with the functionality that we’re going to move to the use case. Here we’ve taken quite a long leap of code, implementing the transformation strategy by using a block.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7
8 class TodoListApp < Sinatra::Base
9 def initialize(add_task_handler, task_repository)
10 @add_task_handler = add_task_handler
11 @task_repository = task_repository
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21
22 get '/api/todo' do
23 tasks = get_tasks_list do |task|
24 "[ ] #{task.id}. #{task.description}"
25 end
26
27 [200, tasks.to_json]
28 end
29
30 private
31
32 def get_tasks_list
33 tasks = @task_repository.find_all
34 return tasks unless block_given?
35
36 representations = []
37 tasks.each do |key, task|
38 representations << yield(task)
39 end
40
41 representations
42 end
43
44 end
It’s now when we create the use case:
1 class GetTaskListHandler
2 def initialize(task_repository)
3 @task_repository = task_repository
4 end
5
6 def execute
7
8 end
9 end
And we use it within the code.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7 require_relative '../../application/get_task_list_handler'
8
9 class TodoListApp < Sinatra::Base
10 def initialize(add_task_handler, task_repository)
11 @add_task_handler = add_task_handler
12 @task_repository = task_repository
13 @get_tasks_list_handler = GetTaskListHandler.new task_repository
14 end
15
16 post '/api/todo' do
17 payload = JSON.parse request.body.read.to_s
18
19 @add_task_handler.execute payload['task']
20
21 [201]
22 end
23
24 get '/api/todo' do
25 tasks = @get_tasks_list_handler.execute do |task|
26 "[ ] #{task.id}. #{task.description}"
27 end
28 tasks = get_tasks_list do |task|
29 "[ ] #{task.id}. #{task.description}"
30 end
31
32 [200, tasks.to_json]
33 end
34
35 private
36
37 def get_tasks_list
38 tasks = @task_repository.find_all
39 return tasks unless block_given?
40
41 representations = []
42 tasks.each do |key, task|
43 representations << yield(task)
44 end
45
46 representations
47 end
48 end
With these changes the test passes. Executing the use case doesn’t have any effect on the test, so we’re going to move the code with the following steps:
First, we copy the private method get_tasks_list in the execute of the use case:
1 class GetTaskListHandler
2 def initialize(task_repository)
3 @task_repository = task_repository
4 end
5
6 def execute
7 tasks = @task_repository.find_all
8 return tasks unless block_given?
9
10 representations = []
11 tasks.each do |key, task|
12 representations << yield(task)
13 end
14
15 representations
16 end
17 end
We run the test to make sure that this change doesn’t carry any undesired effects. Now we remove the call to the private method and we try again:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7 require_relative '../../application/get_task_list_handler'
8
9 class TodoListApp < Sinatra::Base
10 def initialize(add_task_handler, task_repository)
11 @add_task_handler = add_task_handler
12 @task_repository = task_repository
13 @get_tasks_list_handler = GetTaskListHandler.new task_repository
14 end
15
16 post '/api/todo' do
17 payload = JSON.parse request.body.read.to_s
18
19 @add_task_handler.execute payload['task']
20
21 [201]
22 end
23
24 get '/api/todo' do
25 tasks = @get_tasks_list_handler.execute do |task|
26 "[ ] #{task.id}. #{task.description}"
27 end
28
29 [200, tasks.to_json]
30 end
31
32 private
33
34 def get_tasks_list
35 tasks = @task_repository.find_all
36 return tasks unless block_given?
37
38 representations = []
39 tasks.each do |key, task|
40 representations << yield(task)
41 end
42
43 representations
44 end
45 end
With this we make sure that it’s the use case the one that is executing the action, and therefore, is causing the test to keep passing.
Now it’s only a matter of deleting the private method.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7 require_relative '../../application/get_task_list_handler'
8
9 class TodoListApp < Sinatra::Base
10 def initialize(add_task_handler, task_repository)
11 @add_task_handler = add_task_handler
12 @task_repository = task_repository
13 @get_tasks_list_handler = GetTaskListHandler.new task_repository
14 end
15
16 post '/api/todo' do
17 payload = JSON.parse request.body.read.to_s
18
19 @add_task_handler.execute payload['task']
20
21 [201]
22 end
23
24 get '/api/todo' do
25 tasks = @get_tasks_list_handler.execute do |task|
26 "[ ] #{task.id}. #{task.description}"
27 end
28
29 [200, tasks.to_json]
30 end
31 end
And that’s it. The second user story is implemented. We still have a bit of refactoring left. We’re going to inject the use case that we’ve just created. Also, we’re still going to leave the TaskRepository dependency, as it’s foreseeable that we’ll need it again.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../domain/task'
5 require_relative '../../domain/task_repository'
6 require_relative '../../application/add_task_handler'
7 require_relative '../../application/get_task_list_handler'
8
9 class TodoListApp < Sinatra::Base
10 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
11 @add_task_handler = add_task_handler
12 @get_tasks_list_handler = get_tasks_list_handler
13 @task_repository = task_repository
14 end
15
16 post '/api/todo' do
17 payload = JSON.parse request.body.read.to_s
18
19 @add_task_handler.execute payload['task']
20
21 [201]
22 end
23
24 get '/api/todo' do
25 tasks = @get_tasks_list_handler.execute do |task|
26 "[ ] #{task.id}. #{task.description}"
27 end
28
29 [200, tasks.to_json]
30 end
31 end
And we apply this in the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def todo_application
7 @task_repository = TaskRepository.new MemoryStorage.new
8 @add_task_handler = AddTaskHandler.new @task_repository
9 @get_tasks_list_handler = GetTaskListHandler.new @task_repository
10 TodoListApp.new @add_task_handler, @get_tasks_list_handler, @task_repository
11 end
12
13 # ...
Ruby is pretty concise, but even so, I’m going to do some refactoring in the acceptance test extracting the API calls to methods:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def api_post_task(description)
7 @client.post '/api/todo',
8 { task: description }.to_json,
9 { 'CONTENT_TYPE' => 'application/json' }
10 end
11
12 def api_get_tasks
13 @client.get '/api/todo'
14 end
15
16 RSpec.describe 'As a user I want to' do
17
18 before do
19 @client = build_client
20 end
21
22 it "add a new task to the list" do
23
24 api_post_task('Write a test that fails')
25
26 expect(@client.last_response.status).to eq(201)
27 expect(@task_repository.next_id).to eq(2)
28 end
29
30 it 'get a list with all the tasks I\'ve introduced' do
31 api_post_task('Write a test that fails')
32 api_post_task('Write Production code that makes the test pass')
33
34 api_get_tasks
35
36 expect(@client.last_response.status).to eq(200)
37
38 expected_list = [
39 '[ ] 1. Write a test that fails',
40 '[ ] 2. Write Production code that makes the test pass'
41 ]
42
43 expect(@client.last_response.body).to eq(expected_list.to_json)
44 end
45 end
Mark a task as completed
The last piece of functionality that we’re going to implement is to mark a task as completed. We have to perform the steps that we’ve been following until now:
- Add and example to the acceptance test
- Implement the functionality in the controller
- Extract it to a use case
If we need to develop anything new in an object, like it happened with TaskRepository, we do it with a green acceptance test, so that we can later use it in the code without problems.
So let’s go. Let’s start with the acceptance test, which thanks to the previous refactorings should be easy to write. Here it is:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def api_post_task(description)
7 @client.post '/api/todo',
8 { task: description }.to_json,
9 { 'CONTENT_TYPE' => 'application/json' }
10 end
11
12 def api_get_tasks
13 @client.get '/api/todo'
14 end
15
16 RSpec.describe 'As a user I want to' do
17
18 before do
19 @client = build_client
20 end
21
22 # ...
23
24 it 'mark a task completed' do
25 api_post_task('Write a test that fails')
26 api_post_task('Write Production code that makes the test pass')
27
28 api_get_tasks
29
30 @client.patch '/api/todo/1',
31 { completed: true }.to_json,
32 { 'CONTENT_TYPE' => 'application/json' }
33
34 expect(@client.last_response.status).to eq(200)
35
36 expected_list = [
37 '[√] 1. Write a test that fails',
38 '[ ] 2. Write Production code that makes the test pass'
39 ]
40
41 expect(@client.last_response.body).to eq(expected_list.to_json)
42 end
43 end
The main point of interest in this test is that we’re going to check that it has worked by recovering the list and seeing if the task is already being represented as marked. In many respects, we could consider that this test would be sufficient to validate all of the list functionality, since in order to reach the final result, all of the other actions -that we’ve developed with other tests- work.
So we’re going to start adding production production code until we get the test to pass. Of course, the first problem is that there’s neither a route nor an associated controller.
With this first step we solve this problem, and the test failure now has to do with the content of the response.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
7 @add_task_handler = add_task_handler
8 @get_tasks_list_handler = get_tasks_list_handler
9 @task_repository = task_repository
10 end
11
12 #...
13
14 patch '/api/todo/:task_id' do | task_id |
15 [200]
16 end
17 end
This is the error:
1 1) As a user I want to mark a task completed
2 Failure/Error: expect(@client.last_response.body).to eq(expected_list.to_json)
3
4 expected: "[\"[√] 1. Write a test that fails\",\"[ ] 2. Write Production code\
5 that makes the test pass\"]"
6 got: "[\"[ ] 1. Write a test that fails\",\"[ ] 2. Write Production code\
7 that makes the test pass\"]"
This means that the completed task appears unmarked, which is exactly where we want to be.
A way of solving it is using this code:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15
16 @add_task_handler.execute payload['task']
17
18 [201]
19 end
20
21 get '/api/todo' do
22 tasks = @get_tasks_list_handler.execute do |task|
23 "[#{task.id == 1 ? '√' : ' '}] #{task.id}. #{task.description}"
24 end
25
26 [200, tasks.to_json]
27 end
28
29 patch '/api/todo/:task_id' do | task_id |
30 [200]
31 end
32 end
And this code makes our current test pass. However, it makes the previous test fail -the one that retrieves all of the tasks- as in that test we assume that none of them are completed.
Of course, what we need is that a task can say that it’s completed. We need to add some behavior to Task, but also keep the previous acceptance tests passing. Therefore, we’re going to temporarily remove this test, revert this last change, and work to add the capacity of being marked as completed to Task.
For now, it’s enough to remove the last assertion, which is the one that controls the behavior change in Task:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def api_post_task(description)
7 @client.post '/api/todo',
8 { task: description }.to_json,
9 { 'CONTENT_TYPE' => 'application/json' }
10 end
11
12 def api_get_tasks
13 @client.get '/api/todo'
14 end
15
16 RSpec.describe 'As a user I want to' do
17
18 before do
19 @client = build_client
20 end
21
22 #...
23
24 it 'mark a task completed' do
25 api_post_task('Write a test that fails')
26 api_post_task('Write Production code that makes the test pass')
27
28 @client.patch '/api/todo/1',
29 { completed: true }.to_json,
30 { 'CONTENT_TYPE' => 'application/json' }
31
32 expect(@client.last_response.status).to eq(200)
33
34 api_get_tasks
35
36 expected_list = [
37 '[√] 1. Write a test that fails',
38 '[ ] 2. Write Production code that makes the test pass'
39 ]
40
41 # expect(@client.last_response.body).to eq(expected_list.to_json)
42 end
43 end
And I also have to neutralize the change in the production code, temporarily:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15
16 @add_task_handler.execute payload['task']
17
18 [201]
19 end
20
21 get '/api/todo' do
22 tasks = @get_tasks_list_handler.execute do |task|
23 "[ ] #{task.id}. #{task.description}"
24 end
25
26 [200, tasks.to_json]
27 end
28
29 patch '/api/todo/:task_id' do | task_id |
30 [200]
31 end
32 end
Let’s see, then, how to mark completed tasks:
1 require 'rspec'
2
3 require_relative '../../src/domain/task'
4
5 describe 'Task' do
6
7 it 'should be incomplete on creation' do
8 task = Task.new 1, 'Task Description'
9 expect(task.completed).to be_falsey
10 end
11 end
This suffices to introduce the property, initialize it as false, and expose a method to access it.
1 class Task
2 attr_reader :description, :id, :completed
3 def initialize(id, description)
4
5 @id = id
6 @description = description
7 @completed = false
8 end
9 end
On the other hand, we need to be able to mark the task as completed:
1 require 'rspec'
2
3 require_relative '../../src/domain/task'
4
5 describe 'Task' do
6
7 it 'should be incomplete on creation' do
8 task = Task.new 1, 'Task Description'
9 expect(task.completed).to be_falsey
10 end
11
12 it 'should be able to be completed' do
13 task = Task.new 1, 'Task Description'
14 task.mark_completed
15 expect(task.completed).to be_truthy
16 end
17 end
Which is pretty easy to achieve:
1 class Task
2 attr_reader :description, :id, :completed
3 def initialize(id, description)
4
5 @id = id
6 @description = description
7 @completed = false
8 end
9
10 def mark_completed
11 @completed = true
12 end
13 end
For this part, we got everything we need.
Now, we’re going to perform a refactoring in order to use some of these capabilities. With this refactoring we keep the current behavior and get ready to handle the important change:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 # ...
14
15 get '/api/todo' do
16 tasks = @get_tasks_list_handler.execute do |task|
17 "[#{task.completed ? '√' : ' '}] #{task.id}. #{task.description}"
18 end
19
20 [200, tasks.to_json]
21 end
22
23 # ...
24 end
So we recover the test:
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def api_post_task(description)
7 @client.post '/api/todo',
8 { task: description }.to_json,
9 { 'CONTENT_TYPE' => 'application/json' }
10 end
11
12 def api_get_tasks
13 @client.get '/api/todo'
14 end
15
16 RSpec.describe 'As a user I want to' do
17
18 before do
19 @client = build_client
20 end
21
22 it "add a new task to the list" do
23
24 api_post_task('Write a test that fails')
25
26 expect(@client.last_response.status).to eq(201)
27 expect(@task_repository.next_id).to eq(2)
28 end
29
30 it 'get a list with all the tasks I\'ve introduced' do
31 api_post_task('Write a test that fails')
32 api_post_task('Write Production code that makes the test pass')
33
34 api_get_tasks
35
36 expect(@client.last_response.status).to eq(200)
37
38 expected_list = [
39 '[ ] 1. Write a test that fails',
40 '[ ] 2. Write Production code that makes the test pass'
41 ]
42
43 expect(@client.last_response.body).to eq(expected_list.to_json)
44 end
45
46 it 'mark a task completed' do
47 api_post_task('Write a test that fails')
48 api_post_task('Write Production code that makes the test pass')
49
50 @client.patch '/api/todo/1',
51 { completed: true }.to_json,
52 { 'CONTENT_TYPE' => 'application/json' }
53
54 expect(@client.last_response.status).to eq(200)
55
56 api_get_tasks
57
58 expected_list = [
59 '[√] 1. Write a test that fails',
60 '[ ] 2. Write Production code that makes the test pass'
61 ]
62
63 expect(@client.last_response.body).to eq(expected_list.to_json)
64 end
65 end
Which fails for the desired reason. The fact that we’re interested in things failing for good reasons never stops being kind of funny:
1 1) As a user I want to mark a task completed
2 Failure/Error: expect(@client.last_response.body).to eq(expected_list.to_json)
3
4 expected: "[\"[√] 1. Write a test that fails\",\"[ ] 2. Write Production code\
5 that makes the test pass\"]"
6 got: "[\"[ ] 1. Write a test that fails\",\"[ ] 2. Write Production code\
7 that makes the test pass\"]"
Now is when we implement a tentative solution:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 # ...
14
15 patch '/api/todo/:task_id' do | task_id |
16 task = Task.new 1, 'Write a test that fails'
17 task.mark_completed
18
19 @task_repository.store task
20
21 [200]
22 end
23 end
And this passes the test. Obviously we need to recover the task first so we can update it, but it’s something that we don’t have in our TaskRepository yet. But since all our test are passing, we can add the functionality.
1 require 'rspec'
2
3 require_relative '../../src/domain/task_repository'
4 require_relative '../../src/domain/task'
5 require_relative '../../src/infrastructure/persistence/memory_storage'
6
7 describe 'TaskRepository' do
8 before() do
9 memory_storage = MemoryStorage.new
10 @task_repository = TaskRepository.new memory_storage
11 end
12
13 # ...
14
15 it 'should retrieve a task by id' do
16 examples = [
17 Task.new(1, 'Task Description'),
18 Task.new(2, 'Another Task'),
19 Task.new(3, 'Third Task')
20 ].each { |task| @task_repository.store task }
21
22 task = @task_repository.retrieve 1
23
24 expect(task).to eq(examples[0])
25 end
26 end
We implement it like this:
1 class TaskRepository
2 def initialize(storage)
3 @storage = storage
4 end
5
6 def next_id
7 @storage.next_id
8 end
9
10 def store(task)
11 @storage.store task
12 end
13
14 def find_all
15 @storage.find_all
16 end
17
18 def retrieve(task_id)
19 @storage.retrieve task_id
20 end
21 end
Together with:
1 class MemoryStorage
2 def initialize
3 @objects = {}
4 end
5
6 def next_id
7 @objects.count + 1
8 end
9
10 def store(object)
11 @objects.store object.id, object
12 end
13
14 def find_all
15 @objects
16 end
17
18 def retrieve(object_id)
19 @objects[object_id]
20 end
21 end
Now we can use it in our implementation, replacing the direct assignation of task that we had until now.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 post '/api/todo' do
14 payload = JSON.parse request.body.read.to_s
15
16 @add_task_handler.execute payload['task']
17
18 [201]
19 end
20
21 get '/api/todo' do
22 tasks = @get_tasks_list_handler.execute do |task|
23 "[#{task.completed ? '√' : ' '}] #{task.id}. #{task.description}"
24 end
25
26 [200, tasks.to_json]
27 end
28
29 patch '/api/todo/:task_id' do | task_id |
30 task = @task_repository.retrieve task_id
31 task.mark_completed
32
33 @task_repository.store task
34
35 [200]
36 end
37 end
And we’re almost done! The acceptance test is still passing. The only thing that remains is to introduce the use case, for which we follow the refactoring process that we already know well. First we extract the functionality to a private method.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 # ...
14
15 patch '/api/todo/:task_id' do | task_id |
16 mark_task_completed task_id
17
18 [200]
19 end
20
21 def self.mark_task_completed(task_id)
22 task = @task_repository.retrieve task_id
23 task.mark_completed
24
25 @task_repository.store task
26 end
27 end
We introduce the new class, which simply uses the same code that’s already tested.
1 class MarkTaskCompletedHandler
2 def initialize(task_repository)
3
4 @task_repository = task_repository
5 end
6
7 def execute(task_id)
8 task = @task_repository.retrieve task_id
9 task.mark_completed
10
11 @task_repository.store task
12 end
13 end
And now, we introduce its use. Since this action is idempotent, we can do this in such a way that we make sure that it works before deleting the code that we’ve just moved.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @task_repository = task_repository
11 end
12
13 # ...
14
15 patch '/api/todo/:task_id' do | task_id |
16 mark_task_completed task_id
17
18 @mark_task_completed = MarkTaskCompletedHandler.new @task_repository
19 @mark_task_completed.execute task_id
20 [200]
21 end
22
23 # ...
24 end
And the continues to pass as expected. So we can delete the previously extracted method. Later we’ll have to change the construction so as to inject the use case. But let’s do it bit by bit:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7 def initialize(add_task_handler, get_tasks_list_handler, task_repository)
8 @add_task_handler = add_task_handler
9 @get_tasks_list_handler = get_tasks_list_handler
10 @mark_task_completed = MarkTaskCompletedHandler.new @task_repository
11
12 @task_repository = task_repository
13 end
14
15 post '/api/todo' do
16 payload = JSON.parse request.body.read.to_s
17
18 @add_task_handler.execute payload['task']
19
20 [201]
21 end
22
23 get '/api/todo' do
24 tasks = @get_tasks_list_handler.execute do |task|
25 "[#{task.completed ? '√' : ' '}] #{task.id}. #{task.description}"
26 end
27
28 [200, tasks.to_json]
29 end
30
31
32 patch '/api/todo/:task_id' do | task_id |
33 @mark_task_completed.execute task_id
34
35 [200]
36 end
37
38 end
We’re going to direct the change in the construction from the test, starting the application with the services that it really needs.
1 # todo_list_acceptance_spec.rb
2 # frozen_string_literal: true
3
4 # ...
5
6 def todo_application
7 @task_repository = TaskRepository.new MemoryStorage.new
8 @add_task_handler = AddTaskHandler.new @task_repository
9 @get_tasks_list_handler = GetTaskListHandler.new @task_repository
10 @mark_task_completed = MarkTaskCompletedHandler.new @task_repository
11
12 TodoListApp.new @add_task_handler, @get_tasks_list_handler, @mark_task_completed
13 end
14
15 # ...
The tests will fail disastrously, but the change is easy to apply. This is how the application will look like:
1 # frozen_string_literal: true
2
3 require 'sinatra'
4 require_relative '../../../src/domain/task'
5
6 class TodoListApp < Sinatra::Base
7
8 def initialize(add_task_handler, get_tasks_list_handler, mark_task_completed)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @mark_task_completed = mark_task_completed
12 end
13
14 post '/api/todo' do
15 payload = JSON.parse request.body.read.to_s
16
17 @add_task_handler.execute payload['task']
18
19 [201]
20 end
21
22 get '/api/todo' do
23 tasks = @get_tasks_list_handler.execute do |task|
24 "[#{task.completed ? '√' : ' '}] #{task.id}. #{task.description}"
25 end
26
27 [200, tasks.to_json]
28 end
29
30 patch '/api/todo/:task_id' do | task_id |
31 @mark_task_completed.execute task_id
32
33 [200]
34 end
35 end
What have we learned in this kata
- It’s perfectly possible to apply an outside-in approach with TDD’s classic methodology.
- The classic outside-in methodology requires all tests to be green to introduce the design, because we do it in the refactoring phase.
- At some moments we might need test doubles, although we’ll prefer to use fake or test-specific implementations (such as in-memory repositories), or, where appropriate, stubs before mocks.