Las tres leyes de TDD

por Fran Iglesias

Este es un artículo de introducción a Test Driven Development a partir de las “tres leyes” y cómo estas restricciones generan un proceso poderoso para construir software sólido.

He aquí las reglas de TDD descritas por Robert C. Martin:

  • No se permite escribir ningún código de producción a menos que haga pasar un test unitario que falle
  • No se permite escribir más de un test unitario que sea suficiente para fallar; y los errores de compilación son fallos.
  • No se permite escribir más código de producción del que sea necesario para hacer pasar un test unitario que falle

Las tres leyes son lo que hace diferente TDD de simplemente escribir tests antes que el código.

Estas leyes imponen una serie de restricciones cuyo objetivo es forzarnos a seguir un determinado orden y ritmo de trabajo. Definen una serie de condiciones que, si se cumplen, generan un ciclo y guían nuestra toma de decisiones. Entender cómo funcionan, nos ayudará a aprovechar al máximo la capacidad de TDD para ayudarnos a generar código de calidad y mantenible.

Estas leyes se tienen que cumplir todas a la vez porque funcionan juntas.

Las leyes en detalle

Para explicar la aplicación de las leyes de TDD usaré un ejemplo relativamente sencillo.

Imaginemos que en nuestra empresa el departamento de marketing ha diseñado una campaña en la que se repartirán unos códigos de promoción que aportan un determinado descuento cuando se aplican a una compra. Este descuento es una cantidad fija (por ejemplo, 10 €) y se aplica cuando el importe de la compra es igual o superior a una cantidad que propondrá negocio en cada caso (por ejemplo, 50 €).

El código, que una cadena de exactamente 6 letras, y que se aplicará introduciéndolo en el formulario de compra, es el mismo para todos los potenciales clientes de una campaña. Es decir, se usará muchas veces, aunque cada cliente solo lo podrá aplicar solo una vez. Después de discutir cómo vamos a implementar esta campaña decidimos que la información del código de promoción se encapsulará en una clase (Promocode) que será capaz de decirnos si es aplicable a una compra con un importe determinado y cuál es el resultado de aplicarlo a ese importe.

Una vez definida la tarea vamos a ver cómo la desarrollamos aplicando TDD y sus leyes:

No se permite escribir ningún código de producción a menos que haga pasar un test unitario que falle

La primera ley nos dice que no podemos escribir código de producción si no hace pasar un test unitario existente que actualmente está fallando. Esto implica lo siguiente:

  • Tiene que existir un test que describe un aspecto nuevo del comportamiento de la unidad que estamos desarrollando.
  • Este test tiene que fallar porque en el código de producción no existe nada que lo haga pasar.

En resumen, la primera ley nos fuerza a escribir un test que defina el comportamiento que vamos a implementar en la unidad de software que estamos desarrollando antes de plantearnos cómo hacerlo.

Ahora bien, ¿cómo tiene que ser el test que escribamos?

No se permite escribir más de un test unitario que sea suficiente para fallar; y los errores de compilación son fallos.

La segunda ley nos dice que el test debe ser suficiente para fallar y que tenemos que considerar fallos los errores de compilación o su equivalente en lenguajes interpretados. Por ejemplo, entre estos errores estarían algunos tan obvios como que la clase o función no existe o no ha sido definida.

Debemos evitar la tentación de escribir un esqueleto de la clase o la función antes de escribir el primer test. Recuerda que estamos hablando de Test Driven Development. Por tanto, son los tests los que nos dicen qué código de producción escribir y no al revés.

Que el test sea suficiente para fallar quiere decir que el test ha de ser muy pequeño en diversos sentidos y es algo que al principio resulta bastante difícil de definir. Con frecuencia se habla del test “más sencillo”, del caso más simple, pero no es exactamente así.

¿Qué condiciones tendría que reunir un test en TDD, particularmente el primero?

Pues básicamente forzarnos a escribir el mínimo código posible que se pueda ejecutar. Lo mínimo en OOP sería instanciar la clase que queremos desarrollar sin preocuparnos de más detalles, de momento.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }
}

El test variará un poco en función del lenguaje y framework de testing que estemos utilizando. En este ejemplo utilizaré PHPSpec, que es un framework para PHP con el que podemos escribir tests como especificaciones, al estilo de BDD. Aquí el subject under test (SUT) está representado por $this, que actúa como proxy de la clase que estamos desarrollando. En los entornos tipo xUnit tendremos que instanciar el SUT y tal vez hacer algún assert muy obvio sobre el mismo (del tipo no es null o es una instancia de la clase), a fin de que el test pueda fallar y pasar convenientemente.

Si lanzásemos este test veríamos que falla por razones obvias: no existe la clase Promocode en ninguna parte. El test está fallando por un problema de compilación o equivalente. Por tanto podría ser un test suficiente para fallar.

A decir verdad, la aserción es redundante una vez que hayamos escrito el código de producción, pero nos permitirá tener un test que sí puede llegar a pasar.

A lo largo del proceso veremos que este test es redundante y que podemos prescindir de él, pero no nos adelantemos. Todavía tenemos que conseguir que pase.

No se permite escribir más código de producción del que sea necesario para hacer pasar un test unitario que falle

La primera y la segunda leyes nos dicen que tenemos que escribir un test y cómo debería ser ese test. La tercera ley nos dice cómo tiene que ser el código de producción. Y la condición que nos pone es que haga pasar el test que hemos escrito.

Es muy importante entender que es el test el que nos dice qué código necesitamos implementar y, por tanto, aunque tengamos la certeza de que va a fallar porque ni siquiera tenemos un archivo con el código necesario para definir la clase Promocode, debemos ejecutar el test y esperar su mensaje de error.

Es decir: tenemos que ver que el test, efectivamente, falla.

Lo primero que nos dirá al tratar de ejecutarlo es que la clase no existe. En TDD eso no es un problema, sino una indicación de lo que debemos hacer: añadir un archivo con la definición de la clase. Seguramente con las herramientas del IDE podamos generar ese código de manera automática, y es aconsejable hacerlo así. Nos quedará algo parecido a esto:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
}

En este punto volvemos a ejecutar el test para comprobar si pasa de rojo a verde. En muchos lenguajes este código será suficiente. En algunos casos puedes necesitar algo más.

Si es así, y el test pasa, el primer ciclo está completo y podremos pasar al siguiente comportamiento.

Si no es así, nos fijaremos en el mensaje que nos ha mostrado el test fallido y actuaremos en consecuencia, añadiendo el código mínimo necesario para que el test, finalmente, pase y se ponga en verde.

El segundo test y las tres leyes

Cuando hemos logrado hacer pasar el primer test aplicando las tres leyes podríamos pensar que no hemos conseguido realmente nada. Ni siquiera hemos abordado los posibles parámetros que podría necesitar la clase para ser construida, ya sean datos o colaboradores en el caso de servicios o use cases.

Sin embargo, es importante ceñirse a la metodología, sobre todo en estas primeras fases. Con la práctica y la ayuda de un buen IDE el primer ciclo nos habrá llevado apenas unos pocos segundos. En esos pocos segundos hemos escrito un código, ciertamente muy pequeño, pero totalmente respaldado por un test.

Nuestro objetivo sigue siendo que los tests nos dicten qué código tenemos que escribir para implementar cada nuevo comportamiento. Como nuestro primer test ya pasó, tendríamos que escribir el segundo.

Aplicando las tres leyes, lo que viene a continuación es:

  • Escribir un nuevo test que defina un comportamiento
  • Que ese test sea el mínimo posible para obligarnos a hacer un cambio en el código de producción
  • Escribir el código de producción mínimo y suficiente que hace pasar el test

¿Cuál podría ser el próximo comportamiento que necesitamos definir? Los más relevantes son aquellos que nos dicen si el código promocional es aplicable a un importe y cuál es el resultado de aplicarlo. Veamos cómo hacer un test que nos permita iniciar el desarrollo del primero de ellos.

En este caso queremos poder preguntar a Promocode si es aplicable a un importe y queremos que nos responda que no. ¿A qué importe responderá siempre que no es aplicable independientemente del límite? Pues a un importe de cero euros o negativo (pues no queremos regalar dinero). Aquí podemos argumentar que si hay un límite el importe mínimo podría ser justamente por debajo de ese mínimo. Sin embargo, queremos hacer un test mínimo, que nos obligue a implementar el menor código posible. Introducir el concepto del importe mínimo y tenerlo en cuenta en el algoritmo supone un salto mucho más grande y todavía no hemos siquiera establecido la signatura del método.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }
}

Ya tenemos el segundo test, con lo que hemos cumplido las leyes primera y segunda. ¿Qué código de producción nos permite hacerlo pasar cumpliendo la tercera? Pues simplemente el suficiente como para que exista un método al que llamar, que acepte un parámetro y que devuelva un valor bool, que será false para poder superar el test.

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    public function isApplicableTo(float $amount): bool
    {
        return false;
    }
}

Esta implementación parece tonta, pero no lo es. Para empezar, cumple la especificación que hemos hecho del comportamiento: devolver false si el importe a considerar es cero.

Otras ventajas son más sutiles. Por un lado, nos permite posponer el trabajo con el algoritmo. Por otro, nos proporciona una pista de cómo debería ser el próximo test: tiene que ser un test que espere otra respuesta (en este caso true), lo que nos forzará a introducir un cambio en el código de producción.

Habiendo llegado a este punto, nos podríamos preguntar: ¿qué pasa si no cumplimos las tres leyes de TDD?

Violaciones de las tres leyes y sus consecuencias

Obviando la broma fácil de que no acabaremos en la cárcel o con una multa por incumplir las leyes de TDD, lo cierto es que sí tendríamos que apechugar con algunas consecuencias.

Primera ley: escribir código de producción sin tener un test

La consecuencia más inmediata es que rompemos el ciclo red-green. El código que escribimos ya no está guiado ni cubierto por tests. De hecho, si queremos tener esa parte testeada, tendremos que hacer un test a posteriori (un test de QA).

Segunda ley: escribir más que un test que falle

Esto podemos interpretarlo de dos formas: escribir varios tests o escribir un test que supone un salto de comportamiento demasiado grande.

Escribir varios tests provocaría varios problemas. Para hacerlos pasar todos necesitaríamos implementar una gran cantidad de código y la guía que nos podrían proporcionar esos mismos tests se desdibuja tanto que es como no tenerla. No contaríamos con una indicación concreta que resolver implementando nuevo código.

Escribir un único test que define un salto de comportamiento demasiado grande tendría un efecto parecido. Tendríamos que escribir demasiado código de producción de una sola vez, con lo que conlleva de inseguridad y espacio para errores.

Tercera ley: escribir más del código de producción necesario para que pase el test

Se trata quizá de la más frecuente de todas. Llega un momento en que “vemos” el algoritmo con tanta claridad que nuestro impulso es escribirlo ya y terminar el proceso. Sin embargo, esto nos puede lleva a obviar algunas situaciones. Por ejemplo, en el ciclo que acabamos de realizar podríamos haber intentado implementar ya todo el algoritmo que no sería más que comparar el importe pasado al método con el límite de importe mínimo. Pero hacer eso podría habernos llevado a olvidar el caso de que el importe fuese cero o menor, lo que una vez incorporado a la aplicación y desplegado podría llevarnos a errores en producción, incluso con pérdidas económicas.

El ciclo red-green-refactor

Hemos escrito dos tests que fallan y el código de producción necesario para hacerlos pasar, pero el código todavía no nos proporciona la funcionalidad que estamos intentando desarrollar. Es necesario crear nuevos tests que nos guíen en el proceso.

Como hemos comentado hace un rato, nuestro siguiente test tiene que forzar una respuesta distinta del código de producción. Esto es: la llamada al método isApplicableTo tiene que devolver true para forzarnos a implementar un poco más del algoritmo.

Podríamos aplicar un principio similar al que utilizamos para definir el test anterior: ¿es posible encontrar un importe para el cual el código de promoción sería siempre aplicable? El planteamiento de la tarea nos dice que el límite se establecerá en cada caso por lo que no tenemos una cifra y tendríamos que consultar en qué rango se mueve. Por el momento, solo queremos forzar al método a devolver otra respuesta, por lo que podemos imaginar un importe arbitrariamente elevado.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(500)->shouldBe(true);
    }
}

Ejecutamos el test para comprobar que no pasa. Nos dice que esperaba obtener true, pero en realidad ha devuelto false. Y estamos listos para implementar.

De nuevo, la solución pasa por hacer la implementación más simple posible. Una forma sencilla de afrontarlo es la siguiente:

En última instancia el algoritmo ha de devolver true si el amount que pasamos es mayor que el límite, por lo que podríamos imaginar que, de momento, ese límite es cero. Por tanto, podríamos implementar lo siguiente para hacer pasar el test:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    public function isApplicableTo(float $amount): bool
    {
        if ($amount > 0) {
            return true;
        }
        return false;
    }
}

Esto hace pasar el test y nos genera una situación interesante. El código ha aumentado un poco su complejidad al incorporar una condicional. No parece muy impresionante, pero las condicionales siempre nos deberían hacer plantearnos si hay alguna forma de prescindir de ellas y aplanar un poco el código. Llega el momento de refactorizar.

Ahora que tenemos tres tests y los tres están pasando (en verde) podemos examinar el código de producción que tenemos y ver si podemos mejorarlo de algún modo. Hacerlo más simple, mejor escrito, mejor organizado o más fácil de leer. En nuestro caso hay un par de refactors bastante evidentes:

El primero es que realmente no necesitamos la estructura condicional, podemos reemplazarla fácilmente por una operación booleana y devolver directamente el resultado:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    public function isApplicableTo(float $amount): bool
    {
        return $amount > 0;
    }
}

El segundo refactor es un poco más sutil. El 0 aquí representa el límite por debajo del cual no se aplica el descuento. Eso lo sabemos nosotros, pero podemos hacerlo explícito reemplazando el valor por una constante.

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private const APPLICABILITY_LIMIT = 0;

    public function isApplicableTo(float $amount): bool
    {
        return $amount > self::APPLICABILITY_LIMIT;
    }
}

Al hacer esto hemos completado un ciclo Red-Green-Refactor. En cierta forma, es una consecuencia de las leyes de TDD. Al escribir código dirigido por los test, en cualquier momento en que nos encontremos con los tests pasando tenemos garantizado el comportamiento de la unidad bajo test. El refactor es un cambio en el código que busca mejorar la organización o expresividad de éste, pero que no altera su comportamiento. La forma de asegurarnos de que el comportamiento no sufre cambios es justamente tener tests y mantenerlos pasando.

Tal como está el código ahora cualquier importe que pasemos al método isApplicableTo que no sea cero nos devolverá true. Queremos que por debajo de cierto límite arbitrario nos devuelva false. Expresado de otra manera, queremos poder configurar un límite. Teniendo en cuenta la especificación de la tarea parece claro que ese límite ha de definirse en la construcción del objeto. Por tanto, nuestro próximo test nos va a forzar a introducir un parámetro en el constructor y a utilizarlo.

Comenzaremos por este test:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(500)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit()
    {
        $this->beConstructedWith(50);
        $this->isApplicableTo(49)->shouldBe(false);
    }
}

Dependiendo del lenguaje y el framework de testing puede que ocurran cosas algo distintas. En este ejemplo, PHPSpec ha detectado que no existe el constructor (ofreciendo crearlo). Por otro lado, al introducir un parámetro en el constructor, los tests anteriores fallan con error. Tenemos que hacer un pequeño arreglo antes de proseguir. En este caso, nos deshacemos del constructor para permitir que los tests anteriores puedan pasar y refactorizamos la forma en que se construye el subject under test.

En este caso, haremos que se monte en el método let, que equivale aproximadamente a un setUp en otros frameworks. PHPSpec nos obliga a implementar el constructor, así que lo hacemos para poder seguir adelante. Primero el test:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function let()
    {
        $this->beConstructedWith(50);
    }

    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(500)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit()
    {
        $this->isApplicableTo(49)->shouldBe(false);
    }
}

Y luego el código de producción:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private const APPLICABILITY_LIMIT = 0;

    public function __construct(float $applicabilityLimit)
    {
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount > self::APPLICABILITY_LIMIT;
    }
}

De este modo, el nuevo test falla mientras los demás pasan sin problema, que es la situación en la que queríamos estar.

Para que el test pase, tenemos que poder utilizar el límite que pasamos en construcción. Y el código mínimo necesario es el siguiente:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private const APPLICABILITY_LIMIT = 0;

    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount > $this->applicabilityLimit;
    }
}

Con los tests en verde, nos deshacemos de la constante que ya no utilizamos, pues la hemos reemplazado por una propiedad de la clase:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount > $this->applicabilityLimit;
    }
}

Posiblemente te hayas dado cuenta de que tenemos un problema. Tal como está el código, si el importe es el mismo que el límite, el descuento no será aplicable, por lo que tenemos que cambiar un poco el código. Pero para eso, necesitamos hacer primero un test que demuestre que eso no está implementado y nos fuerce a hacerlo.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    public function let()
    {
        $this->beConstructedWith(50);
    }

    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(500)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit()
    {
        $this->isApplicableTo(49)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit()
    {
        $this->isApplicableTo(50)->shouldBe(true);
    }
}

El test falla, como habíamos predicho. Hacer que pase es fácil:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }
}

Hemos logrado que pase el test. Si examinamos el código del test, es fácil ver que podemos hacer unas mejoras. Los test también se pueden refactorizar.

Para empezar, el 50 es un número arbitrario. Podemos darle significado convirtiéndolo en una constante:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let()
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(500)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit()
    {
        $this->isApplicableTo(49)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit()
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }
}

Esto nos proporciona también la posibilidad de mejorar los otros tests, evitando que los ejemplos usados parezcan tan arbitrarios:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let()
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable()
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts()
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts()
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit()
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit()
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }
}

Aplicamos estos cambios y cada vez ejecutamos los tests para asegurarnos de que pasan y que no hemos cambiado su significado.

Qué significa que un test pasa nada más escribirlo

Cuando escribimos un test y pasa sin añadir código de producción puede ser por alguno de esos motivos:

  • El algoritmo que hemos escrito es lo bastante general como para cubrir todos los casos posibles: hemos terminado nuestro desarrollo
  • El ejemplo que hemos elegido no es cualitativamente diferente de otros que ya hemos usado y por lo tanto no nos fuerza a escribir código de producción. tenemos que encontrar otro ejemplo.

En el problema que estamos tratando, podemos comprobar que se cumple el primer caso. No hay una combinación de límite/importe concebible que nos permita hacer un test que falle y, por tanto, nos fuerce a escribir más código de producción. En este punto, un nuevo test nos puede servir como test de aceptación o para verificar algunos edge cases en los que podamos tener dudas.

La otra posibilidad es que el ejemplo escogido no sea representativo de un nuevo comportamiento, lo que puede venir dado por una mala definición de la tarea o bien por no haber analizado bien los posibles escenarios.

Seguir desarrollando

La clase que estamos desarrollando tiene dos comportamientos principales. Uno ya lo hemos desarrollado, la capacidad de decir si un código es aplicable. El que nos queda es hacer el cálculo del importe con el descuento, que es una simple resta.

En este punto surge una cuestión interesante: para implementar este nuevo comportamiento, ¿no sería mejor crear una nueva especificación, aunque ejercitemos la misma clase?

Tratándose de un problema muy pequeño y acotado puede que no sea necesario o incluso práctico, pero es cierto que la estructura de los tests no tiene que ser igual a la estructura del código de producción. Los tests se estructuran en torno a los comportamientos del software, con independencia de cómo estén implementados. Por tanto, tendría mucho sentido crear una nueva especificación o TestCase para desarrollar el nuevo comportamiento.

Nosotros ahora seguiremos con la misma especificación.

El nuevo comportamiento consiste en aplicar el descuento al importe. Para esto tenemos que usar el que hemos definido anteriormente. En resumen:

  • Si el descuento es aplicable, el importa final será el importe pasado menos el descuento.
  • Si no lo es, el importe final será el mismo.

Como ya sabemos, necesitamos el test más pequeño posible que falle y nos obligue a implementar código en producción. Podemos hacer algo parecido a lo que hicimos antes: un importe de 0 siempre devolverá un importe descontado de 0 porque el descuento no se aplica, sin obligarnos a implementar nada más que lo necesario para disponer del método:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shoulBe(0.0);
    }
}

Nos basta con implementar el método devolviendo el valor esperado.

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        return 0;
    }
}

A continuación, podemos ejecutar un nuevo test que esta vez nos obligue a utilizar el parámetro $amount sin tener que abordar todavía el algoritmo. Un importe para el que nunca será aplicable el descuento es cualquiera por debajo del límite de aplicabilidad.


<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT - 1)->shouldBe(self::APPLICABILITY_LIMIT - 1.0);
    }
}

Luego veremos como refactorizar el test, de momento tenemos que hacerlo pasar, cosa que es bastante sencilla:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        return $amount;
    }
}

Antes de continuar, ahora que pasan todos, hacemos un pequeño refactor del test:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }
}

Un nuevo test que falle nos debería llevar hacia el algoritmo deseado:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - 10.0);
    }
}

El código de producción mínimo para lograr pasar el test podría ser el siguiente:

<?php
declare (strict_types = 1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($amount === $this->applicabilityLimit) {
            return $amount - 10;
        }
        
        return $amount;
    }
}

Hagamos ahora que el test nos obligue a manejar los valores por encima del límite:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - 10.0);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - 10.0);
    }
}

Este test lo podemos hacer pasar con un cambio muy pequeño:

<?php

declare (strict_types=1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($amount >= $this->applicabilityLimit) {
            return $amount - 10;
        }

        return $amount;
    }
}

Echemos un vistazo a ver si podemos refactorizar. Lo más obvio es que la condicional es exactamente la misma que la del método isApplicableTo. Decidir si el importe es igual o superior al límite de aplicabilidad es un conocimiento que está en dos lugares en el código. Por tanto, podemos hacer que el método isApplicableTo sea la única fuente de verdad para ese conocimiento. Hacemos el cambio y comprobamos que el test sigue pasando:

<?php

declare (strict_types=1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;

    public function __construct(float $applicabilityLimit)
    {
        $this->applicabilityLimit = $applicabilityLimit;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - 10;
        }

        return $amount;
    }
}

Nos queda un comportamiento que implementar. Queremos que la cuantía del descuento sea configurable. Para esto necesitamos escribir un test que nos fuerce a implementar ese comportamiento, construyendo el SUT con el nuevo parámetro y esperando un importe con el descuento aplicado.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - 10.0);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - 10.0);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }
}

El test falla, de modo que podemos escribir el código de producción necesario para hacerlo pasar. De momento, lo ponemos como opcional con un valor por defecto para evitar que fallen todos los tests anteriores.

<?php

declare (strict_types=1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;
    private $discount;

    public function __construct(float $applicabilityLimit, float $discount = 10)
    {
        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Ahora, cambiamos la forma de construir el SUT en el test para usar el nuevo parámetro:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }
}

Con este cambio, deberíamos poder hacer que el parámetro $discount sea obligatorio en construcción. Hacemos el cambio y comprobamos que los tests siguen pasando todos:

<?php

declare (strict_types=1);

namespace App\Domain;

class Promocode
{
    private $applicabilityLimit;
    private $discount;

    public function __construct(float $applicabilityLimit, float $discount)
    {
        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Haber empezado por ahí… o no

He dejado para el final un comportamiento por el que quizá llevas un rato largo preguntándote. El código de promoción se identifica mediante una cadena de caracteres y no hemos hablado de ella hasta ahora.

La razón por la que he preferido dejarla para el final es que presenta un tipo de problemática un tanto particular para tratar en TDD. La clase no ejerce ningún comportamiento específico que lo ponga en juego. Lo que sí podemos hacer es asegurar que las reglas de negocio se aplican en el momento de instanciar el Promocode y que solo se instancian aquellos que usan cadenas de caracteres válidas. En otras circunstancias probablemente hubiera empezado por este comportamiento, pero creo que resulta confuso hacerlo cuando te inicias en TDD.

La regla dice que tiene que ser una cadena de caracteres (solo letras) de exactamente seis caracteres. Como no queremos que se puedan instanciar si no son correctos, esperaremos un fallo en forma de excepción. Modificar la signatura del constructor en este punto, particularmente para implementar un comportamiento que genera excepciones, va a propiciar que fallen un montón de tests.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT+1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT-1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith( self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too short promocode'))->duringInstantiation();
    }
}

Esto es debido a que vamos a empezar lanzando la excepción de forma incondicional hasta requerir un nuevo comportamiento con otro test:

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

class Promocode
{
    private $applicabilityLimit;
    private $discount;

    public function __construct(float $applicabilityLimit, float $discount)
    {
        throw new InvalidArgumentException('Too short promocode');

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

De hecho, pasa el nuevo test, pero hemos roto todos los anteriores.

Para provocar un cambio en el código, lo que miraremos a continuación es que no se puedan introducir promocodes demasiado largos.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT + 1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT - 1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too short promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_too_long_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too long promocode'))->duringInstantiation();
    }
}

Para hacer pasar este test y el anterior implementaremos que se recibe la cadena y evaluaremos su longitud.

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

use function strlen;

class Promocode
{
    private $applicabilityLimit;
    private $discount;
    private $promocode;

    public function __construct(float $applicabilityLimit, float $discount, string $promocode)
    {
        if (strlen($promocode) < 6) {
            throw new InvalidArgumentException('Too short promocode');
        }
        throw new InvalidArgumentException('Too long promocode');

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
        $this->promocode = $promocode;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Ahora pasan los dos últimos tests, pero todavía tenemos que hacer pasar todos los demás. El siguiente criterio de validación es que no puede contener otros caracteres que no sean letras, cosa definida por el siguiente test:

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT + 1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT - 1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too short promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_too_long_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'TOOLONGCODE');
        $this->shouldThrow(new InvalidArgumentException('Too long promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_bad_characters_in_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, '012345');
        $this->shouldThrow(new InvalidArgumentException('Malformed promocode'))->duringInstantiation();
    }
}

El código mínimo para pasar el test:

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

use function strlen;

class Promocode
{
    private $applicabilityLimit;
    private $discount;
    private $promocode;

    public function __construct(float $applicabilityLimit, float $discount, string $promocode)
    {
        if (strlen($promocode) < 6) {
            throw new InvalidArgumentException('Too short promocode');
        }
        if (strlen($promocode) > 6) {
            throw new InvalidArgumentException('Too long promocode');
        }
        throw new InvalidArgumentException('Malformed promocode');

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
        $this->promocode = $promocode;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Estamos más cerca. Ahora podremos introducir un código válido, por ejemplo ‘SPCTDD’, y así forzar implementar el código suficiente para pasar y, con eso, arreglar el resto de los tests construyendo el SUT con ese mismo código.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT);
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT + 1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT - 1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0);
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too short promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_too_long_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'TOOLONGCODE');
        $this->shouldThrow(new InvalidArgumentException('Too long promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_bad_characters_in_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, '012345');
        $this->shouldThrow(new InvalidArgumentException('Malformed promocode'))->duringInstantiation();
    }

    public function it_should_accept_codes_with_only_letters(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SPCTDD');
        $this->shouldNotThrow(InvalidArgumentException::class)->duringInstantiation();
    }
}

Usando regexp podemos verificar esto:

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

use function preg_match;
use function strlen;

class Promocode
{
    private $applicabilityLimit;
    private $discount;
    private $promocode;

    public function __construct(float $applicabilityLimit, float $discount, string $promocode)
    {
        if (strlen($promocode) < 6) {
            throw new InvalidArgumentException('Too short promocode');
        }
        if (strlen($promocode) > 6) {
            throw new InvalidArgumentException('Too long promocode');
        }
        if (!preg_match('/^[A-Z]{6}$/', $promocode)) {
            throw new InvalidArgumentException('Malformed promocode');
        }

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
        $this->promocode = $promocode;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Ahora que ya podemos instanciar el Promocode con un código válido, cambiaremos la instanciación del SUT para que vuelva a pasar toda la especificación.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'PRCODE');
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT + 1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT - 1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0, 'SPCTDD');
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(new InvalidArgumentException('Too short promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_too_long_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'TOOLONGCODE');
        $this->shouldThrow(new InvalidArgumentException('Too long promocode'))->duringInstantiation();
    }

    public function it_should_fail_using_bad_characters_in_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, '012345');
        $this->shouldThrow(new InvalidArgumentException('Malformed promocode'))->duringInstantiation();
    }

    public function it_should_accept_codes_with_only_letters(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SPCTDD');
        $this->shouldNotThrow(InvalidArgumentException::class)->duringInstantiation();
    }
}

Ahora que pasan todos los tests, podemos dedicarnos a refactorizar el constructor. Pero primero retocaremos un poco los tests que chequean por una excepción. Ahora mismo esperamos una excepción exacta, con el mismo mensaje. Esto ha sido útil para diferenciar los distintos casos, pero eso hace que el test y la implementación estén acoplados por un detalle muy frágil, como es un mensaje informativo de una excepción. Al hacer esto, los tests siguen pasando igualmente y nos dejan las manos libres para un refactor.

<?php

namespace spec\App\Domain;

use App\Domain\Promocode;
use InvalidArgumentException;
use PHPSpec\ObjectBehavior;

class PromocodeSpec extends ObjectBehavior
{
    private const APPLICABILITY_LIMIT = 50;
    private const DISCOUNT = 10.0;

    public function let(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'PRCODE');
    }

    public function it_is_initializable(): void
    {
        $this->shouldHaveType(Promocode::class);
    }

    public function it_should_not_be_aaplicable_to_zero_amounts(): void
    {
        $this->isApplicableTo(0)->shouldBe(false);
    }

    public function it_should_be_applicable_to_great_amounts(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT + 1)->shouldBe(true);
    }

    public function it_should_not_be_applicable_under_defined_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT - 1)->shouldBe(false);
    }

    public function it_should_be_applicable_if_amount_equals_limit(): void
    {
        $this->isApplicableTo(self::APPLICABILITY_LIMIT)->shouldBe(true);
    }

    public function it_should_not_apply_discount_to_zero_amount(): void
    {
        $this->applyDiscount(0)->shouldBe(0.0);
    }

    public function it_should_not_apply_discount_under_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT - 1.0;
        $this->applyDiscount($amount)->shouldBe($amount);
    }

    public function it_should_apply_discount_to_limit(): void
    {
        $this->applyDiscount(self::APPLICABILITY_LIMIT)->shouldBe(self::APPLICABILITY_LIMIT - self::DISCOUNT);
    }

    public function it_should_apply_discount_to_amounts_over_limit(): void
    {
        $amount = self::APPLICABILITY_LIMIT + 1;

        $this->applyDiscount($amount)->shouldBe($amount - self::DISCOUNT);
    }

    public function it_should_apply_configurable_discount(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, 5.0, 'SPCTDD');
        $amount = self::APPLICABILITY_LIMIT;
        $this->applyDiscount($amount)->shouldBe($amount - 5.0);
    }

    public function it_should_fail_using_too_short_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SHORT');
        $this->shouldThrow(InvalidArgumentException::class)->duringInstantiation();
    }

    public function it_should_fail_using_too_long_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'TOOLONGCODE');
        $this->shouldThrow(InvalidArgumentException::class)->duringInstantiation();
    }

    public function it_should_fail_using_bad_characters_in_promocodes(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, '012345');
        $this->shouldThrow(InvalidArgumentException::class)->duringInstantiation();
    }

    public function it_should_accept_codes_with_only_letters(): void
    {
        $this->beConstructedWith(self::APPLICABILITY_LIMIT, self::DISCOUNT, 'SPCTDD');
        $this->shouldNotThrow(InvalidArgumentException::class)->duringInstantiation();
    }
}

Nuestro objetivo son los varios if que hay ahora en la construcción para chequear la validez del código promocional. Podemos simplificar esto. Vamos a hacerlo por partes:

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

use function preg_match;
use function strlen;

class Promocode
{
    private $applicabilityLimit;
    private $discount;
    private $promocode;

    public function __construct(float $applicabilityLimit, float $discount, string $promocode)
    {
        if (strlen($promocode) !== 6) {
            throw new InvalidArgumentException('Too short or long promocode');
        }
        if (!preg_match('/^[A-Z]{6}$/', $promocode)) {
            throw new InvalidArgumentException('Malformed promocode');
        }

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
        $this->promocode = $promocode;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Si examinados la expresión regular, podemos ver que obliga a que la cadena tenga una longitud determinada, así que nos podemos ahorrar el chequeo anterior sin problemas:

<?php

declare (strict_types=1);

namespace App\Domain;

use InvalidArgumentException;

use function preg_match;
use function strlen;

class Promocode
{
    private $applicabilityLimit;
    private $discount;
    private $promocode;

    public function __construct(float $applicabilityLimit, float $discount, string $promocode)
    {
        if (!preg_match('/^[A-Z]{6}$/', $promocode)) {
            throw new InvalidArgumentException('Malformed promocode');
        }

        $this->applicabilityLimit = $applicabilityLimit;
        $this->discount = $discount;
        $this->promocode = $promocode;
    }

    public function isApplicableTo(float $amount): bool
    {
        return $amount >= $this->applicabilityLimit;
    }

    public function applyDiscount(float $amount): float
    {
        if ($this->isApplicableTo($amount)) {
            return $amount - $this->discount;
        }

        return $amount;
    }
}

Y con esto, hemos conseguido desarrollar nuestra clase.

Referencias

March 6, 2020

Etiquetas: testing   tdd  

Temas

good-practices

refactoring

php

testing

tdd

python

blogtober19

design-principles

design-patterns

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile