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.

El ciclo outside-in clasicista

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:

  • TaskRepository es 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: Task y TaskRepostory.

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 Task en 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.