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); @Test
public class ShipSpec {
public void whenInstantiatedThenLocationIsSet() {
Location location =
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;
}
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() {
Listobstacles = 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