Outside-in TDD clásico
Es posible seguir una metodología outside-in mientras mantenemos el ciclo de TDD clásico. Como ya sabrás, en esta aproximación el diseño se aplica durante la fase de refactor, por lo que, una vez que hemos desarrollado una versión tosca de la funcionalidad deseada, vamos identificando responsabilidades y extrayéndolas a diferentes objetos con los que vamos componiendo el sistema.
En las katas de estilo clásico que hemos presentado en la segunda parte del libro no hemos llegado a esta fase de extracción a colaboradores, aunque lo hemos sugerido varias veces, y sería algo perfectamente posible. De hecho, es un ejercicio recomendable.
Sin embargo, cuando hablamos de outside-in es frecuente que pensemos más bien en proyectos más complejos que los problemas propuestos en las katas. Es decir, el desarrollo de un producto de software real visto desde el punto de vista de sus consumidores.
Nuestro ejemplo de backend de aplicación de lista de tareas estaría en esta categoría. En el capítulo anterior hemos desarrollado el proyecto usando el enfoque mockista, cuya característica principal es que partimos de un test de aceptación y vamos entrando en cada componente de la aplicación, que desarrollamos con la ayuda de un test unitario, mockeando los componentes más internos que aún no hemos desarrollado.
En TDD clásico con frecuencia se hace un diseño up-front para tener una idea de los componentes necesarios y luego se desarrolla cada uno de ellos, integrándose después.
Pero outside-in clásico es un poco diferente. Empezaríamos también con un test en el nivel de aceptación y con el fin de escribir la lógica que lo hace pasar. En las fases de refactor comenzaríamos a extraer objetos capaces de hacerse cargo de las diversas responsabilidades identificadas.
Para este ejemplo escribiremos una nueva versión de nuestra aplicación de lista de tareas, esta vez en Ruby. El framework HTTP será Sinatra y el framework de testing RSpec.
Planteando el problema
Nuestro punto de partida será igualmente un test de aceptación como consumidoras de la API. En cierto modo, podríamos considerar el sistema como un gran objeto con el que nos comunicamos mediante request a sus endpoints.
Al tratarse de TDD clásico no usaremos mocks, salvo si necesitamos definir un límite de arquitectura. Obviamente, para definir este tipo de cosas necesitamos tener algún mínimo de diseño up-front, así que esperamos que en algún momento tendremos casos de uso, entidades de dominio y repositorios.
El límite de arquitectura en nuestro ejemplo será el repositorio. Como todavía no vamos a definir cuál es la tecnología concreta de persistencia, en su momento lo mockearemos. Después veremos cómo desarrollar una implementación.
Poniendo en marcha el desarrollo
Mi primera propuesta de test es la siguiente:
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
Este test intenta instanciar un objeto TodoListApp, que es la clase en la que definiremos la aplicación Sinatra que responderá en primera instancia. Requiere instalar rspec, si no lo tenemos ya. Y fallará con este error:
1 NameError:
2 uninitialized constant TodoListApp
3 # ./spec/todo_list_acceptance_spec.rb:10:in `block (2 levels) in <\
4 top (required)>'
Que nos indica que no tenemos la clase definida en ningún sitio. Para hacerlo pasar, introduciré la clase en el mismo archivo del test y cuando consiga ponerlo en verde, lo moveré a su ubicación.
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
Esto es suficiente para hacer pasar el test, por lo que voy a hacer el refactor más obvio, que es mover TodoListApp a un lugar adecuado en el proyecto.
La fase de refactor es la fase en la que tomamos decisiones de diseño en el enfoque clásico. Los controladores pertenecen a la capa de infraestructura, por lo que será allí donde coloque esta clase. Con eso, el test queda así:
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
Y verificamos que sigue pasando.
Para el siguiente punto necesito hacer un salto un poco más grande y preparar el cliente que ejecutará las requests contra los endpoints. Usando rack-test, puedo crear un cliente del API. Puesto que estoy en verde, voy a introducirlo e iniciarlo. Tendremos que instalar rack-test primero.
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
Este refactor no cambia el resultado del test, así que vamos bastante bien.
Ahora vamos a asegurarnos de que podemos hacer una llamada POST /api/todo y que alguien nos responde.
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
Ahora el test falla, porque la aplicación no es capaz de enrutar la llamada a ningún método. Es el momento de implementar algo en TodoListApp hasta lograr hacer pasar el test. Esto requerirá introducir e instalar sinatra.
1 # frozen_string_literal: true
2
3 require 'sinatra'
4
5 class TodoListApp < Sinatra::Base
6
7 end
Lo cierto es que basta con esto para que el test pase, ya que no estamos haciendo ninguna expectativa sobre la respuesta. Necesitamos un poco más de resolución para obligarnos a implementar una acción asociada al endpoint, para lo cual hacemos que el test sea más preciso y explícito:
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
Y este test, que ya es un test de verdad, nos muestra que no se encuentra la ruta deseada:
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
Con lo que ya podemos implementar una acción que responda.
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
Ahora hemos hecho pasar el test, devolviendo una respuesta fija, y ya tenemos la seguridad de que nuestra aplicación está respondiendo al endpoint. Sería el momento de introducir la llamada con su payload, que será la descripción de la nueva tarea.
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
El test no añade nueva información. Si queremos progresar en el desarrollo necesitaremos introducir otro test que cuestione la implementación actual, obligando a hacer un cambio en la dirección de conseguir aquello que se espera que haga el test.
Este endpoint sirve para crear tareas y guardarlas en la lista, lo que quiere decir que produce un efecto (side effect) en el sistema. Es un comando y no ofrece ninguna respuesta. Para testarlo tenemos que comprobar el efecto verificando que en algún lugar hay una tarea creada.
Una posibilidad es asumir que la tarea se persistirá en un TaskRepository, que sería un colaborador de TodoListApp. Los repositorios son objetos en los límites de arquitectura y se basan en una tecnología concreta. Esto presupone un cierto nivel de diseño previo, pero creo que es un compromiso aceptable dentro del enfoque clásico.
Esto implica modifica la forma en que se instancia TodoListApp, de modo que podamos pasarle colaboradores. Así que antes de nada, vamos a refactorizar el test de modo que la creación de nuevos ejemplos sea más fácil y el test más expresivo.
Quedaría algo así:
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
Con este rediseño el test sigue pasando. Ahora, tenemos que introducir un doble del repositorio. Lo mínimo necesario para forzarnos a crear algo es:
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
Con lo que tendríamos que introducir la definición de la clase. De momento, lo haremos en el mismo archivo.
1 # ...
2
3 class TaskRepository
4
5 end
6
7 def todo_application
8 double(TaskRepository)
9
10 TodoListApp.new
11 end
12
13 # ...
Y se lo pasamos a TodoListApp como parámetro de construcción.
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
En principio estos cambios no afectan al resultado del test. Así que vamos a mover TaskRepository a su sitio, en la capa de dominio.
A continuación, necesitamos definir el efecto que esperamos obtener, lo cual hacemos fijando una expectativa sobre el mensaje que vamos a enviar a 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
El test falla inicialmente porque hemos introducido Task, así que lo añadimos ya en su ubicación en la capa de dominio, porque lo necesitaremos enseguida. Al hacerlo, conseguimos que el test falle por el motivo adecuado:
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
Añadiendo este código en TodoListApp, hacemos que pase el test.
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
Ahora necesitamos que un nuevo test nos pida implementar que se instancie una Task con los valores deseados. Esto es, queremos que Task se inicie con el ID 1 y la descripción que le pasamos. Para que el test funcione tenemos que implementar una inicialización en Task, que aún no tenemos y alguna forma de comparar objetos Task.
Por otro lado, tenemos que implementar alguna manera de inicializar Task. Esta creación puede ser cubierta por el propio test de aceptación. Otro modo de hacerlo sería desarrollando Task con un test unitario, pero la verdad es que, de momento, no lo veo necesario.
Al introducir esto en el 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
Empezará a fallar, por lo que tenemos que implementar la inicialización:
1 class Task
2 def initialize(id, description)
3
4 @id = id
5 @description = description
6 end
7 end
El test falla ahora porque en el TodoListApp no estamos inicializando bien Task ya que no le pasábamos argumentos. Con este pequeño cambio, el test ya pasa.
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
Se puede decir que aquí estamos usando constantes para satisfacer el test, por lo que tenemos que evolucionar el código y obtener una implementación más flexible. Empezaré con un pequeño refactor que ponga de manifiesto lo que tenemos que lograr a continuación.
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
Así de simple, tenemos que obtener valores para las variables que acabamos de introducir. Pero ahora mismo no lo estamos comprobando. Es el momento de introducir un matcher.
1 RSpec::Matchers.define :has_same_data do |expected|
2 match do |actual|
3 expected.id == actual.id && expected.description == actual.descript\
4 ion
5 end
6 end
Para usarlo, cambiaremos el 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.descript\
31 ion
32 end
33 end
34
35
36 RSpec.describe 'As a user I want to' do
37
38 before do
39 @client = build_client
40 end
41
42 it "add a new task to the list" do
43
44 task = Task.new 1, 'Write a test that fails'
45
46 expect(@task_repository)
47 .to receive(:store)
48 .with(has_same_data(task))
49
50 @client.post '/api/todo',
51 { task: 'Write a test that fails' }.to_json,
52 { 'CONTENT_TYPE' => 'application/json' }
53
54 expect(@client.last_response.status).to eq(201)
55 end
56 end
En este momento el test no pasará porque Task no expone métodos para acceder a sus propiedades, por lo que añadiremos 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
Y con esto el test pasa.
task_description viene en la payload de la request. Puesto que ya está definida en el test ahora mismo podríamos simplemente usarla.
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
En cuanto al ID de task, necesitaremos un generador de identidades. En nuestro diseño hemos puesto esta responsabilidad en TaskRepository, que tendría un método next_id. En este caso, tendremos que especificarlo en el test mediante un 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
Tal y como está el código de producción el test pasa, por lo que no nos dice qué tendríamos que hacer a continuación, así que voy a hacer una pequeña trampa y forzar un fallo del test:
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
Ahora sí tiene sentido introducir la llamada a next_id:
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
Extracción del caso de uso
Ahora el test ya pasa y podríamos decir que la implementación del endpoint está completa. Sin embargo, tenemos varios problemas:
-
TaskRepositoryes un mock. Sabemos qué interfaz debería tener, pero no tenemos ninguna implementación concreta que pueda funcionar en producción. - En el controlador hay un montón de lógica de negocio que no debería estar ahí.
- De hecho tenemos objetos de dominio en el controlador:
TaskyTaskRepostory.
En resumen, ahora mismo, el controlador está haciendo más cosas de las debidas. Además de su tarea como controlador, que es gestionar la request que viene del exterior, está haciendo tareas de la capa de aplicación, coordinando objetos del dominio.
Por tanto, tendríamos que extraer esta parte de la implementación a un nuevo objeto, que será el caso de uso AddTaskHandler.
Lo primero que hago es extraer la funcionalidad a un método privado
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
Crearé una clase AddTaskHandler en la capa de aplicación que encapsule la misma funcionalidad:
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
Y reemplazo la implementación del método por una llamada:
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
Hago un inline del método:
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
Y refactorizo un poco la solución, moviendo la inicialización al constructor y eliminando alguna variable temporal:
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
El siguiente paso es inyectar la dependencia de AddTaskHandler en lugar de la del repositorio. Para ello cambio primero el 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.descript\
31 ion
32 end
33 end
34
35
36 RSpec.describe 'As a user I want to' do
37
38 before do
39 @client = build_client
40 end
41
42 it "add a new task to the list" do
43
44 task = Task.new 1, 'Write a test that fails'
45
46 allow(@task_repository)
47 .to receive(:next_id)
48 .and_return(1)
49
50 expect(@task_repository)
51 .to receive(:store)
52 .with(has_same_data(task))
53
54 @client.post '/api/todo',
55 { task: 'Write a test that fails' }.to_json,
56 { 'CONTENT_TYPE' => 'application/json' }
57
58 expect(@client.last_response.status).to eq(201)
59 end
60 end
Esto hará que el test falle porque el código de producción sigue esperando al repositorio como dependencia, así que lo cambiamos de este modo:
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
Y ya tenemos esta parte resuelta.
Implementando un repositorio
Para arrancar el desarrollo hemos empezado con un TaskRepository que es un mock. Hemos introducido una clase vacía para poder doblarla, pero esta version real no puede recibir mensajes siquiera. Esto ha sido una licencia que me he permitido para no empezar a desarrollar desde dentro, creando componentes de la capa de dominio como este repositorio, antes de saber cómo iban a ser usados.
El repositorio es uno de esos objetos que viven en el límite de arquitectura, por así decir, por lo que es bastante aceptable usar un doble. Sin embargo, ahora vamos a tratar de implementar una versión que pueda servirnos para testear.
Esto supone un pequeño problema si consideramos que TaskRepository es un objeto del dominio, por lo que no queremos tener implementaciones concretas en esta capa. Una forma sencilla de hacerlo es mediante composición: en dominio tendríamos una clase TaskRepository que simplemente delegaría en la implementación concreta que inyectemos. Este es el enfoque que vamos a adoptar en este caso, implementando las versiones del repositorio que puedan ser necesarias a partir de un test unitario extrayendo las implementaciones a partir de una genérica.
En esta ocasión empezamos por la capacidad del repositorio de atender un mensaje next_id, que debería ser 1 cuando el repositorio está vacío.
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
Este método aún no existe y el test fallará. Implementamos una versión inicial.
1 class TaskRepository
2 def next_id
3 1
4 end
5 end
Con el test en verde, vamos a hacer un refactor. next_id debería proporcionarnos un número que es el resultado de sumar uno a la cantidad de tareas almacenada. Así que vamos a representar esto en código primero.
1 class TaskRepository
2 def initialize
3 @tasks = {}
4 end
5 def next_id
6 @tasks.count + 1
7 end
8 end
Lo suyo sería poder añadir elementos y ver si las cosas realmente funcionan, así que vamos a permitir que el repositorio se pueda inicializar con algún contenido.
1 class TaskRepository
2 def initialize(*tasks)
3 tasks.empty? ? @tasks = {} : (@tasks = Hash[tasks.collect { |task| \
4 [task.id, task] }] unless tasks.empty?)
5 end
6
7 def next_id
8 @tasks.count + 1
9 end
10 end
Con esto, podemos testear que si iniciamos el repositorio con algún elemento nos devuelve el identificador correcto. Por ejemplo, así:
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
Esto ya debería ser suficiente para fiarnos de next_id. Puede que estés pensando que la generación de identidades con este algoritmo no es precisamente robusta, pero de momento nos llega para el ejemplo. En cualquier caso, podríamos implementar cualquier otra estrategia.
Ahora podríamos usar next_id como una manera indirecta de saber si hemos añadido tareas en el repositorio, por lo que ya podemos testear el método store.
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
De momento, el test falla porque no tenemos un método que atienda el mensaje store, así que lo añadimos es implementamos la solución más simple:
1 class TaskRepository
2 def initialize(*tasks)
3 tasks.empty? ? @tasks = {} : (@tasks = Hash[tasks.collect { |task| \
4 [task.id, task] }] 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
Que, por lo demás, es suficiente para hacer pasar el test. El último test se superpone al anterior test de next_id, así que lo vamos a quitar.
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
Y también podemos quitar la inicialización, ya que no la necesitamos realmente.
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
Podríamos asegurarnos de que podemos introducir más tareas:
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
Puesto que queremos separar la tecnología concreta de persistencia, usaré estos tests para extraer un repositorio en memoria. Nos queda así:
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
Ahora podemos inyectarlo, para ello modificamos primero el test:
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 Des\
29 cription')
30 @task_repository.store Task.new(@task_repository.next_id, 'Another \
31 Task')
32 @task_repository.store Task.new(@task_repository.next_id, 'Third Ta\
33 sk')
34
35 expect(@task_repository.next_id).to eq(4)
36 end
37 end
Y ahora que solo tenemos un lugar para inicializar el repositorio…
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
El test fallará, pero solo es necesario hacer este cambio:
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
Con el cual tenemos un TaskRepository que podremos configurar para usar distintas tecnologías de persistencia y que podríamos empezar a usar en nuestro test de aceptación.
Un cambio posible es este, aunque luego seguiremos evolucionándolo:
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
Obtener la lista de las tareas
Una vez que podemos añadir tareas, sería interesante poder acceder a ellas. Nuestro siguiente test de aceptación describiría esta acción, introduciendo una o más tareas y obteniendo una lista con todas las que tengamos.
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
Lanzamos este test y vemos que falla, ya que no hay controlador que se encargue de esta ruta.
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 Así que añadimos uno:
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
Esta vez el error es que no se devuelve nada. Podemos arreglarlo fácilmente con esta implementación constante:
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
Por supuesto, lo suyo sería obtener las tareas del repositorio y generar la respuesta a partir de ahí. Para ello vamos a modificar un poco el test, introduciendo una tarea más y esperando una lista más larga en consecuencia.
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 pas\
29 s' }.to_json,
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
El test fallará porque no coinciden la lista generada y la esperada. Para hacerlo pasar necesitaremos volver a inyectar el repositorio, de modo que podamos recuperar las tareas guardadas.
De momento, podemos hacerlo en el test, pero antes tendríamos que anular este segundo test para volver a verde y hacer los cambios que necesitamos. Este es el test que quedaría:
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 p\
29 ass' }.to_json,
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
El código de producción:
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
Ahora nos encontramos un par de problemas:
- No tenemos un método en el repositorio para obtener las tareas
- Tenemos que gestionar la transformación de
Tasken su representación
Personalmente, creo que me interesa abordar antes este último. Puestos a devolver una respuesta hard-coded, puedo empezar con la transformación desde el objeto Task y luego ya continuaré el desarrollo de TaskRepository.
De hecho tiene sentido esto como refactor en la situación actual, mientras el test está en verde. Así que vamos a ello:
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
Esta solución es muy sencilla en Ruby y nos permite hacer pasar el test.
Para el siguiente paso necesitaremos implementar el método find_all en el repositorio, por lo que tenemos que cambiar de foco y movernos a su test. De momento, empezamos con un test sencillo:
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
Para hacerlo pasar necesitamos:
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
Y como no está implementado en memory_storage, pues se lo añadimos:
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
Esto hace pasar el test, podríamos añadir aquí tests para verificar que las tareas almacenadas son las que hemos guardado. Después de toquetear un poco:
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
Con lo que ya tendríamos lo que necesitamos en el repositorio. Por tanto, podemos introducir su uso en el código de producción después de recuperar el 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 pas\
29 s' }.to_json,
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
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
Del mismo modo que hicimos en la historia anterior, ahora sería el momento de extraer la lógica de negocio que contiene el controlador a un caso de uso. Hay que recordar que la condición es que sea el controlador quien decida la representación que necesita.
Seguiremos el mismo procedimiento que antes, extrayendo un método privado con la funcionalidad que vamos a mover al caso de uso. Aquí hemos dado un salto bastante grande de código, implementando la estrategia de transformación mediante un 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
Es ahora cuando creamos el caso de uso:
1 class GetTaskListHandler
2 def initialize(task_repository)
3 @task_repository = task_repository
4 end
5
6 def execute
7
8 end
9 end
Y lo usamos dentro del código.
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
Con estos cambios el test pasa. La ejecución del caso de uso no tiene ningún efecto en el test, así que vamos a mover el código con los siguientes pasos:
Primero, copiamos el método privado get_tasks_list en el execute del caso de uso:
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
Ejecutamos el test para asegurarnos de que este cambio no tiene efectos indeseados. Ahora quitamos la llamada al método privado y volvemos a probar:
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
Con esto ya nos aseguramos de que es el caso de uso el que ejecuta la acción y, por tanto, está haciendo que el test siga pasando.
Solo nos queda borrar el método privado.
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
Y ya está. La segunda historia de usuario está implementada. Nos queda todavía un poco de refactor. Vamos a inyectar el caso de uso que acabamos de crear. Por otro lado, dejaremos aún la dependencia de TaskRepository porque es previsible que la necesitemos de nuevo.
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_reposit\
11 ory)
12 @add_task_handler = add_task_handler
13 @get_tasks_list_handler = get_tasks_list_handler
14 @task_repository = task_repository
15 end
16
17 post '/api/todo' do
18 payload = JSON.parse request.body.read.to_s
19
20 @add_task_handler.execute payload['task']
21
22 [201]
23 end
24
25 get '/api/todo' do
26 tasks = @get_tasks_list_handler.execute do |task|
27 "[ ] #{task.id}. #{task.description}"
28 end
29
30 [200, tasks.to_json]
31 end
32 end
Y aplicamos esto en el 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_rep\
11 ository
12 end
13
14 # ...
Ruby es bastante conciso, aun así, voy a hacer algún refactor en el test de aceptación extrayendo a métodos las llamadas a la API:
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
Marcar una tarea completada
La última funcionalidad que vamos a implementar es marcar una tarea como completada. Nos toca seguir los pasos que hemos realizado hasta ahora:
- Añadir un ejemplo al test de aceptación
- Implementar la funcionalidad en el controlador
- Extraerla a un caso de uso
Si necesitamos desarrollar algo nuevo en algún objeto, como ha ocurrido con TaskRepository, lo hacemos con el test de aceptación en verde, de modo que luego podamos usarlo sin problemas en el código.
Así que vamos allá. Empecemos con el test de aceptación que, gracias a los refactors anteriores, debería ser fácil de escribir. Aquí está:
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
El principal punto de interés en este test es que vamos a comprobar que ha funcionado recuperando la lista y viendo si ya se representa la tarea como marcada. En muchos aspectos, podríamos considerar que este test sería suficiente para validar toda la funcionalidad de la lista, ya que para llegar al resultado final todas las demás acciones, que hemos desarrollado con otros tests, funcionan.
Así que vamos a empezar a añadir código de producción hasta lograr que el test pase. Por supuesto, el primer problema es que no hay una ruta ni un controlador asociado.
Con este primer paso conseguimos resolver este problema y el fallo del test ya tiene que ver con el contenido de la respuesta.
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_reposit\
7 ory)
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 [200]
17 end
18 end
Este es el error:
1 1) As a user I want to mark a task completed
2 Failure/Error: expect(@client.last_response.body).to eq(expected_l\
3 ist.to_json)
4
5 expected: "[\"[√] 1. Write a test that fails\",\"[ ] 2. Write Pr\
6 oduction code that makes the test pass\"]"
7 got: "[\"[ ] 1. Write a test that fails\",\"[ ] 2. Write Pr\
8 oduction code that makes the test pass\"]"
Y este error ya es que la tarea completada aparece sin marcar, que es exactamente donde queremos estar.
Una forma de solucionarlo es con este código:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_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_handler.execute do |task|
24 "[#{task.id == 1 ? '√' : ' '}] #{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 [200]
32 end
33 end
Y este código hace pasar nuestro test actual. Sin embargo, hace fallar el test anterior de obtener todas las tareas, ya que en ese test se asume que no hay ninguna completada.
Por supuesto, lo que necesitamos es que una tarea pueda decir que está completada. Necesitamos añadir algún comportamiento en Task, pero también que los tests de aceptación anteriores pasen. Por tanto, vamos a quitar este test temporalmente, revertir este último cambio y trabajar en añadir en Task la capacidad de ser marcada como completa.
De momento, me basta con anular la última aserción, que es la que controla el cambio de comportamiento en 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
Y también tengo que neutralizar el cambio en el código de producción, temporalmente:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_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_handler.execute do |task|
24 "[ ] #{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 [200]
32 end
33 end
Vamos a ver entonces cómo marcar tareas completadas:
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
Esto nos basta para introducir la propiedad, iniciarla como false, y exponer un método para acceder a ella.
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
Por otra parte, necesitamos poder marcar la tarea como completada:
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
Lo cual es bastante sencillo de lograr:
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
Por esta parte ya tenemos lo que necesitamos.
Ahora, vamos a hacer un refactor para usar algunas de estas capacidades. Con este refactor mantenemos el comportamiento actual y estamos preparados para atender al cambio importante:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @task_repository = task_repository
12 end
13
14 # ...
15
16 get '/api/todo' do
17 tasks = @get_tasks_list_handler.execute do |task|
18 "[#{task.completed ? '√' : ' '}] #{task.id}. #{task.description}"
19 end
20
21 [200, tasks.to_json]
22 end
23
24 # ...
25 end
Así que recuperamos el 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
Que falla por el motivo deseado. No deja de tener cierta gracia que nos interese que fallen cosas por una buena razón:
1 1) As a user I want to mark a task completed
2 Failure/Error: expect(@client.last_response.body).to eq(expected_l\
3 ist.to_json)
4
5 expected: "[\"[√] 1. Write a test that fails\",\"[ ] 2. Write Pr\
6 oduction code that makes the test pass\"]"
7 got: "[\"[ ] 1. Write a test that fails\",\"[ ] 2. Write Pr\
8 oduction code that makes the test pass\"]"
Ahora es cuando implementamos una solución tentativa:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @task_repository = task_repository
12 end
13
14 # ...
15
16 patch '/api/todo/:task_id' do | task_id |
17 task = Task.new 1, 'Write a test that fails'
18 task.mark_completed
19
20 @task_repository.store task
21
22 [200]
23 end
24 end
Y esto hace pasar el test. Obviamente, necesitamos recuperar primero la tarea para poder actualizarla, pero es algo que no tenemos todavía en nuestro TaskRepository. Pero como tenemos todos los tests pasando podemos añadir la funcionalidad.
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
Lo implementamos así:
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
Junto con:
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
Ahora posamos usarlo en nuestra implementación, reemplazando la asignación directa de task, que tenemos ahora.
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_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_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 task = @task_repository.retrieve task_id
32 task.mark_completed
33
34 @task_repository.store task
35
36 [200]
37 end
38 end
Y ya casi estamos. El test de aceptación sigue pasando. Lo único que nos queda es introducir el caso de uso, para lo que seguimos el proceso de refactor que ya conocemos. Primero extraemos la funcionalidad a un método privado.
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @task_repository = task_repository
12 end
13
14 # ...
15
16 patch '/api/todo/:task_id' do | task_id |
17 mark_task_completed task_id
18
19 [200]
20 end
21
22 def self.mark_task_completed(task_id)
23 task = @task_repository.retrieve task_id
24 task.mark_completed
25
26 @task_repository.store task
27 end
28 end
Introducimos la nueva clase, que simplemente usa el mismo código que ya está testado.
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
Y ahora, introducimos su uso. Como esta acción es idempotente, podemos hacer esto de modo que probamos si funciona antes de eliminar el código que hemos movido:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @task_repository = task_repository
12 end
13
14 # ...
15
16 patch '/api/todo/:task_id' do | task_id |
17 mark_task_completed task_id
18
19 @mark_task_completed = MarkTaskCompletedHandler.new @task_repository
20 @mark_task_completed.execute task_id
21 [200]
22 end
23
24 # ...
25 end
Y el test sigue pasando, como era de esperar. Así que podemos eliminar el método extraído antes. Después tendremos que cambiar la construcción para inyectar el caso de uso. Pero vamos por partes:
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_reposit\
8 ory)
9 @add_task_handler = add_task_handler
10 @get_tasks_list_handler = get_tasks_list_handler
11 @mark_task_completed = MarkTaskCompletedHandler.new @task_repository
12
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.completed ? '√' : ' '}] #{task.id}. #{task.description}"
27 end
28
29 [200, tasks.to_json]
30 end
31
32
33 patch '/api/todo/:task_id' do | task_id |
34 @mark_task_completed.execute task_id
35
36 [200]
37 end
38
39 end
El cambio de la construcción lo vamos a dirigir desde el test, iniciando la aplicación con los servicios que realmente necesita
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_tas\
13 k_completed
14 end
15
16 # ...
Los test fallarán estrepitosamente, pero el cambio es fácil de aplicar. Así queda la aplicación:
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_co\
9 mpleted)
10 @add_task_handler = add_task_handler
11 @get_tasks_list_handler = get_tasks_list_handler
12 @mark_task_completed = mark_task_completed
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 patch '/api/todo/:task_id' do | task_id |
32 @mark_task_completed.execute task_id
33
34 [200]
35 end
36 end
Qué hemos aprendido con esta kata
- Es perfectamente posible aplicar un enfoque outside-in con la metodología clásica de TDD.
- La modalidad outside-in clásica require que tengamos los tests en verde para introducir el diseño porque lo hacemos en la fase de refactor.
- En algunos momentos podríamos necesitar dobles de test, aunque preferiremos usar implementaciones fake o específicas para test (como los repositorios en memoria), o en su caso stubs antes que mocks.