miércoles, 28 de agosto de 2019

Kata TDD: Batalla naval sobre los mares de la tierra.


Una Kata en programación es una demostración de una técnica en este caso TDD. Durante la kata el programador experimenta con un ejemplo sencillo si el uso de TDD le resulta práctico y beneficioso para conseguir un código de mejor calidad. La idea de la Kata y el código aquí mostrados están basados en el libro Test-Driven Java Development de Viktor Farcic y Alex García publicado por Packt Publishing en 2015. 

Para comprender este post, es necesario tener conocimientos básicos de: qué es TDD, un lenguaje de programación orientado a objetos (aquí se usa java) y entender que es un marco de pruebas unitarias XUnit (aquí se usa TestNG).

Nuestro trabajo es crear un programa que pueda mover barcos alrededor de los mares de la tierra.
Partimos de clases de soporte que ya están construidas y realizan ciertas funcionalidades útiles para mover los barcos. Para obtener el código del que se parte en esta Kata acceder a https://bitbucket.org/vfarcic/tdd-java-ch04-ship.git en el que se encontrará un proyecto gradle. 

Las clases de soporte son: Direction, Location, Planet y Point. Cada una tiene sus correspondientes clases de prueba cuyo nombre es igual al de la clase que prueba con el sufijo Spec.  El objetivo de las clases Spec es realizar las pruebas unitarias de las clases de soporte suministradas y además servir como especificación o documentación de las mismas, por eso se sufijan con Spec de Specification. Además existe la clase Ship que inicialmente está vacía y es donde se va construir el código con su correspondiente clase de prueba o especificación ShipSpec.

Siguiendo la técnica TDD, se va a proceder a realizar ciclos red-gree-refactor abordando las funcionalidades a implementar en lotes de trabajo muy pequeños.

Lote 1

Se necesita conocer la localización del barco para poder moverlo. Además se necesita saber si el barco está mirando hacía: el norte, el sur, el este o el oeste. Se parte de que un barco tiene un punto inicial (x,y) en el que está situado y una dirección hacia la que mira su proa (N, S, E o W).
Entre las clases de soporte está la clase Point que tiene el constructor.

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

En la clase  Direction existe un método Direction. 

public enum Direction {
    NORTH(0, 'N),
    EAST(1, 'E'),
    SOUTH(2, 'S'),
    WEST(3, 'W'),
    NONE(4, 'X');
}

Además hay una clase Location con el siguiente constructor.

    public Location(Point point, Direction direction) {
        this.point = point;
        this.direction = direction;
    }

Usando la técnica TDD se empieza el primer ciclo red-green-refactor construyendo la  prueba para saber dónde está el barco y hacia donde está mirando. 

@Test
public class ShipSpec {
    public void whenInstantiatedThenLocationIsSet() {
        Location location = 
           new Location(new Point(21,13),Direction.NORTH);      
        Ship ship = new Ship(location);      
        assertEquals(ship.getLocation(), location);   
    }

} 

La prueba verifica que el objeto Location que hemos pasado al constructor del barco ha sido almacenado y se puede acceder a él a través del método getLocation() de Ship.
La implementación consiste en: poner un atributo location a los objetos de tipo Ship, un argumento de tipo Location al constructor de Ship y una función getLocation() que devuelva la location en la que se encuentra el barco.

    private final Location location;
    public Location getLocation() {
        return location;
    }


    public Ship(Location location) {
        this.location = location;
    }


En la parte de refactorización del ciclo decidimos añadir una anotación @BeforeMethod para crear una instancia de un objetos de tipo Ship. Esta instancia la necesitaremos en todos los casos de prueba.

@Test
public class ShipSpec {
 
    private Ship ship;
    private Location location;
 
    @BeforeMethod
        public void beforeTest() {         
         Location location = new Location(
             new Point(21, 13), Direction.NORTH);
        ship = new Ship(location);
    }
 
    public void whenInstantiatedThenLocationIsSet() {
          assertEquals(ship.getLocation(), location);
    }
}

Lote 2

Implementar los comandos que muevan el barco adelante (forward) y atrás (backward). La clase de soporte Location tiene los métodos forward y backward, que implementan esta funcionalidad cambiando la localización del barco según se ordene ir hacia delante (forward) o hacia detrás (backward).

Para la prueba. ¿Qué ocurre cuando, por ejemplo, el barco está mirando hacia el norte y se ordena mover el barco hacia adelante? Su localización sobre la coordenada Y debe incrementarse en una unidad. Otro ejemplo sería cuando el barco está mirando hacia el este, debería incrementarse su localización en X en una unidad.

    public void givenNorthWhenMoveForwardThenYIncreases() {
        ship.moveForward();
        assertEquals(ship.getLocation().getPoint().getY(), 13);
    }
 
    public void givenEastWhenMoveForwardThenXIncreases() {
        ship.getLocation().setDirection(Direction.EAST);
        ship.moveForward();
        assertEquals(ship.getLocation().getPoint().getX(), 22);
    }
 
Esta prueba es correcta. Esto se debe a que la prueba requiere tener conocimiento de qué métodos tiene la clase Location. Es decir getPoint() y getX() son métodos de Location y no se deberían usar en la prueba de Ship. Otro problema que tiene este enfoque es que si Location cambia, hay que buscar todos los lugares en los que se usan sus funciones además de en su propia prueba unitaria.
Aquí lo correcto es asumir que el código al que llamamos tiene ya sus propias pruebas. Es decir, Location ya tiene sus propias pruebas unitarias. Estas pruebas se deben ejecutar cada vez que se incorpora o modifica código al proyecto, asegurando que el nuevo código no rompe lo construido (pruebas de regresión).

Una forma más correcta de escribir esta prueba es.

    public void whenMoveForwardThenForward() {
        Location expected = location.copy();
        expected.forward();
        ship.moveForward();
        assertEquals(ship.getLocation(), expected);
    }
 
Cómo Location ya tiene el método forward, todo lo que necesario es asegurar que se invoca al método de la forma adecuada. Para ello, se crea un objeto de la clase Location llamado expected, se invoca al método forward() y se compara la Location expected con la localización del barco después ordenar que se mueva hacia adelante con moveForward().
Ahora se procede la implementación para llegar a la parte green del ciclo. Consiste aquí en codificar en la clase Ship el método moveForward de la siguiente manera

    public boolean moveForward() {
        return location.forward();
    }
 
Para backward realizamos otro ciclo red-green-refactor similar. La prueba.
     public void whenMoveBackwardThenBackward() {
        Location expected = location.copy();
        expected.backward();
        ship.moveBackward();
        assertEquals(ship.getLocation(), expected);
    }
 
La implementación

    public boolean moveBackward() {
        return location.backward();
    }

Lote 3

Implementar los comandos que mueven el barco a derecha e izquierda. Siguiendo el diseño de mover adelante y atrás, realizamos dos ciclos red-green-refactor uno para la izquierda y otro para la derecha. La  prueba para el movimiento hacia la izquierda.

    public void whenTurnLeftThenLeft() {
        Location expected = location.copy();
        expected.turnLeft();
        ship.turnLeft();
        assertEquals(ship.getLocation(), expected);
    }
 
Y la implementación
    public void turnLeft() {
        location.turnLeft();
    }
 
Y para la derecha otro ciclo con la prueba

    public void whenTurnRightThenRight() {
        Location expected = location.copy();
        expected.turnRight();
        ship.turnRight();
        assertEquals(ship.getLocation(), expected);
    }
 
Y la implementación

    public void turnRight() {
        location.turnRight();
    }

Lote 4

El barco puede recibir una cadena de caracteres con comandos. Esto es ‘lrfb’ es equivalente a: left (izquierda), right (derecha), forward (adelante) y backward (atrás). Se empieza por la prueba para el comando f.

    public void whenReceiveCommandsFThenForward() {
        Location expected = location.copy();
        expected.forward();
        ship.receiveCommands("f");
        assertEquals(ship.getLocation(), expected);
    }
 
La implementación

    public void receiveCommands(String commands) {
        if (commands.charAt(0) == 'f') {
            moveForward();
        }
    }
 
La prueba y la implementación es similar para los comandos r, l y b. Teniendo implementado cada comando por separado, ahora vamos a implementar una prueba para probar un comando compuesto ‘rflb’.

    public void whenReceiveCommandsThenAllAreExecuted() {
        Location expected = location.copy();
        expected.turnRight();
        expected.forward();
        expected.turnLeft();
        expected.backward();
        ship.receiveCommands("rflb");
        assertEquals(ship.getLocation(), expected);
    }
 
La implementación la hacemos modificando el método receiveCommands()
    public void receiveCommands(String commands) {
        for (char command : commands.toCharArray()) {
            switch(command) {
                case 'f':
                    moveForward();
                    break;
                case 'b':
                    moveBackward();
                    break;
                case 'l':
                    turnLeft();
                    break;
                case 'r':
                    turnRight();
                    break;
            }
        }
    }

Lote 5

La tierra es una esfera. Cuando nos movemos en un mapa plano llega un momento que no podemos avanzar más pero en una esfera eso no es así, siempre se puede avanzar para cualquier dirección. Lo implementado hasta ahora no tiene esto en cuenta, para tenerlo en cuenta se considera el mapa una plantilla con cuadrícula. De esta forma, cuando se llega a un borde, se pasa al borde contrario para simular el movimiento en la esfera.

La cuadrícula tiene una longitud y anchura máximas. Entonces se va a implementar como llegando al borde se pasa al borde contrario. Se usa la clase de soporte Planet.  Se prepara una prueba en la que al constructor del barco además de enviarle la localización le enviamos planet. El objetivo a la larga es conocer los límites de la cuadrícula del mapa para un determinado planeta. Ahora solo se comprueba que el barco sepa el planeta en el que se encuentra.

    public void whenInstantiatedThenPlanetIsStored() {
        Point max = new Point(50, 50);
        Planet planet = new Planet(max);
        ship = new Ship(location, planet);
        assertEquals(ship.getPlanet(), planet);
    }
 
Para que esto funcione correctamente hay que modificar el constructor de Ship, pero esto puede romper pruebas que ya están usando el constructor solo con el argumento location. Para mantener en funcionamiento lo anterior se crea un nuevo constructor que sobrecarga el anterior.

    public Ship(Location location) {
        this.location = location;
    }
    public Ship(Location location, Planet planet) {
        this.location = location;
        this.planet = planet;
    }
 
Después de comprobar que todas las pruebas siguen funcionando, en la fase de refactoring se valora si dejar un único constructor. Para dejar con un único constructor cambiamos la prueba

public class ShipSpec {
    private Planet planet;
 
    @BeforeMethod
    public void beforeTest() {
        Point max = new Point(50, 50);
        location = new Location(new Point(21, 13), Direction.NORTH);
        planet = new Planet(max);
//        ship = new Ship(location);
        ship = new Ship(location, planet);
    }
    public void whenInstantiatedThenPlanetIsStored() {
//        Point max = new Point(50, 50);
//        Planet planet = new Planet(max);
//        ship = new Ship(location, planet);
        assertEquals(ship.getPlanet(), planet);
    }
}
 
Con esto el constructor de un solo argumento puede ser borrado. Usando esta secuencia de pasos las pruebas permanecen siempre en verde.

Ahora se procede a la implementación del movimiento de una parte de la cuadrícula a otra, es decir el salto al otro lado cuando se llega al límite. Para ello se usan las clases de soporte. El método forward de Location está sobrecargado con el método forward(Point max). Esté método se encarga de llegar a la otra parte de la cuadrícula cuando alcanzamos el límite máximo de cualquier extremo: norte, sur, este y oeste. Se prepara una prueba para comprobar que cuando el barco está mirando hacia el este y está en el límite máximo de la abscisa X si se ordena que avance, vuelva al valor 1 de X, esto simula que da la vuelta en la esfera terrestre.

/* The name of this method has been shortened due to line's length restrictions. The aim of this test is to check the behavior of ship when it is told to overpass the right boundary.
*/
    public void overpassEastBoundary() {
        location.setDirection(Direction.EAST);
        location.getPoint().setX(planet.getMax().getX());
        ship.receiveCommands("f");
        assertEquals(location.getX(), 1);
    }
 
La implementación que corresponde a esto es

    public boolean moveForward() {
//        return location.forward();
        return location.forward(planet.getMax());
    }
 
Ahora se realiza otro ciclo red-green-refactor con el método backward.

Lote 6

En los planetas no todo es agua y por ello es necesario implementar detección de tipo de superficie antes de hacer el movimiento. Si el comando encuentra una superficie no acuática, el barco aborta el movimiento y reporta el obstáculo.

En las clases de apoyo contamos con:
  • Qué el objeto Planet tiene un constructor que acepta una lista de obstáculos. Cada obstáculo es una instancia de la clase Point.
  • Los métodos Location.forward y Location.backward tiene versiones sobrecargadas que aceptan una lista de obstáculos. Estos métodos devuelven true si el movimiento se hizo y falso si falló.
  • Para construir el informe de estado se usa el método Ship.receiveCommands. Este método  debería devolver una cadena de caracteres del estado resultante de la ejecución de cada movimiento. O representa que el movimiento se hiza y X que no se hizo. Ejemplo: OOXO = OK, OK, Failure y OK.
Preparamos la prueba

public void whenReceiveCommandsThenStopOnObstacle() {
        List obstacles = new ArrayList<>();
        obstacles.add(new Point(location.getX() + 1, location.getY()));
        ship.getPlanet().setObstacles(obstacles);
        Location expected = location.copy();
        expected.turnRight();
        // Moving forward would encounter an obstacle
        // expected.forward(new Point(0, 0), new ArrayList());
        expected.turnLeft();
        expected.backward(new Point(0, 0), new ArrayList<>());
        ship.receiveCommands("rflb");
        assertEquals(ship.getLocation(), expected);
    }
    public void whenReceiveCommandsThenOForOkAndXForObstacle() {
        List obstacles = new ArrayList<>();
        obstacles.add(new Point(location.getX() + 1, location.getY()));
        ship.getPlanet().setObstacles(obstacles);
        String status = ship.receiveCommands("rflb");
        assertEquals(status, "OXOO");
    }
 
Y la implementación 

public boolean moveForward() {
//        return location.forward();
        return location.forward(planet.getMax(), planet.getObstacles());
    }
 
    public boolean moveBackward() {
//        return location.backward();
        return location.backward(planet.getMax(), planet.getObstacles());
    }
 
El código sobre el cual se ha realizado la explicación, está en https://bitbucket.org/vfarcic/tdd-java-ch04-ship/branch/req06-obstacles tal y como lo suministran los autores del libro mencionado al comienzo de este post.

Conclusión

Mediante esta Kata se muestra como el código de una aplicación va tomando forma mediante la técnica TDD, en definitiva inicialmente el código no es nada y toma forma dependiendo exclusivamente de las decisiones del programador. Quizás este post le sirva al programador para reflexionar sobre las ventajas que obtendría si usa esta técnica de construcción de código.
En la opinión la autora de este post, se obtienen múltiples ventajas:
  • Pruebas unitarias automatizadas para todo el código
  • Pruebas unitarias bien construidas (principio FIRST)
  • Especificaciones actualizadas
  • Código construido para ser probado
  • Facilidades para el posterior mantenimiento del sistema
  • No hay código superfluo. Solo se codifica lo imprescindible para superar la prueba
Soto del Real. Madrid a 28 de agosto de 2019

No hay comentarios:

Publicar un comentario