Desarrollo guiado por tests (TDD)

7.1 Qué es el TDD

Las siglas TDD (en inlgés Test-driven development), se puede traducir como "desarrollo guiado por tests". Lo que quiere significar es que el desarrollo del software debe estar guiado por los tests, o dicho de otra forma, para desarrollar o refactorizar un fragmento de código, debemos escribir los tests que verifiquen ese comportamiento antes que el código que lo implementa. El concepto Red-Green testing, viene de que, para crear la funcionalidad final, primero debemos crear un test que no estará resuelto (Red), y tras la implementación de una solución, el test deberá pasar correctamente (Green).

El desarrollo en TDD está asociado siempre al desarrollo de test unitarios, no de test funcionales o de integración. El motivo es porque, como veremos a continuación, siguiendo la metodología TDD se intenta testear el mínimo código posible que completaría una parte de la especificación del problema.

7.1.1 Pasos a seguir

  • Creación de un test.

Dada una especificación a cumplimentar, creamos un test unitario que debe cumplir la implementación a desarrollar.

  • Ejecución del test comprobando que el test falla.

Para cada creación de un nuevo test, ejecutamos el conjunto de tests y verificamos que la implementación que estamos añadiendo no está resuelta por el estado actual del código, dado que los test producen el error correspondiente.

  • Escribir código para pasar el test.

Implementamos la funcionalidad, con el mínimo código posible que pase el test. De esta forma, vamos desarrollando paso a paso una funcionalidad completa, pero con la intención de contemplar todos los escenarios posibles.

  • Ejecutar el test, comprobando que pasa el test.

Ejecutamos el conjunto de tests para ver que el código que hemos implementado se comporta de la forma esperada.

  • Refactorizar el código

Este paso es importante para, una vez estar seguros de que tenemos una solución para nuestro problema, debemos dejar el código de la forma más legible y eficiente posible.

  • Repetir

El proceso debe repetirse por cada iteración del desarrollo.

Los defensores del desarrollo por TDD defiende que siguiendo estos pasos la calidad del código mejor, la cobertura de tests está garantizada y los diseños arquitecturales más meditados. Los retractores de esta metodología rechazan la necesidad de seguir todos estos pasos, de una forma tan metódica.

7.2 Ejemplo: FizzBuzz

Para ilustrar de una forma más comprensible como sería el procedimiento, vamos a realizar la implementación de un juego llamado FizzBuzz. FizzBuzz es un juego de niños con las siguientes reglas: Un grupo de niños en un circulo comienzan a contar del uno hasta N, de forma consecutiva, es decir, el primer niño dice "uno", el segundo "dos", etc... con las excepciones de que, si el número es divisor de tres, el niño debe decir "Fizz", si el número es divisor de cinco, el niño debe decir "Buzz" y si es divisor de tres y cinco el niño debe decir "FizzBuzz!".

Para nuestro caso, dado un número devolveremos la cadena "fizzbuzz" desde el uno hasta el número indicado, sustituyendo "Fizz" por "F" y "Buzz" por "B".

Por ejemplo, nuestro FizzBuzz de 10 sería la cadena de textos "1 2 F 4 B F 7 8 F B".

Para simplificar nuestro ejemplo, solo en el último test realizaremos la refactorización que hay que realizar siguiendo los pasos TDD.

El primer test que debemos deberíamos pasar sería la cadena FizzBuzz(1), cuyo resultado debería ser "1". El test podría ser el siguiente:


class FizzBuzzTest extends PHPUnit_Framework_TestCase {

    public function setUp() {
        $this->fizzbuzz = new FizzBuzz();
    }

    public function testOne() {
        $this->assertEquals("1", $this->fizzbuzz->solve(1));
    }
}

Omitimos la devolución del error dado que en este punto, ni siquiera existe la clase FizzBuzz en nuestro repositorio de código.

Y la implementación que resuelve el test:


class FizzBuzz {

    public function solve($number) {
        return $number;
    }

}

Como se puede apreciar, para pasar este test no hemos intentado resolver el problema completo, sino que, como nos propone el procedimiento, hemos implementado el mínimo necesario para resolver el test. En este caso una solución puede ser devolver el mismo valor que el valor de entrada.

El siguiente paso será crear un nuevo test, que nos exija añadir funcionalidad a nuestro código. El test FizzBuzz(2) no pasará, dado que la mínima implementación anterior está preparada para dar el resultado requerido por la implementación. El test sería:


    public function testTwo() {
        $this->assertEquals("12", $this->fizzbuzz->solve(2));
    }

Y la respuesta obtenida por PHPUnit:


There was 1 failure:

1) FizzBuzzTest::testTwo
Failed asserting that 2 matches expected '12'.

/home/jose/workspace/MyThings/TestingBook/TddTesting/test/FizzBuzzTest.php:16

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Como esperábamos, el test no ha pasado. A partir de ahora solo mostraremos la implementación del método solve. La implementación que pasaría el tests podría ser:


    public function solve($number) {
        $str = "";
        for($i=1;$i<=$number;$i++) {
            $str.=$i;
        }

        return $str;
    }

Siguiendo el procedimiento, ahora añadiríamos un nuevo test para verificar la resolución del problema para un múltiplo de tres. El test sería:


    public function testThree() {
        $this->assertEquals("12F", $this->fizzbuzz->solve(3));
    }

Y como esperábamos una vez más el test. En la implementación anterior el código no resolvía el caso de que el número de entrada fuera múltiplo de tres. El error obtenido es:

1) FizzBuzzTest::testThree
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'12F'
+'123'

Y la implementación que pasaría el test:


    public function solve($number) {
        $str = "";
        for($i=1;$i<=$number;$i++) {
            if($i%3==0) { 
                $str.="F";
            } else {
                $str.=$i;
            }
        }

        return $str;
    }

En este paso necesitamos añadir la el resultado correcto para números múltiplos de cinco. El test sería el siguiente:


    public function testFive() {
        $this->assertEquals("12F4B", $this->fizzbuzz->solve(5));
    }

Volvemos a ejecutar los tests y obtenemos el resultado siguiente:

There was 1 failure:

1) FizzBuzzTest::testFive
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'12F4B'
+'12F45'

Y resolvemos implementación requerida por el tests modificando nuestro método "solve" de la siguiente forma:


    public function solve($number) {
        $str = "";
        for($i=1;$i<=$number;$i++) {
            if($i%3==0) {
                $str.="F";
            } elseif($i%5==0) { 
                $str.="B";
            } else {
                $str.=$i;
            }
        }

        return $str;
    }

Y en último lugar, necesitamos implementar el caso en el que un número sea múltiplo de tres y de cinco, para ello, creamos el tests para verificar el resultado para el número dieciséis.


    public function testSixteen() {
        $this->assertEquals("12F4BF78FB11F1314FB16", $this->fizzbuzz->solve(16));
    }

El error obtenido por PHPUnit, como era de esperar, es el siguiente:

Resultado del último test:

1) FizzBuzzTest::testSixteen
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'12F4BF78FB11F1314FB16'
+'12F4BF78FB11F1314F16'

Y la implementación que resuelve todos los casos anteriores podría ser:

    public function solve($number) { 
        $str = "";
        for($i=1;$i<=$number;$i++) {
            if ($i%3 == 0 && $i%5==0) {
                $str .= "FB";
            } elseif($i%3==0) { 
                $str.="F";
            } elseif($i%5==0) {
                $str.="B";
            } else {
                $str.=$i;
            }
        }

        return $str;
    }

Dado que el procedimiento TDD contempla el paso de refactorización hemos refactorizado el modoso solve de la siguiente manera:


     public function solve($number) {
        $str = "";
        for($i=1;$i<=$number;$i++) {
            $fizz = ""; 
            $buzz = "";
            if ($i%3 == 0) $fizz = "F";
            if ($i%5 == 0) $buzz = "B"; 
            if($fizz =="" and $buzz=="") {
                $str.=$i;
            } else {
                $str.=$fizz.$buzz;
            }
        }

        return $str;
    }

Tras la refactorización hemos vuelvo a ejecutar los tests y obtenido el resultado positivo que esperábamos.

Incluso si quisiéramos refactorizar nuestro método solve, para permitir que sea configurable, es decir, poder poner otros números primos como a los que se deba decir otra palabra, con nuestros conjunto de test podríamos hacerlo garantizando que el comportamiento se mantendrá para las entradas definidas. Una refactorización de este tipo podría ser de la siguiente forma:


     public function solve($number, $primes=[3=>"F",5=>"B"]) {

        $solution = "";
        for($i=1;$i<=$number; $i++) {
            $str = [];
            foreach($primes as $key => $value) {
                if($i%$key==0) {
                    $str[] = $value;
                }
            }

            if(count($str)>=1) {
                $solution.= implode("", $str);
            } else {
                $solution.= $i;
            }
        }

        return $solution;
    }

Esta implementación del método solve pasaría todos los test creados anteriormente y permitiría distintas opciones en el segundo parámetro de entrada permitiendo más flexibilidad.

Nuestro ejemplo quizás sea un poco exagerado, pero el procedimiento que define TDD es prácticamente este. Otro punto considerable es la cantidad de código de testeo que se necesita desarrollar para seguir la metodología. Para el ejemplo anterior, posiblemente utilizando el último test tendríamos una cobertura de código casi completa, con el requirimiento funcional, aunque no se habría resuelto el problema considerando todos los casos.

Para algunos desarrolladores es excesivo el tiempo que hay que emplear para desarrollar algo. Sin embargo, el nivel de detalle al que se puede llegar desarrollando la solución de un problema es muy detallada.

7.3 Ventajas de desarrollar bajo TDD

Trabajar siguiendo la metodología TDD nos puede aportar beneficios en distintos aspectos a la hora de desarrollar.

  • Introducimos el hábito de testear el código de forma automática.

  • Pensar en el test primero obliga a pensar mejor el interfaz de la aplicación. Este pensamiento en el interfaz nos ayuda a ver como la clase será usada y a mantener separada dicha interfaz de la implementación.

  • El procedimiento de tener resultados es más corto, debido al propio ciclo de TDD.

  • Siguiendo la metodología TDD se crea una detallada especificación.

  • Se minimiza el tiempo decicado de depurar el código. Si es necesaria esta depuración sería más sencilla ya que el resto de la aplicación también está testeada.

  • Siguiendo TDD somos avisados antes de qué refactorización rompió el estado verde de los tests.

  • Permite que el diseño sea más adaptable a los cambios del entendimiento del problema.

  • Se consigue un detallado nivel de test de regresión.

  • Cobertura del 90% del código casi garantizada.

Hay que destacar que trabajar con TDD no son todo ventajas. Algunas desventajas es que seguir la metodología de forma disciplinada no es fácil de aprender. Los desarrolladores que siguen esta metodología dicen que durante los primeros meses de seguirla, perdieron productividad.

7.4 Framewoks expecíficos de TDD: Phpspec

Phpspec [23] es una herramienta que puede ayudar a escribir código de forma limpia siguiendo BDD o Behavior driven development, en español, desarrollo guiado por comportamiento.

Como veremos en la siguiente sección, BDD es una técnica a nivel de historias de usuarios y a nivel de la especificación. Phpspec es usada para desarrollar los tests a nivel de especificación o SpecBDD. En realidad, no hay diferencia entre SpecBDD y TDD, por ello hemos incluido Phpspec dentro de la sección de TDD y no en la sección de BDD.

El valor añadido de usar una herramienta de especificación en lugar de una herramienta de testeo es por el lenguaje en sí. Los desarrolladores que optaron por testear su código siguiendo TDD, pronto se fijaron en la importancia de desarrollar fijándose en el comportamiento y el diseño del código. Por ello, BDD y herramientas que siguen la filosofía SpecBDD se han centrado en eliminar el vocabulario de testeo.

7.4.1 Diferencias entre SpecBDD y StoreBDD

Herramientas que basan la filosofía de StoryBDD, como Behat, que también veremos en la próxima sección, ayudan a entender y clarificar el dominio. Estas herramientas especifican las funcionalidades de forma narrativa, sus necesidades y cuales son los objetivos. Mientras tanto, con SpecBDD y herramientas como Phpscpec nos centramos en la implementación.

7.4.2 Instalación de Phpspec

Para instalar Phpspec en nuestro proyecto a través de Composer necesitamos añadir dependencia correspondiente:


{
  "require-dev":{
        "phpspec/phpspec": "~2.3"
    }
}

Después de añadir las lineas anteriores en nuestro fichero composer.json necesitamos ejecutar el comando composer install, para instalar las dependencias.

7.4.3 Trabajando con Phpspec

En esta sección vamos a desarrollar un ejemplo utilizando Phpspec. Hay que tener en cuenta que Phpspec no es solo una herramienta de testeo, sino también de desarrollo, por lo que la el uso es distinto que el que podemos dar a otros frameworks como PHPUnit.

Supongamos que estamos desarrollando una web en la que usuarios pueden tener un álbum de fotos, como podría ser Flickr.com. Para dicho sistema necesitaríamos la clase User y la clase Image. Lo primero que tendríamos que hacer siguiendo Phpspec es crear la especificación de la clase usuario. Para ello, desde nuestro terminal ejecutaríamos el siguiente comando:


bin/phpspec desc User
Specification for User created in spec.

Esta ejecución crearía la clase php/UserSpec.php con el siguiente código:


namespace spec;

use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class UserSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('User');
    }
}

Con esta clase estamos creando una especificación, es decir, un test, sobre una clase User. Hay que tener en cuenta, que en este punto todavía no hemos creado la clase User, por lo que la ejecución de los tests no pasaría. En este momento podemos ver algunas particularidades de Phpspec, por ejemplo:

  • Para Phpspec cada método público que comience por it_ es un test, en lugar del prefijo test necesario en PHPUnit o Atoum.
  • La clase a testear, en nuestro ejemplo UserSpec es una instancia del SUT User. Esto le da sentido al punto anterior, dado que para clase de especificación estamos definiendo el comportamiento, no testeando su implementación.
  • Los parámetros de entrada de cada tests o especificación (métodos it_) son mockeados directamente por el framework, como veremos más adelante.

Para continuar con el ejemplo ejecutaremos los tests desde nuestro terminal con el siguiente comando:

bin/phpspec run

Dado que la clase User no existe todavía, esta ejecución nos devolverá la siguiente pregunta:

Do you want me to create `User` for you?  [Y/n]

Después de pulsar Y e intro, la clase User.php será creada en la ruta especificada por nuestro namespace con el código:


class User
{
}

En este punto, si ejecutamos los tests de nuevo veremos que tenemos una respuesta positiva.

El siguiente paso es desarrollar la funcionalidad que permite a un usuario añadir una imagen. Para ello, en lugar de implementar el método que nos aportaría esta funcionalidad, el método addImage, crearemos un método nuevo en nuestra clase UserSpec que nos aporte dicha funcionalidad.

El código que especificaría el comportamiento de este método sería:


    function it_add_image(Image $image)
    {
        $this->addImage($image);

        $this->shouldHaveCount(1);
    }

A continuación volvemos a ejecutar los tests escribiendo en el terminal:

bin/phpspec run

Como el método addImage ni siquiera existe en nuestra clase User, esta ejecución nos presentaría la siguiente pregunta:

  Do you want me to create `User::addImage()` for you? [Y/n]

A continuación pulsamos "Y" y nuestra clase User se modificará con la definición del método que pasaría el test sin implementar, con la forma:


public function addImage($argument1)
{
    // TODO: write logic here
}

Para pasar el test, la implementación que deberíamos realizar es sencilla. El método addImage quedaría de la siguiente forma:


public function addImage($image)
{ 
    $this->images[] = $image;
}

La siguiente funcionalidad a implementar podría ser añadir más de una imagen a la vez, por ejemplo creando un método addImages dentro del la clase User que tenga como parámetro de entrada un array de imágenes.

El test podría ser:


function it_add_several_images(Image $img1, Image $img2) {
    $this->addImages([$img1, $img2]);

    $this->getImages()->shouldHaveCount(2);
}

Ejecutamos nuevamente el comando de tests de Phpspec:

bin/phpspec run

Y Phpspec nos volverá a preguntar por añadir el método addImages y nos dejará la responsabilidad de implementar dicho método. Pasando directamente a la implementación del método generado por Phpspec, una solución podría ser:


public function addImages($images)
{
    $this->images = array_merge($this->images, $images);
}

En el caso anterior no hemos contemplado la posibilidad que algún elemento del array que pasamos como parámetro al método addImages sea del tipo Image. Para tener en cuenta esta consideración podemos añadir el siguiente test:


function it_should_be_instance_of_images(Image $img1, \stdClass $obj) {
    $this->shouldThrow('\InvalidArgumentException')->duringAddImages([$img1, $obj]);
}

En este test estamos especificando que el método addImage lance una excepción si alguno de los elementos del array no es de la clase Image. Tras ejecutar el test obtenemos como resultado la siguiente salida en el terminal:

User
  32  X it should be instance of images
      expected to get exception, none got.

Para pasar el test una implementación válida podría ser la siguiente:


public function addImages($images)
{
    foreach($images as $image) {
        if(!$image instanceof Image) {
            throw new InvalidArgumentException("All element of the array has to be Image");
        }
    }
    $this->images = array_merge($this->images, $images);
}

El resto del proceso es iterar de la misma forma, hasta acabar con la implementación deseada. Como vemos, el proceso guiado con Phpspec es prácticamente el mismo que detallamos en la sección de TDD, siendo la propia herramienta la que crea las clases y métodos cuando no existen y dejándonos la responsabilidad de implementar dichos métodos, para pasar los tests especificados. En el ejemplo solo hemos dado visibilidad a una pequeña parte de las posibilidades del framework. Algunas de las posibilidades que, por la simplicidad del ejemplo, nos estamos saltando son:

  • Especificación de identidad: Especifica la relación de igualdad de valor y tipo de PHP ( === ).

  • Especificaciones de comparaciones (Comparison Matcher). Especifica la relación de igualdad de valor, sin considerar el tipo, tal y como lo hace PHP con el operador ==. Por ejemplo, "5" == 5, es true.

  • Especificación de tipo (Type Matcher): Con este tipo de métodos se puede concretar el tipo de un objeto, la devolución de un método, si es una instancia de una clase o si implementa un interfaz.

  • Especificación de excepciones (Throw Matcher): Especificamos los casos o situaciones por las que devolvemos una excepción, como hemos visto en el ejemplo al introducir un argumento incorrecto.

  • Identificación del estado de objeto (ObjectState Matcher). Testea el valor del estado de un objeto llamando métodos del propio objeto. Estos métodos deberán comenzar con is o has y devuelven true o false.

  • Identificador de contador (Count Matcher). Testea sobre el número de elementos de la devolución de un método. El valor devuelto por el método puede ser un array o un objeto que implemente el interfaz \Countable.

  • Identificador de typo (Scalar Matcher). Especifica el tipo de datos del valor devuelto por el método.

  • Identificación de elemento o elementos en un array (ArrayContain Matcher).

Estos pueden ser los "Matcher", las formas de determinar el comportamiento de una implementación más comunes, pero hay otras: ArrayKeyWithValue, ArrayKey, StringStart, StringEnd, StringRegex. Además, se pueden extender otros Matcher utilizando un Inline Matcher. En la documentación de Phpspec [23] podemos ver este ejemplo de Inline Matcher, en el que se determina un valor por defecto para el atributo username la clase Movie:


namespace spec;

use PhpSpec\ObjectBehavior;
use PhpSpec\Matcher\InlineMatcher;

class MovieSpec extends ObjectBehavior
{
    function it_should_have_some_specific_options_by_default()
    {
        $this->getOptions()->shouldHaveKey('username');
        $this->getOptions()->shouldHaveValue('diegoholiveira');
    }

    public function getMatchers()
    {
        return [
            'haveKey' => function ($subject, $key) {
                return array_key_exists($key, $subject);
            },
            'haveValue' => function ($subject, $value) {
                return in_array($value, $subject);
            },
        ];
    }
}

results matching ""

    No results matching ""