Algunas correcciones de rumbo antes de proseguir con el desarrollo de la aplicación de gestión de un punto de recogida de paquetes. Quizá no hay mucho de arquitectura hexagonal en este artículo, pero espero que sea útil igualmente.
Puedes leer aquí el artículo anterior
Los tests deben fallar, al menos al principio
Un aspecto que me molesta un poco en el estado actual del proyecto es que el escenario de aceptación no falla. Un test que no falla no proporciona información y lo que queremos es que nos diga qué es lo que tenemos que hacer a continuación.
El motivo de que ahora no falle el test es haber construido el código usando un enfoque TDD clásico, introduciendo la implementación mínima necesaria para que el test pueda pasar. El problema, en parte, es que lenguajes como Ruby nos permiten una implementación vacía de un método. La otra parte es haber introducido implementaciones fake para ir saliendo adelante.
En el caso de los comandos, que como sabemos no devuelven nada sino que provocan un efecto en el sistema, los tests van a pasar si no chequeamos que se haya producido el efecto esperado, lo que puede incluir código que no hace nada. Por desgracia, en este momento inicial de desarrollo puede que aún no sepamos ni que efecto queremos que se produzca, ni como verificarlo.
En las queries, basta con introducir la implementación fake que devuelva lo que pide el test.
Para que el código pueda evolucionar desde esta situación, siempre desde una metodología clásica, la mejor opción que tenemos es introducir tests que desafíen la implementación actual, obligándonos a introducir código más flexible. Lo que ocurre es que hacerlo en el ciclo de aceptación puede ser bastante costoso. Al menos en nuestro ejemplo, en el que el escenario es intencionadamente genérico.
En TDD outside-in suelo apoyarme en la idea de lo que podríamos llamar implementaciones pendientes. Es decir, declarar de forma explícita que una implementación no está completa. Esto no solo hace que el test de aceptación falle, sino que también nos dice dónde deberíamos seguir trabajando.
En el estado actual del código podríamos conseguir esto de dos maneras:
- eliminando (o no poniendo desde el principio) método vacíos, de modo que el escenario no se pueda ejecutar hasta que los añadimos de nuevo y los implementamos. Diría que esta forma va mejor en lenguajes interpretados, ya que el error se va a producir cuando el test intente ejecutar ese método y no evita que la parte ya implementada pueda correr igualmente.
- haciendo que los métodos no implementados generen una excepción o error que indique precisamente que están pendientes de tener código. Esta forma aplica a los lenguajes compilados, ya que permite compilar la aplicación y correr la parte ejecutable del test, hasta fallar en el punto preciso por el que deberíamos continuar.
Vamos a verlo aplicado a nuestro ejemplo.
El paso del escenario que queremos desarrollar ahora es este:
Then("first available container is located") do
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new
response = available_container_handler.handle(available_container)
@container = response.container
end
Queremos que la ejecución del test falle indicándonos que tenemos que implementar AvailableContainerHandler.handle
, que ahora mismo tiene esta implementación, con la cual pasamos el escenario:
class AvailableContainerHandler
def handle(available_container)
AvailableContainerResponse.new
end
end
Con la primera técnica, simplemente quitamos el código. El intérprete de Ruby indicará el fallo, dejándonos claro que tendríamos que bajar al nivel unitario para seguir el desarrollo.
class AvailableContainerHandler
end
Esto es lo que ocurre al ejecutar el escenario, que nos dice cual es exactamente el problema y, en consecuencia, cuál es el siguiente paso en que deberíamos concentrarnos.
Then first available container is located # features/step_definitions/steps.rb:19
undefined method `handle' for #<AvailableContainerHandler:0x00000001113ddaa8>
response = available_container_handler.handle(available_container)
^^^^^^^ (NoMethodError)
./features/step_definitions/steps.rb:22:in `"first available container is located"'
features/register_packages.feature:11:in `first available container is located'
Ahora voy a demostrar la otra técnica. Ruby incluye un tipo de error que encaja perfectamente con lo que necesitamos.
class AvailableContainerHandler
def handle(available_container)
raise NotImplementedError "Please, implement AvailableContainerHandler.handle"
end
end
Y esto es lo que ocurre cuando ejecutamos el test. Como podemos ver, es prácticamente lo mismo.
Then first available container is located # features/step_definitions/steps.rb:19
undefined method `NotImplementedError' for #<AvailableContainerHandler:0x000000010d425960>
raise NotImplementedError "Please, implement AvailableContainerHandler.handle"
^^^^^^^^^^^^^^^^^^^ (NoMethodError)
./lib/app/for_registering_packages/available_container/available_container_handler.rb:7:in `handle'
./features/step_definitions/steps.rb:22:in `"first available container is located"'
features/register_packages.feature:11:in `first available container is located'
Para el caso de Ruby me parece que la primera técnica da un mensaje de error mucho más claro. Pero, como he mencionado hace un momento, en otros lenguajes, la técnica de lanzar un error o excepción es más recomendable, ya que permite compilar el proyecto.
Para qué nos sirve que los tests fallen
Los test que fallan nos proporcionan información. Existe el dicho de “no confíes en un test que no falle” y es precisamente porque si bien un test que falla tras un cambio del código nos dice exactamente qué es lo que no funciona, un test que no falla después de introducir un cambio no nos dice nada.
Por esa razón, ahora hemos decidido que tenemos que hacer que los tests que antes pasaban dejen de hacerlo, para poder introducir el código necesario que los pueda hacer pasar. Además, el test concreto que falla nos dice exactamente el lugar donde necesitamos intervenir.
Y al tratarse de una metodología outside-in, cuando un test nos dice que algo está sim implementar, es el momento de diseñar ese componente.
El componente en cuestión es AvailableContainerHandler
.
Disponibilidad de contenedores
De entrada, la primera cuestión bastante obvia es que este caso de uso debería obtener la colección de contenedores de algún lugar, para poder determinar cuál es el primero disponible para el paquete recibido.
Si lo recuerdas, el escenario asumía que habría disponibilidad de espacio suficiente para cualquier paquete que pudiésemos haber registrado. De hecho, para cumplir el primer escenario podríamos tener un paquete de tamaño 3 y un contenedor con capacidad 4 (el más pequeño).
Scenario: There is space for allocating package
When Merry registers a package
Then first available container is located
And he puts the package into it
En estas condiciones ni siquiera necesitamos saber ni el tamaño del paquete ni el del contenedor. Pero sí podríamos plantear un diseño básico. La regla de negocio nos pide obtener el primer contenedor que disponga de espacio. Por otro lado, el caso de uso no debería hacer otra cosa que actuar de coordinador. Por tanto, la responsabilidad de proporcionar el contenedor adecuado debería ser de algún servicio.
Más o menos tal como expresa este test:
RSpec.describe "AvailableContainerHandler" do
context "when used for first time" do
it "should provide available container" do
containers = double("Containers")
expected = Container.new
allow(containers).to receive(:available).and_return(expected)
query = AvailableContainer
handler = AvailableContainerHandler.new(containers)
response = handler.handle(query)
container = response.container
expect(container).to eq(expected)
end
end
end
Esto nos permite introducir una primera implementación del caso de uso y de su objeto response:
class AvailableContainerHandler
def initialize(containers)
@containers = containers
end
def handle(available_container)
available = @containers.available
AvailableContainerResponse.new(available)
end
end
class AvailableContainerResponse
attr_reader :container
def initialize(container)
@container = container
end
end
Que hacen pasar el test. Elementos a destacar:
- Todavía no estamos tomando en cuenta ni el tamaño del paquete ni el del contenedor y, si vamos a eso, ni siquiera si tenemos contenedores. Pero es que tampoco tenemos ningún test que nos fuerce a implementar algo más. Ya llegará el momento.
- Hemos definido lo que esperamos de un objeto que representa la colección de contenedores disponible. Por ahora, es una abstracción que tendremos que especificar del mismo modo que hicimos en el artículo anterior con
PackageQueue
. Sabemos queContainers
tendrá que exponer un métodoavailable
que nos devolverá el contenedor que debemos usar.
Lentitud ahora, rapidez después
Puede que te haya llamado la atención que vamos avanzando bastante despacio, en pasos muy pequeños y que no estamos intentando resolver muchas circunstancias del problema que son obvias desde el principio, como lo que acabo de mencionar sobre el tamaño de paquetes y contenedores.
Esto es totalmente intencional. Se dice que la arquitectura de software consiste en la toma de decisiones que son muy caras de deshacer. Por tanto, estoy intentando avanzar de tal modo que pueda corregir mi rumbo con facilidad.
Tiene que ver con el llamado síndrome del coste hundido (sunken cost, aunque yo prefiero traducirlo como coste sumergido). Este fenómeno se da cuando evitamos aplicar cambios necesarios en una estrategia porque hemos invertido mucho esfuerzo en desarrollarla. Este coste sumergido, una inversión de tiempo, esfuerzo y dinero, nos bloquea cuando tenemos que decidir si seguir por esa línea o reconducir nuestro proyecto.
En desarrollo de software hablamos del principio YAGNI1 (no lo vas a necesitar) que básicamente aboga por concentrarnos única y exclusivamente en la tarea más necesaria ahora mismo, sin tratar de anticiparnos a requisitos por venir. Incluso cuando esos requisitos son esperables. Esto nos evita casarnos con decisiones difíciles de revertir.
En resumen, avanzar ahora lentamente, nos permitirá adquirir velocidad de desarrollo en el futuro.
Retomando el desarrollo
Ahora que los tests unitarios de AvailableContainerHandler
pasan, volvemos a ejecutar el escenario. El escenario fallará debido a los cambios que hemos aplicado.
ArgumentError: wrong number of arguments (given 0, expected 1)
/Users/frankie/Projects/storage/lib/app/for_registering_packages/available_container/available_container_handler.rb:6:in `initialize'
Necesitamos introducir un objeto Containers
para pasarlo a AvailableContainerHandler
en su construcción. Algo así, teniendo en cuenta que Containers
es una abstracción y que necesitamos una implementación del adaptador que podamos usar en tests:
Then("first available container is located") do
containers = InMemoryContainers.new
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new(containers)
response = available_container_handler.handle(available_container)
@container = response.container
end
Y llegamos a este punto, en el que ejecutar el escenario nos dice que:
NameError: uninitialized constant InMemoryContainers
InMemoryContainers
es un adaptador y debería implementar la interfaz Containers
que aún no hemos definido formalmente. En otros lenguajes habríamos tenido que declararla para poder crear su doble de test. En Ruby no es necesario, porque incluso podríamos escribir un adapter válido con tal de que pase el duck typing2, pero nos interesa hacerlo explícito. Además, al hacerlo en forma de especificación, nos garantizamos no solo que tiene los métodos necesarios, sino que exhibe el comportamiento necesario.
Así que hagámoslo, primero definiendo lo más básico:
shared_examples "a Containers" do
it { is_expected.to respond_to(:available) }
before do
@containers = described_class.new
end
describe ".available" do
it "returns a container with enough space" do
container = @containers.available
expect(container).to be_a(Container)
end
end
end
Para hacer pasar esta especificación nos basta con una implementación bastante fake:
class InMemoryContainers
def available
Container.new
end
end
Implementación que es suficiente para superar el segundo paso del escenario.
Scenario: There is space for allocating package # features/register_packages.feature:9
When Merry registers a package # features/step_definitions/steps.rb:12
Then first available container is located # features/step_definitions/steps.rb:20
And he puts the package into it # features/step_definitions/steps.rb:28
undefined method `handle' for #<StorePackageHandler:0x00000001060175c8>
store_package_handler.handle(store_package)
^^^^^^^ (NoMethodError)
./features/step_definitions/steps.rb:31:in `"he puts the package into it"'
features/register_packages.feature:12:in `he puts the package into it'
Puesto que es suficiente, avanzamos al siguiente paso y a lo que nos indica el test: tenemos que implementar StorePackageHandler
.
Para este último paso vamos a necesitar acceder a la cola de paquetes PackageQueue
que usamos en el primer paso del escenario y probablemente también a la colección de contenedores Containers
, al menos para asegurarnos de que guardamos el contenedor con el paquete asignado.
Si estás pensando un poco por adelantado, es posible que ya estés considerando problemas como ¿qué ocurre si entra un nuevo paquete y se le asigna el mismo contenedor antes de guardar el anterior? Y cosas por el estilo. Como he dicho, vamos a ir centrándonos en el problema actual. Una vez resuelto, usaremos nuevos escenarios y tests para cuestionar la solución que tengamos hasta ahora y así cubrir todo ese abanico de casos.
Para desarrollar StorePackageHandler
tenemos que ir al nivel unitario otra vez. Esta es mi primera versión de su especificación:
RSpec.describe StorePackageHandler do
before do
# Do nothing
end
after do
# Do nothing
end
context "Storing in container" do
it "should store package in provided container" do
package_queue = double("PackageQueue")
containers = double("Containers")
package = Package.new("locator")
allow(package_queue).to receive(:get).and_return(package)
expect(containers).to receive(:update) do |c|
expect(c.contains?("locator")).to be_truthy
end
handler = StorePackageHandler.new(package_queue, containers)
command = StorePackage.new(Container.new)
handler.handle(command)
end
end
end
Y aquí la implementación básica:
class StorePackageHandler
def initialize(package_queue, containers)
@package_queue = package_queue
@containers = containers
end
def handle(store_package)
package = @package_queue.get
container = store_package.container
container.store(package)
@containers.update(container)
end
end
Al re-implementar los pasos del escenario, vemos que nos tenemos algún error, ya que hemos introducido nuevos métodos en algunas interfaces:
Given("Merry registers a package") do
@memory_package_queue = InMemoryPackageQueue.new
@locator = "some-locator"
register_package = RegisterPackage.new @locator
register_package_handler = RegisterPackageHandler.new @memory_package_queue
register_package_handler.handle(register_package)
end
Then("first available container is located") do
@containers = InMemoryContainers.new
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new(@containers)
response = available_container_handler.handle(available_container)
@container = response.container
end
Then("he puts the package into it") do
store_package = StorePackage.new(@container)
store_package_handler = StorePackageHandler.new(@memory_package_queue, @containers)
store_package_handler.handle(store_package)
expect(@container.contains?(@locator)).to be_truthy
end
Por ejemplo:
Scenario: There is space for allocating package # features/register_packages.feature:9
When Merry registers a package # features/step_definitions/steps.rb:12
Then first available container is located # features/step_definitions/steps.rb:20
And he puts the package into it # features/step_definitions/steps.rb:28
undefined method `get' for #<InMemoryPackageQueue:0x000000010e48cb28 @packages=[#<Package:0x000000010e48c7b8 @locator="some-locator">]>
package = @package_queue.get
^^^^
Did you mean? gem (NoMethodError)
Esto supone cambiar la especificación:
shared_examples "a PackageQueue" do
it { is_expected.to respond_to(:put).with(1).argument }
before do
@queue = described_class.new
end
describe ".put" do
it "accepts packages" do
a_package = Package.register("locator")
@queue.put(a_package)
expect(@queue).to include(a_package)
end
end
describe ".get" do
before do
@a_package = Package.register("locator")
@queue.put(@a_package)
end
it "gives first package" do
recovered = @queue.get
expect(recovered).to be(@a_package)
end
it "removes package from queue" do
@queue.get
expect { @queue.get }.to raise_error(NoMorePackages)
end
end
end
La cual nos guiará en la implementación de InMemoryPackageQueue
:
class InMemoryPackageQueue
def initialize
@packages = []
end
def put(package)
@packages.unshift(package)
end
def get
if @packages.length < 1
raise NoMorePackages
end
@packages.pop
end
def include?(package)
@packages.include?(package)
end
end
Con esta versión podemos avanzar un poquito más en el escenario. Containers
necesita un método update
, que es un nombre horroroso, pero que voy a usar mientras no se me ocurre nada mejor.
Scenario: There is space for allocating package # features/register_packages.feature:9
When Merry registers a package # features/step_definitions/steps.rb:12
Then first available container is located # features/step_definitions/steps.rb:20
And he puts the package into it # features/step_definitions/steps.rb:28
undefined method `update' for #<InMemoryContainers:0x000000010c4546f8>
@containers.update(container)
^^^^^^^ (NoMethodError)
Así que tenemos que hacer el mismo proceso. Añadir una especificación para el método update
e implementarla en el adapter que estamos usando.
require "rspec"
require_relative "../domain/container"
require_relative "../domain/package"
shared_examples "a Containers" do
it { is_expected.to respond_to(:available) }
before do
@containers = described_class.new
end
describe ".available" do
it "returns a container with enough space" do
container = @containers.available
expect(container).to be_a(Container)
end
end
describe ".update" do
it "updates container info" do
container = Container.new
container.store(Package.new("locator"))
@containers.update(container)
recovered = @containers.available
expect(recovered.contains?("locator")).to be_truthy
end
end
end
class InMemoryContainers
def initialize
@container = Container.new
end
def available
@container
end
def update(container)
@container = container
end
end
Y con estos cambios el escenario ya pasa completamente. Aunque todavía es muy limitada y muy simple, ya tenemos la capacidad de aceptar paquetes.
La implementación de InMemoryContainers
solo nos vale para aquellos ejemplos en los que el contenedor admita nuevos paquetes, pero tendríamos que hacerla algo más inteligente, devolviendo el contenedor únicamente si puede admitir más paquetes.
Puedes ver el código hasta este punto en el repositorio.
Consideraciones hasta el momento
Una de las ventajas de Arquitectura Hexagonal es precisamente separar la lógica de la aplicación en sí, de la de los adaptadores. Esto facilita el desarrollo del modelo de dominio, ya que nos podemos olvidar de todos los condicionantes que nos imponen las distintas tecnologías específicas de los adaptadores.
Al relatar el desarrollo de esta forma en el artículo, el proceso puede parecer lento. Sin embargo, la verdad es que en tiempo real transcurre bastante rápido. Gracias a que hemos rebanado muy finamente la funcionalidad, la mayor parte de las implementaciones que hemos tenido que hacer hasta ahora son bastante obvias. Si bien es cierto que aún queda trabajo por hacer hasta que el sistema pueda ponerse en producción, incluso considerando solo la parte del registro de paquetes.
El trabajo a continuación consiste en introducir las prestaciones que necesitamos ayudándonos con nuevos escenarios de aceptación y seguir el doble ciclo outside-in hasta implementarlos.
Nuevos escenarios
Para continuar voy a introducir este escenario en el que definimos que si no hay espacio para guardar un paquete, quede en la cola de espera:
Scenario: There is no enough space for allocating package
Given no container with enough space
When Merry registers a package
Then package stays in queue
Y defino los siguientes pasos:
# There is no enough space for allocating package
Given("no container with enough space") do
@containers = InMemoryContainers.new
full_container = FullContainer.new
@containers.update(full_container)
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new(@containers)
response = available_container_handler.handle(available_container)
@container = response.container
expect(@container).to be_nil
end
Then("package stays in queue") do
expect(@memory_package_queue.contains?(@locator)).to be_truthy
end
Para resolver el problema tenemos la opción de poder definir el contenedor con el que iniciamos InMemoryContainers
, bien sea a través de su constructora o del método update
. Pero también tenemos que conseguir que un Container
pueda estar lleno, cosa que no es posible todavía. Por el momento, no hemos preparado ningún escenario en el que se haga mención explícita de los tamaños de los paquetes o las capacidades de los contenedores. Pero la verdad es que, de momento, todavía no quiero hacerlo.
Lo que hacemos aquí es aplicar un patrón Null Object y diseñar un tipo de contenedor que siempre está lleno, por lo que siempre fallará al intentar guardar algo en él. Como era de esperar, si ejecutamos el escenario nos pedirá implementar FullContainer
.
RSpec.describe Container do
context "Storing packages" do
it "fails when is full" do
container = FullContainer.new
package = Package.register("new")
expect { container.store(package) }.to raise_error(NoSpaceInContainer)
end
end
end
La implementación de FullContainer
es sencilla: simplemente extendemos Container
y sobreescribimos el método store
.
class FullContainer < Container
def store(package)
raise NoSpaceInContainer.new
end
end
Una vez aplicados estos cambios, al ejecutar el escenario, falla porque AvailableContainerHandler
no debería devolver ningún contenedor. Esta lógica en último término está a cargo de Containers
, implementado por InMemoryContainers
, responsable de decirnos qué contenedor está disponible.
En la implementación fake, simplemente nos devuelve el único contenedor que tiene hasta el momento, sin más, pero necesitamos que le pregunte el ese contenedor si tiene espacio para el nuevo paquete. En resumen: Container tiene que ser capaz de decirnos si admite más paquetes o no. Y de momento nos basta con que un contenedor normal nos diga que sí, y el contenedor lleno, nos diga que no.
Vamos a cambiar la especificación de Container
para implementar esto.
RSpec.describe "Container" do
context "Space available" do
it "should be available if empty" do
expect(Container.new.available?).to be_truthy
end
it "should be not available if full" do
expect(FullContainer.new.available?).to be_falsey
end
end
context "Storing packages" do
it "fails when is full" do
container = FullContainer.new
package = Package.register("new")
expect { container.store(package) }.to raise_error(NoSpaceInContainer)
end
end
end
De momento, es bastante fácil:
class Container
def initialize
@packages = []
end
def contains?(locator)
true
end
def available?
true
end
def store(package)
@packages.append(package)
end
end
class FullContainer < Container
def store(package)
raise NoSpaceInContainer.new
end
def available?
false
end
end
Ahora tenemos que hacer que InMemoryContainers
le pregunte a su único contenedor si tiene espacio disponible3.
Para ello, modificamos también la especificación:
shared_examples "a Containers" do
it { is_expected.to respond_to(:available) }
before do
@containers = described_class.new
end
describe ".available" do
it "should return a container with enough space" do
container = @containers.available
expect(container).to be_a(Container)
end
it "should return nil if no container with available space" do
@containers.update(FullContainer.new)
container = @containers.available
expect(container).to be_nil
end
end
describe ".update" do
it "updates container info" do
container = Container.new
container.store(Package.new("locator"))
@containers.update(container)
recovered = @containers.available
expect(recovered.contains?("locator")).to be_truthy
end
end
end
Y esto debería servir por el momento:
class InMemoryContainers
def initialize
@container = Container.new
end
def available
return @container if @container.available?
nil
end
def update(container)
@container = container
end
end
Una vez que hemos hecho pasar todas las especificaciones en el nivel unitario, volvemos a ejecutar el escenario. Este nos pide implementar un método contains?
en PackageQueue
para poder verificar que el paquete sigue ahí. Sin embargo, en vez de introducir un método para el test, quizá podríamos simplemente usar lo que tenemos y extraer el paquete que debería estar ahí. Así que cambiemos la definición del escenario:
Then("package stays in queue") do
recovered = @memory_package_queue.get
expect(recovered.locator).to eq(@locator)
end
Esto supone añadir un attr_reader
a Package
(el equivalente de un getter
en Ruby), pero hace su función y el escenario pasa completamente.
Hay que tener en cuenta que cuando la aplicación esté en producción no se ejecuta automáticamente la acción de guardar el paquete: primero se obtiene el contenedor y luego la persona encargada ejecuta la acción física de guardar el paquete y la registra, lo que equivale a ejecutar la acción de guardar. Si no hay contenedor disponible, simplemente lo deja en la cola.
El segundo escenario queda así implementado. Nuestros siguientes pasos nos deberían llevar a considerar los tamaños de paquetes y contenedores, a fin de completar la definición de este puerto.
Este es el último commit hasta el momento.
Refinando el puerto
Cuando comenzamos a diseñar un software no siempre podemos tener todos los detalles desde el primer momento. Sin embargo, en lugar de aplicar un método waterfall y tratar de tomar en consideración todos los requisitos imaginables, podemos permitirnos empezar con una idea más grosera y descubrir los detalles a medida que vamos construyendo. Al contrario que en la ingeniería física, la ingeniería de software nos permite avanzar de una manera más tentativa, comenzando con implementaciones muy sencillas que actúan a modo de prototipos, pero que pueden evolucionar e incorporar cambios. Esta flexibilidad la podemos, y debemos, aprovechar para comenzar los desarrollos incluso sin conocer completamente el dominio, tan solo lo necesario para poder entregar un mínimo de valor que marque la diferencia entre usar o no usar el software.
Usando este enfoque, hemos establecido las líneas maestras y la estructura de nuestra aplicación, definiendo los flujos de la acción de registrar paquetes. Ahora viene el momento de fijarnos en los detalles que, en nuestro ejemplo, consisten en:
- Tener en consideración el tamaño de los paquetes y los contenedores para determinar la disponibilidad de espacio y decidir el contenedor en el que guardar cada paquete
- Configurar varios contenedores y decidir cuál de ellos es el adecuado en cada registro
Para empezar, vamos a seguir considerando un solo contenedor, pero esta vez definiendo su capacidad. Veamos si podemos expresarlo en un escenario:
Scenario: Having a container with capacity
Given container with capacity 4 that is empty
When Merry registers a package of size 2
Then package is allocated in container
And he puts the package into it
Si lo examinamos con detenimiento este escenario es exactamente el mismo que el primero, con la diferencia de que hemos hecho explícitos la capacidad del contenedor y el tamaño del paquete. ¿Podría ser más interesante probar otro escenario? Es posible, pero también supondría algo más de complejidad, así que vamos a intentarlo con este.
Pero hay otra cosa que llama la atención: el escenario suena poco natural. Los tamaños expresados con números hacen que sea más difícil entender. Casi podemos apostar a que en la vida real nos referiríamos a los distintos tamaños como “pequeño”, “mediano” y “grande”. Si reescribimos el escenario de esta forma creo que queda más claro lo que expresa4:
Scenario: Having a container with capacity
Given an empty "small" container
When Merry registers a "medium" size package
Then package is allocated in container
And he puts the package into it
Es conveniente documentar esto y podemos hacerlo en el mismo documento, que quedaría así:
Feature: Registering packages
Packages are registered and assigned to a container where they will be stored
Rules:
- Packages can have sizes of 1, 2 or 3 volumes, so:
* Small = 1 vol
* Medium = 2 vols
* Large = 3 vols
- Containers can have capacity for 4, 6 or 8 volumes, so:
* Small = 4 vol
* Medium = 6 vol
* Large = 8 vol
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package
When Merry registers a package
Then first available container is located
And he puts the package into it
Scenario: There is no enough space for allocating package
Given no container with enough space
When Merry registers a package
Then package stays in queue
Scenario: Having a container with capacity
Given an empty "small" container
When Merry registers a "small" size package
Then package is allocated in container
And he puts the package into it
A mí, el escenario ya me está empezando a sugerir value objects como PackageSize
y ContainerCapacity
. En ambos casos representan conceptos que son importantes para este dominio y todo apunta a que tendrán comportamiento propio.
Pero no nos adelantemos. Definir los pasos de este escenario nos va a ayudar a perfilar el puerto “for registering packages” al incorporar datos que deben intercambiarse en esa conversación. Vamos a ver una posible implementación:
# Having a container with capacity
#
Given("an empty {string} container") do |capacity|
@containers = InMemoryContainers.new
@small_container = Container.of_capacity(capacity)
@containers.update(@small_container)
end
When("Merry registers a {string} size package") do |size|
@memory_package_queue = InMemoryPackageQueue.new
@locator = "some-locator"
register_package = RegisterPackage.new(@locator, size)
register_package_handler = RegisterPackageHandler.new @memory_package_queue
register_package_handler.handle(register_package)
end
Then("package is allocated in container") do
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new(@containers)
response = available_container_handler.handle(available_container)
@container = response.container
expect(@container).to be(@small_container)
end
Para que este escenario se pueda ejecutar necesitamos introducir un método factoría (Container.of_capacity
), así como añadir parámetros a RegisterPackage
, y también al constructor de Package
. Vamos allá. Empiezo definiendo una especificación:
RSpec.describe "Container" do
context "Container instantiation" do
it "should prepare container of desired capacity" do
small = Container.of_capacity("small")
expect(small.capacity).to eq(Capacity.new(4))
end
end
# Removed code
end
Y para implementarla introduzco varias clases nuevas, incluyendo un value object para representar Capacity
.
class Container
def initialize
@packages = []
end
def self.of_capacity(capacity)
return SmallContainer.new if capacity == "small"
end
def capacity
Capacity.new(4)
end
def contains?(locator)
true
end
def available?
true
end
def store(package)
@packages.append(package)
end
end
require_relative "full_container"
require_relative "small_container"
He decidido modelar los tamaños de los contenedores de esta forma. Por ahora solo necesito el pequeño.
class SmallContainer < Container
def capacity
Capacity.new(4)
end
end
Y Capacity
que tiene especial interés por la forma en que implementamos propiedad de igualdad por valor.
class Capacity
attr_reader :capacity
def initialize(capacity)
@capacity = capacity
end
def ==(other)
@capacity == other.capacity
end
end
Una vez que las especificaciones pasan, ejecuto el escenario de nuevo. Lo siguiente que me pide es permitir el parámetro size
en RegisterPackage
y modificar sus usos.
class RegisterPackage
attr_reader :locator, :size
def initialize(locator, size)
@locator = locator
@size = size
end
end
Una vez añadida esta modificación, el escenario pasa. Esto ocurre en parte por lo que decíamos antes: es prácticamente el mismo escenario que el primero, por lo que realmente no cuestiona la implementación existente. Para ello, necesitaríamos introducir un escenario en el que un contenedor lleno, o casi lleno, no permita guardar un paquete grande.
Pero ya que todos los tests están en verde, podríamos refactorizar para prepararnos para este cambio. Package.register
podría aceptar el parámetro size
, por ejemplo. Aquí está, aunque todavía no lo usamos:
class Package
attr_reader :locator
def initialize(locator)
@locator = locator
end
def self.register(locator, size)
Package.new(locator)
end
def allocated?
false
end
end
Mi idea es que register actúe igual que Container.of_capacity
y me devuelva un derivado de Package
en función del tamaño.
class Package
attr_reader :locator
def initialize(locator)
@locator = locator
end
def self.register(locator, size)
return SmallPackage.new(locator) if size == "small"
end
def allocated?
false
end
def size
PackageSize.new("small")
end
end
require_relative "small_package"
class SmallPackage < Package
def size
PackageSize.new("small")
end
end
Y PackageSize
.
class PackageSize
attr_reader :size
def initialize(size)
@size = size
end
def ==(other)
@size == other.size
end
end
Para hacer avanzar el desarrollo voy a necesitar un nuevo escenario que fuerce cambios necesarios en Container
. Particularmente me interesa que Container.available?
responda en función del espacio real disponible, para lo que tiene que sumar el espacio ya ocupado por los paquetes que pueda contener. Creo que un buen escenario para esto sería tener un contenedor parcialmente ocupado y tratar de registrar un paquete que no cabe, el cual quedará en la cola.
Este escenario podría servir. Un contenedor pequeño que ya tenga un paquete grande no tiene espacio para otro paquete grande. No queremos probar el caso del paquete pequeño porque sabemos que cabe y ese escenario no cuestiona la implementación actual.
Scenario: Container with packages in it and not enough free space
Given an empty "small" container
And a "large" package stored
When Merry registers a "large" size package
Then package stays in queue
Añado la siguiente definición:
# Container with packages in it and not enough free space
Given("a {string} package stored") do |size|
package = Package.register("large-pkg", size)
@small_container.store(package)
@containers.update(@small_container)
end
Ejecuto el escenario y falla con este error. Posiblemente, el primer problema es que Package.register
no sabe instanciar un paquete de tamaño grande.
NoMethodError: undefined method `locator' for nil:NilClass
expect(recovered.locator).to eq(@locator)
^^^^^^^^
Soluciono esto y el escenario pasa. Sin embargo, me doy cuenta de que el escenario no asegura que no se asigna ningún contenedor, por lo que el resultado no es concluyente. Por otro lado, esto mismo ocurre en otro escenario anterior y creo que no lo tengo bien expresado. Así que cambio este último escenario por:
Scenario: Container with packages in it and not enough free space
Given an empty "small" container
And a "large" package stored
When Merry registers a "large" size package
Then there is no available container
Then package stays in queue
Y hago una modificación similar en el segundo escenario:
Scenario: There is no enough space for allocating package
Given no container with enough space
When Merry registers a package
Then there is no available container
And package stays in queue
La definición del paso es sencilla porque, de hecho, ya la tenía, pero en el lugar equivocado:
Then("there is no available container") do
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new(@containers)
response = available_container_handler.handle(available_container)
@container = response.container
expect(@container).to be_nil
end
Con este cambio, el escenario recién introducido falla, que es justo lo que necesitaba, y los demás escenarios siguen pasando.
El escenario no pasa porque el sistema encuentra un contenedor disponible, algo que ocurre porque la implementación es fake y es justo lo que quería hacer evolucionar. Container.available?
tiene que responder teniendo en cuenta los paquetes que ya tenga almacenados. Pero también tiene que tener en cuenta el tamaño del paquete. Básicamente, un contenedor tiene espacio si:
capacidad - espacio usado > tamaño del paquete
Este es un tipo de problema perfecto para desarrollarlo mediante TDD o especificación mediante ejemplos. Durante este desarrollo hemos visto varios casos en que podíamos establecer algunos comportamientos usando solo el test de aceptación (los escenarios). Sin embargo, muchas veces el comportamiento se establece mejor usando tests unitarios, con los que probar más ejemplos de una forma más ágil.
Ahora bien, el problema es que hasta ahora Container.available?
no tenía en cuenta el tamaño del paquete entrante. La flexibilidad de Ruby me permitirá que eso sea opcional. En Java, por ejemplo, nos bastaría con sobrecargar el método.
RSpec.describe "Container" do
context "Container instantiation" do
it "should prepare container of desired capacity" do
small = Container.of_capacity("small")
expect(small.capacity).to eq(Capacity.new(4))
end
end
context "Space available" do
it "should be available if empty" do
expect(Container.new.available?).to be_truthy
end
it "should be not available if full" do
expect(FullContainer.new.available?).to be_falsey
end
it "should be partially available with only one package" do
container = Container.of_capacity("small")
container.store(LargePackage.new("locator-large"))
package = SmallPackage.new("locator-small")
expect(container.available?(package)).to be_truthy
end
end
# Removed code
end
El problema es que nos basta aceptar el parámetro para que el nuevo test pase, por tanto, necesitamos probar otra cosa. Lo mejor es provocar que no haya espacio suficiente y así forzar un cambio en la implementación. Este sería el test que queremos:
it "should not be available for second large package" do
container = Container.of_capacity("small")
container.store(LargePackage.new("locator-large"))
package = LargePackage.new("other_large")
expect(container.available?(package)).to be_falsey
end
Para hacer pasar este test hago varios cambios, no solo en Container
, sino en Capacity
o PackageSize
. Container
queda así, por el momento:
# frozen_string_literal: true
class Container
def initialize
@packages = []
end
def self.of_capacity(capacity)
return SmallContainer.new if capacity == "small"
end
def contains?(locator)
true
end
def capacity
Capacity.new(4)
end
def available?(package = nil)
remaining = capacity.subtract(allocated)
return remaining.not_full? if package.nil?
remaining.enough_for?(package)
end
def store(package)
@packages.append(package)
end
private
def allocated
alloc = Capacity.new(0)
@packages.each do |package|
alloc = alloc.add_size(package)
end
alloc
end
end
require_relative "full_container"
require_relative "small_container"
Puedes ver los cambios en este commit.
Para continuar y hacer que el escenario pase, tenemos que hacer que el servicio AvailableContainer
sepa también que tiene que tener en cuenta el tamaño del paquete. ¿Qué paquete? Pues el que acabamos de registrar. ¡Ups! Podríamos obtenerlo fácilmente mirando la cola, pero ya intuimos los problemas que se nos vienen: ¿qué pasa si mientras tanto se añade otro paquete a la cola de espera? ¿Cómo garantizo que cada paquete es asignado al contenedor elegido? ¿Cómo se reserva el espacio en el contenedor para que no se ofrezca como disponible a otros paquetes?
Pero el desarrollo iterativo nos permite gestionar estas preguntas sin agobiarnos mucho. En los escenarios actuales solo tenemos que gestionar un paquete y no tenemos el peligro de que se mezcle con otro. Eso nos permite validar el concepto general, construir algo que funcione y luego abordar los requerimientos uno a uno. Esas preguntas se tienen que convertir en tests.
De momento, esta es la solución que he encontrado para hacer pasar el escenario que nos quedaba.
class AvailableContainerHandler
def initialize(containers, queue)
@containers = containers
@queue = queue
end
def handle(available_container)
package = @queue.get
available = @containers.available package
@queue.put package
AvailableContainerResponse.new(available)
end
end
De nuevo, esto conlleva hacer algunos arreglos en diversas partes del código, hasta que todos los escenarios y especificaciones vuelven a pasar. Esto me lleva a pensar que puedo tener un poco de Shotgun Surgery, que es lo que ocurre cuando para hacer un cambio necesitas tocar muchas partes del código. Lo cierto es que sobre todo son tests, algo que probablemente se puede solucionar con algunos refactors y uso del patrón Object Mother, para evitar las consecuencias de que las signaturas todavía sean inestables.
Y hasta aquí hemos llegado por ahora
Conclusiones
En esta entrega no hemos hablado mucho de arquitectura hexagonal, pero hemos avanzado en el diseño de uno de los puertos de la aplicación. Las interfaces son todavía inestables y estamos avanzando en pasos muy pequeños. La parte positiva es que la idea básica está probada y parece viable. En el lado menos positivo, hemos detectado limitaciones, por lo que nos queda bastante por hacer.
Sin embargo, creo que es bueno que lo hecho hasta ahora haya permitido obtener este feedback. Como hemos avanzado relativamente poco, se podría decir que también hemos invertido poco y corregir la dirección del desarrollo es todavía bastante barato.
-
You ain’t gonna need it: no lo vas a necesitar ↩
-
Por si no lo sabías ya, duck typing es una forma de tipado en la que consideramos que un objeto cumple una interfaz, si responde a los mensajes que son aceptables para esa interfaz. El nombre viene del dicho “si camina como un pato, nada como un pato y grazna como un pato, es que es un pato”. ↩
-
en un futuro próximo extenderemos esto a varios contenedores. ↩
-
Cucumber detecta los números como parámetros de cada paso. Los textos entre comillas también se toman como parámetros. ↩