Técnicas útiles
en el desarrollo de software
Refactorización
Ejemplo: Generación de números primos
¿Cuándo hay que refactorizar?
Algunas refactorizaciones comunes
Pruebas de unidad
JUNIT
Ejemplo: La clase Money
TDD [Test-Driven Development]
Caso práctico: Bolera
Bibliografía
Robert C. Martin:
“Agile Software Development: Principles, Patterns, and Practices”.
Prentice Hall, 2003. ISBN 0-13-597444-5.
Martin Fowler:
“Refactoring: Improving the design of existing code”.
Addison-Wesley, 2000. ISBN 0-201-48567-2.
Kent Beck:
“Test-Driven Development by Example”.
Addison-Wesley, 2003. ISBN 0-321-14653-0
Refactorización
Definición
Refactorización (n)
Cambio realizado a la estructura interna del software
para hacerlo… más fácil de comprender
y más fácil de modificar
sin cambiar su comportamiento observable.
Refactorizar (v)
Reestructurar el software
aplicando una secuencia de refactorizaciones.
¿Por qué se “refactoriza” el software?
1. Para mejorar su diseño
a. Conforme se modifica, el software pierde su estructura.
b. Eliminar código duplicado simplificar su mantenimiento.
2. Para hacerlo más fácil de entender
p.ej. La legibilidad del código facilita su mantenimiento
3. Para encontrar errores
p.ej. Al reorganizar un programa, se pueden apreciar con mayor
facilidad las suposiciones que hayamos podido hacer.
4. Para programar más rápido
Al mejorar el diseño del código, mejorar su legibilidad
y reducir los errores que se cometen al programar,
se mejora la productividad de los programadores.
Técnicas útiles en el desarrollo de software -1- © Fernando Berzal
Ejemplo
Generación de números primos
© Robert C. Martin, “The Craftsman” column,
Software Development magazine, julio-septiembre 2002
Para conseguir un trabajo nos plantean el siguiente problema…
Implementar una clase que sirva para calcular todos los números
primos de 1 a N utilizando la criba de Eratóstenes
Una primera solución
Necesitamos crear un método que reciba como parámetro un valor
máximo y devuelva como resultado un vector con los números primos.
Para intentar lucirnos, escribimos…
/**
* Clase para generar todos los números primos de 1 hasta
* un número máximo especificado por el usuario. Como
* algoritmo se utiliza la criba de Eratóstenes.
* <p>
* Eratóstenes de Cirene (276 a.C., Cirene, Libia – 194
* a.C., Alejandría, Egipto) fue el primer hombre que
* calculó la circunferencia de la Tierra. También
* se le conoce por su trabajo con calendarios que ya
* incluían años bisiestos y por dirigir la mítica
* biblioteca de Alejandría.
* <p>
* El algoritmo es bastante simple: Dado un vector de
* enteros empezando en 2, se tachan todos los múltiplos
* de 2. A continuación, se encuentra el siguiente
* entero no tachado y se tachan todos sus múltiplos. El
* proceso se repite hasta que se pasa de la raíz cuadrada
* del valor máximo. Todos los números que queden sin
* tachar son números primos.
*
* @author Fernando Berzal
* @version 1.0 Enero’2005 (FB)
*/
Técnicas útiles en el desarrollo de software -2- © Fernando Berzal
El código lo escribimos todo en un único (y extenso) método, que
procuramos comentar correctamente:
public class Criba
{
/**
* Generar números primos de 1 a max
* @param max es el valor máximo
* @return Vector de números primos
*/
public static int[] generarPrimos (int max)
{
int i,j;
if (max >= 2) {
// Declaraciones
int dim = max + 1; // Tamaño del array
boolean[] esPrimo = new boolean[dim];
// Inicializar el array
for (i=0; i<dim; i++)
esPrimo[i] = true;
// Eliminar el 0 y el 1, que no son primos
esPrimo[0] = esPrimo[1] = false;
// Criba
for (i=2; i<Math.sqrt(dim)+1; i++) {
if (esPrimo[i]) {
// Eliminar los múltiplos de i
for (j=2*i; j<dim; j+=i)
esPrimo[j] = false;
}
}
// ¿Cuántos primos hay?
int cuenta = 0;
for (i=0; i<dim; i++) {
if (esPrimo[i])
cuenta++;
}
Técnicas útiles en el desarrollo de software -3- © Fernando Berzal
// Rellenar el vector de números primos
int[] primos = new int[cuenta];
for (i=0, j=0; i<dim; i++) {
if (esPrimo[i])
primos[j++] = i;
}
return primos;
} else { // max < 2
return new int[0]; // Vector vacío
}
}
}
Para comprobar que nuestro código funciona bien, decidimos probarlo
con un programa que incluye distintos casos de prueba (para lo que
usaremos la herramienta JUnit, disponible en http://www.junit.org/):
import junit.framework.*; // JUnit
import java.util.*;
// Clase con casos de prueba para Criba
public class CribaTest extends TestCase
{
// Programa principal (usa un componente de JUnit)
public static void main(String args[])
{
junit.swingui.TestRunner.main (
new String[] {"CribaTest"});
}
// Constructor
public CribaTest (String nombre)
{
super(nombre);
}
Técnicas útiles en el desarrollo de software -4- © Fernando Berzal
// Casos de prueba
public void testPrimos()
{
int[] nullArray = Criba.generarPrimos(0);
assertEquals(nullArray.length, 0);
int[] minArray = Criba.generarPrimos(2);
assertEquals(minArray.length, 1);
assertEquals(minArray[0], 2);
int[] threeArray = Criba.generarPrimos(3);
assertEquals(threeArray.length, 2);
assertEquals(threeArray[0], 2);
assertEquals(threeArray[1], 3);
int[] centArray = Criba.generarPrimos(100);
assertEquals(centArray.length, 25);
assertEquals(centArray[24], 97);
}
}
Al ejecutar los casos de prueba, conseguimos tener ciertas garantías de
que el programa funciona correctamente:
Técnicas útiles en el desarrollo de software -5- © Fernando Berzal
Después de eso, vamos orgullosos a enseñar nuestro programa y…
un programador no dice que, si queremos el trabajo, mejor no le
enseñemos eso al jefe de proyecto (que no tiene demasiada paciencia)
Veamos qué cosas hemos de mejorar…
Primeras mejoras
Parece evidente que nuestro método generarPrimos realiza tres
funciones diferentes, por lo que de generarPrimos extraemos tres
métodos diferentes. Además, buscamos un nombre más adecuado para
la clase y eliminamos todos los comentarios innecesarios.
/**
* Esta clase genera todos los números primos de 1 hasta un
* número máximo especificado por el usuario utilizando la
* criba de Eratóstenes
* <p>
* Dado un vector de enteros empezando en 2, se tachan todos
* los múltiplos de 2. A continuación, se encuentra el
* siguiente entero no tachado y se tachan sus múltiplos.
* Cuando se llega a la raíz cuadrada del valor máximo, los
* números que queden sin tachar son los números primos
*
* @author Fernando Berzal
* @version 2.0 Enero'2005 (FB)
*/
public class GeneradorDePrimos
{
private static int dim;
private static boolean esPrimo[];
private static int primos[];
public static int[] generarPrimos (int max)
{
if (max < 2) {
return new int[0]; // Vector vacío
} else {
inicializarCriba(max);
cribar();
rellenarPrimos();
return primos;
}
}
Técnicas útiles en el desarrollo de software -6- © Fernando Berzal
private static void inicializarCriba (int max)
{
int i;
dim = max + 1;
esPrimo = new boolean[dim];
for (i=0; i<dim; i++)
esPrimo[i] = true;
esPrimo[0] = esPrimo[1] = false;
}
private static void cribar ()
{
int i,j;
for (i=2; i<Math.sqrt(dim)+1; i++) {
if (esPrimo[i]) {
// Eliminar los múltiplos de i
for (j=2*i; j<dim; j+=i)
esPrimo[j] = false;
}
}
}
private static void rellenarPrimos ()
{
int i, j, cuenta;
// Contar primos
cuenta = 0;
for (i=0; i<dim; i++)
if (esPrimo[i])
cuenta++;
// Rellenar el vector de números primos
primos = new int[cuenta];
for (i=0, j=0; i<dim; i++)
if (esPrimo[i])
primos[j++] = i;
}
}
Los mismos casos de prueba de antes nos permiten comprobar que,
tras la refactorización, el programa sigue funcionando correctamente.
Técnicas útiles en el desarrollo de software -7- © Fernando Berzal
Un segundo intento
El código ha mejorado pero aún es algo más enrevesado de la cuenta:
eliminamos la variable dim (nos vale esPrimo.length),
elegimos identificadores más adecuados para los métodos y
reorganizamos el interior del método inicializarCandidatos
(el antiguo inicializarCriba).
public class GeneradorDePrimos
{
private static boolean esPrimo[];
private static int primos[];
public static int[] generarPrimos (int max)
{
if (max < 2) {
return new int[0];
} else {
inicializarCandidatos(max);
eliminarMultiplos();
obtenerCandidatosNoEliminados();
return primos;
}
}
private static void inicializarCandidatos (int max)
{
int i;
esPrimo = new boolean[max+1];
esPrimo[0] = esPrimo[1] = false;
for (i=2; i<esPrimo.length; i++)
esPrimo[i] = true;
}
private static void eliminarMultiplos ()
… // Código del antiguo método cribar()
private static void obtenerCandidatosNoEliminados ()
… // Código del antiguo método rellenarPrimos()
}
El código resulta más fácil de leer tras la refactorización.
Técnicas útiles en el desarrollo de software -8- © Fernando Berzal
Mejoras adicionales
El bucle anidado de eliminarMultiplos podía eliminarse si usamos
un método auxiliar para eliminar los múltiplos de un número concreto.
Por otro lado, la raíz cuadrada que aparece en eliminarMultiplos
no queda muy claro de dónde proviene (en realidad, es el valor
máximo que puede tener el menor factor de un número no primo
menor o igual que N). Además, el +1 resulta innecesario.
private static void eliminarMultiplos ()
{
int i;
for (i=2; i<maxFactor(); i++)
if (esPrimo[i])
eliminarMultiplosDe(i);
}
private static int maxFactor ()
{
return (int) Math.sqrt(esPrimo.length) + 1;
}
private static void eliminarMultiplosDe (int i)
{
int multiplo;
for ( multiplo=2*i;
multiplo<esPrimo.length;
multiplo+=i)
esPrimo[multiplo] = false;
}
De forma análoga, el método obtenerCandidatosNoEliminados
tiene dos partes bien definidas, por lo que podemos extraer un método
que se limite a contar el número de primos obtenidos…
Hemos ido realizando cambios que mejoran la implementación
sin modificar su comportamiento externo (su interfaz),
algo que verificamos tras cada refactorización
volviendo a ejecutar los casos de prueba.
Técnicas útiles en el desarrollo de software -9- © Fernando Berzal
¿Cuándo hay que refactorizar?
Cuando se está escribiendo nuevo código
Al añadir nueva funcionalidad a un programa (o modificar su
funcionalidad existente), puede resultar conveniente refactorizar:
- para que éste resulte más fácil de entender, o
- para simplificar la implementación de las nuevas funciones
cuando el diseño no estaba inicialmente pensado para lo que
ahora tenemos que hacer.
Cuando se intenta corregir un error
La mayor dificultad de la depuración de programas radica en que
hemos de entender exactamente cómo funciona el programa para
encontrar el error. Cualquier refactorización que mejore la calidad del
código tendrá efectos positivos en la búsqueda del error.
De hecho, si el error “se coló” en el código es porque
no era lo suficientemente claro cuando lo escribimos.
Cuando se revisa el código
Una de las actividades más productivas desde el punto de vista de la
calidad del software es la realización de revisiones del código
(recorridos e inspecciones). Llevada a su extremo, la programación
siempre se realiza por parejas (pair programming, una de las técnicas
de la programación extrema, XP [eXtreme Programming]).
¿Por qué es importante la refactorización?
Cuando se corrige un error o se añade una nueva función, el valor
actual de un programa aumenta. Sin embargo, para que un programa
siga teniendo valor, debe ajustarse a nuevas necesidades (mantenerse),
que puede que no sepamos prever con antelación. La refactorización,
precisamente, facilita la adaptación del código a nuevas necesidades.
Técnicas útiles en el desarrollo de software - 10 - © Fernando Berzal
¿Qué síntomas indican que debería refactorizar?
El código es más difícil de entender (y, por tanto, de cambiar) cuando:
- usa identificadores mal escogidos,
- incluye fragmentos de código duplicados,
- incluye lógica condicional compleja,
- los métodos usan un número elevado de parámetros,
- incluye fragmentos de código secuencial muy extensos,
- está dividido en módulos enormes (estructura monolítica),
- los módulos en los que se divide no resultan razonables desde el
punto de vista lógico (cohesión baja),
- los distintos módulos de un sistema están relacionados con otros
muchos módulos de un sistema (acoplamiento fuerte),
- un método accede continuamente a los datos de un objeto de una
clase diferente a la clase en la que está definida (posiblemente, el
método debería pertenecer a la otra clase),
- una clase incluye variables de instancia que deberían ser
variables locales de alguno(s) de sus métodos,
- métodos que realizan funciones análogas se usan de forma
diferente (tienen nombres distintos y/o reciben los parámetros en
distinto orden),
- un comentario se hace imprescindible para poder entender un
fragmento de código (deberíamos re-escribir el código de forma
que podamos entender su significado).
NOTA: Esto no quiere decir que dejemos de comentar el código.
Técnicas útiles en el desarrollo de software - 11 - © Fernando Berzal
Algunas refactorizaciones comunes
Algunos IDEs (p.ej. Eclipse) permiten realizarlas de forma automática.
Renombrar método [rename method]
Cuando el nombre de un método no refleja su propósito
1. Declarar un método nuevo con el nuevo nombre.
2. Copiar el cuerpo del antiguo método al nuevo método
(y realizar cualquier modificación que resulte necesaria).
3. Compilar
(para verificar que no hemos introducido errores sintácticos)
4. Reemplazar el cuerpo del antiguo método por una llamada al
nuevo método (este paso se puede omitir si no se hace referencia
al antiguo método desde muchos lugares diferentes).
5. Compilar y probar.
6. Encontrar todas las referencias al antiguo método y cambiarlas
por invocaciones al nuevo método.
7. Eliminar el antiguo método.
8. Compilar y probar.
NOTA:
Si el antiguo método era un método público usado por otros
componentes o aplicaciones y no podemos eliminarlo, el antiguo
método se deja en su lugar (como una llamada al nuevo método)
y se marca como “deprecated” con Javadoc (@deprecated).
Técnicas útiles en el desarrollo de software - 12 - © Fernando Berzal
Extraer método [extract method]
Convertir un fragmento de código en un método
cuyo identificador explique el propósito del fragmento de código.
1. Crear un nuevo método y buscarle un identificador adecuado.
2. Copiar el fragmento de código en el cuerpo del método.
3. Buscar en el código extraído referencias a variables locales del
método original (estas variables se convertirán en los
parámetros, variables locales y resultado del nuevo método):
a. Si una variable se usa sólo en el fragmento de código
extraído, se declara en el nuevo método como variable
local de éste.
b. Si el valor de una variable sólo se lee en el fragmento de
código extraído, la variable será un parámetro del nuevo
método.
c. Si una variable se modifica en el fragmento de código
extraído, se intenta convertir el nuevo método en una
función que da como resultado el valor que hay que
asignarle a la variable modificada.
d. Compilar el código para comprobar que todas las
referencias a variables son válidas
4. Reemplazar el fragmento de código en el método original por
una llamada al nuevo método.
5. Eliminar las declaraciones del método original correspondientes
a las variables que ahora son variables locales del nuevo método.
6. Compilar y probar.
Técnicas útiles en el desarrollo de software - 13 - © Fernando Berzal
Versión final
Como sabemos que no es muy recomendable usar demasiado a
menudo la palabra reservada static, con unos pequeños cambios
convertimos GeneradorDePrimos en una clase de la que se puedan
crear distintos objetos.
En primer lugar, modificamos los casos de prueba con los que
comprobaremos el funcionamiento de nuestro generador de primos:
import junit.framework.*;
public class GeneradorDePrimosTest extends TestCase
{
GeneradorDePrimos generador;
int[] primos;
public static void main(String args[])
{
junit.swingui.TestRunner.main(
new String[] {"GeneradorDePrimosTest"});
}
public GeneradorDePrimosTest(String name)
{
super(name);
}
public void testPrimos0()
{
generador = new GeneradorDePrimos(0);
primos = generador.getPrimos();
assertEquals( primos.length, 0);
}
public void testPrimos2()
{
generador = new GeneradorDePrimos(2);
primos = generador.getPrimos();
assertEquals(primos.length, 1);
assertEquals(primos[0], 2);
}
Técnicas útiles en el desarrollo de software - 10 - © Fernando Berzal
public void testPrimos3()
{
generador = new GeneradorDePrimos(3);
primos = generador.getPrimos();
assertEquals(primos.length, 2);
assertEquals(primos[0], 2);
assertEquals(primos[1], 3);
}
public void testPrimos100()
{
generador = new GeneradorDePrimos(100);
primos = generador.getPrimos();
assertEquals(primos.length, 25);
assertEquals(primos[24], 97);
}
}
A continuación, creamos un constructor para GeneradorDePrimos
(que construye el vector de números primos) y añadimos un método
getPrimos() con el cual acceder a este vector desde el exterior…
/**
* Esta clase genera todos los números primos de 1
* hasta un número máximo especificado por el usuario
* utilizando la criba de Eratóstenes.
* <p>
* Dado un vector de enteros empezando en 2, se tachan
* todos los múltiplos de 2. A continuación, se
* encuentra el siguiente entero no tachado y se
* tachan sus múltiplos. Los números que queden sin
* tachar al final son los números primos entre 1 y N.
*
* @author Fernando Berzal
* @version 3.0 Enero'2005 (FB)
*/
public class GeneradorDePrimos
{
private boolean esPrimo[];
private int primos[];
Técnicas útiles en el desarrollo de software - 11 - © Fernando Berzal
public GeneradorDePrimos (int max)
{
if (max < 2) {
primos = new int[0]; // Vector vacío
} else {
inicializarCandidatos(max);
eliminarMultiplos();
obtenerCandidatosNoEliminados();
}
}
public int[] getPrimos ()
{
return primos;
}
private void inicializarCandidatos (int max)
{
int i;
esPrimo = new boolean[max+1];
esPrimo[0] = esPrimo[1] = false;
for (i=2; i<esPrimo.length; i++)
esPrimo[i] = true;
}
private void eliminarMultiplos ()
{
int i;
for (i=2; i<maxFactorPrimo(); i++)
if (esPrimo[i])
eliminarMultiplosDe(i);
}
Técnicas útiles en el desarrollo de software - 12 - © Fernando Berzal
private int maxFactorPrimo ()
{
return (int) Math.sqrt(esPrimo.length) + 1;
}
private void eliminarMultiplosDe (int i)
{
int multiplo;
for ( multiplo=2*i;
multiplo<esPrimo.length;
multiplo+=i )
esPrimo[multiplo] = false;
}
private void obtenerCandidatosNoEliminados ()
{
int i, j;
primos = new int[numPrimos()];
for (i=0, j=0; i<esPrimo.length; i++) {
if (esPrimo[i])
primos[j++] = i;
}
}
private int numPrimos ()
{
int i;
int cuenta = 0;
for (i=0; i<esPrimo.length; i++) {
if (esPrimo[i])
cuenta++;
}
return cuenta;
}
}
Técnicas útiles en el desarrollo de software - 13 - © Fernando Berzal
Pruebas de unidad
con JUnit
Cuando se implementa software, resulta recomendable comprobar
que el código que hemos escrito funciona correctamente.
Para ello, implementamos pruebas que verifican
que nuestro programa genera los resultados que de él esperamos.
Conforme vamos añadiéndole nueva funcionalidad a un programa,
creamos nuevas pruebas con las que podemos medir nuestro progreso
y comprobar que lo que antes funcionaba sigue funcionando tras haber
realizado cambios en el código (test de regresión).
Las pruebas también son de vital importancia cuando refactorizamos
(aunque no añadamos nueva funcionalidad, estamos modificando la
estructura interna de nuestro programa y debemos comprobar que no
introducimos errores al refactorizar).
Automatización de las pruebas
Para agilizar la realización de las pruebas resulta práctico que un test
sea completamente automático y compruebe los resultados esperados.
û No es muy apropiado llamar a una función, guardar el resultado
en algún sitio y después tener que comprobar manualmente si el
resultado era el deseado.
ü Mantener automatizado un conjunto amplio de tests permite
reducir el tiempo que se tarda en depurar errores y en verificar la
corrección del código.
Técnicas útiles en el desarrollo de software - 14 - © Fernando Berzal
JUnit
Herramienta especialmente diseñada para implementar y automatizar
la realización de pruebas de unidad en Java.
Dada una clase de nuestra aplicación…
En una clase aparte definimos un conjunto de casos de prueba
- La clase hereda de junit.framework.TestCase
- Cada caso de prueba se implementa en un método aparte.
- El nombre de los casos de prueba siempre comienza por test.
import junit.framework.*;
public class CuentaTest extends TestCase
{
…
}
Cada caso de prueba invoca a una serie de métodos de nuestra clase
y comprueba los resultados que se obtienen tras invocarlos.
- Creamos uno o varios objetos de nuestra clase con new
- Realizamos operaciones con ellos.
- Definimos aserciones (condiciones que han de cumplirse).
public void testCuentaNueva ()
{
Cuenta cuenta = new Cuenta();
assertEquals(cuenta.getSaldo(), 0.00);
}
public void testIngreso ()
{
Cuenta cuenta = new Cuenta();
cuenta.ingresar(100.00);
assertEquals(cuenta.getSaldo(), 100.00);
}
Técnicas útiles en el desarrollo de software - 15 - © Fernando Berzal
Finalmente, ejecutamos los casos de prueba con JUnit:
§ Si todos los casos de prueba funcionan correctamente…
§ Si algún caso de prueba falla…
Tendremos que localizar el error y corregirlo
con ayuda de los mensajes que nos muestra JUnit.
MUY IMPORTANTE: Que nuestra implementación supere todos los
casos de prueba no quiere decir que sea correcta; sólo quiere decir que
funciona correctamente para los casos de prueba que hemos diseñado.
Técnicas útiles en el desarrollo de software - 16 - © Fernando Berzal
Apéndice: Cómo ejecutar JUnit desde nuestro propio código
Para lanzar JUnit desde nuestro propio código,
sin tener que ejecutar la herramienta a mano,
hemos de implementar el método main en nuestra clase
y definir un sencillo constructor.
import junit.framework.*;
public class CuentaTest extends TestCase
{
public static void main(String args[])
{
junit.swingui.TestRunner.main (
new String[] {"CuentaTest"});
}
public CuentaTest(String name)
{
super(name);
}
// Casos de prueba
…
}
Técnicas útiles en el desarrollo de software - 17 - © Fernando Berzal
Ejemplo: La clase Money
Basado en “Test-Driven Development by Example”, pp. 1-87 © Kent Beck
Vamos a definir una clase, denominada Money, para representar
cantidades de dinero que pueden estar expresadas en distintas monedas
Por ejemplo, queremos usar esta clase para generar informes como…
Empresa Acciones Precio Total
Telefónica 200 10 EUR 2000 EUR
Vodafone 100 50 EUR 5000 EUR
7000 EUR
El problema es que también nos podemos encontrar con situaciones
como la siguiente …
Empresa Acciones Precio Total
Microsoft 200 13 USD 2600 USD
Indra 100 50 EUR 5000 EUR
7000 EUR
donde hemos tenido que utilizar el tipo de cambio actual (1€=$1.30)
Comenzamos creando una clase para representar cantidades de dinero:
public class Money
{
private int cantidad;
private String moneda;
public Money (int cantidad, String moneda)
{
this.cantidad = cantidad;
this.moneda = moneda;
}
public int getCantidad() { return cantidad; }
public String getMoneda() { return moneda; }
}
Técnicas útiles en el desarrollo de software - 18 - © Fernando Berzal
Una de las cosas que tendremos que hacer es sumar cantidades, por lo
que podemos idear un caso de prueba como el siguiente:
import junit.framework.*;
public class MoneyTest extends TestCase
{
public void testSumaSimple()
{
Money m10 = new Money (10, "EUR");
Money m20 = new Money (20, "EUR");
Money esperado = new Money (30, "EUR");
Money resultado = m10.add(m20);
Assert.assertEquals(resultado,esperado);
}
}
Al idear el caso de prueba, nos estamos fijando en cómo tendremos
que usar nuestra clase en la práctica, lo que nos es extremadamente
útil para definir su interfaz.
En este caso, nos hace falta añadir un método add a la clase Money
para poder compilar y ejecutar el caso de prueba…
Creamos una implementación inicial de este método:
public Money add (Money m)
{
int total = getCantidad() + m.getCantidad();
return new Money( total, getMoneda());
}
Compilamos y ejecutamos el test para llevarnos una sorpresa…
Técnicas útiles en el desarrollo de software - 19 - © Fernando Berzal
El caso de prueba falla porque la comparación de objetos en Java, por
defecto, se limita a comparar referencias (no compara el estado de los
objetos, que es lo que podríamos pensar [erróneamente]).
La comparación de objetos en Java se realiza con el método equals,
que puede recibir como parámetro un objeto cualquiera.
Por tanto, hemos de definir el método equals en la clase Money:
public boolean equals (Object obj)
{
Money aux;
boolean iguales;
if (obj instanceof Money) {
aux = (Money) obj;
iguales = aux.getMoneda().equals(getMoneda())
&& (aux.getCantidad() == getCantidad());
} else {
iguales = false;
}
return iguales;
}
Volvemos a ejecutar el caso de prueba…
… y ahora sí podemos seguir avanzando
Técnicas útiles en el desarrollo de software - 20 - © Fernando Berzal
De todas formas, para asegurarnos de que todo va bien, creamos un
caso de prueba específico que verifique el funcionamiento de equals
public void testEquals()
{
Money m10 = new Money (10, "EUR");
Money m20 = new Money (20, "EUR");
Assert.assertEquals(m10,m10);
Assert.assertEquals(m20,m20);
Assert.assertTrue(!m10.equals(m20));
Assert.assertTrue(!m20.equals(m10));
Assert.assertTrue(!m10.equals(null));
}
Al ejecutar nuestros dos casos de prueba con JUNIT
vemos que todo marcha como esperábamos.
Sin embargo, comenzamos a ver que existe código duplicado
(y ya sabemos que eso no es una buena señal),
por lo que utilizamos la posibilidad que nos ofrece JUNIT
de definir variables de instancia en la clase que hereda de TestCase
(variables que hemos de inicializar en el método setUp())
public class MoneyTest extends TestCase
{
Money m10;
Money m20;
public void setUp()
{
m10 = new Money (10, "EUR");
m20 = new Money (20, "EUR");
}
…
}
Técnicas útiles en el desarrollo de software - 21 - © Fernando Berzal
Aparte de sumar cantidades de dinero, también tenemos que ser
capaces de multiplicar una cantidad por un número entero
(p.ej. número de acciones por precio de cada acción)
Podemos añadir un nuevo caso de prueba que utilice esta función:
public void testMultiplicar ()
{
Assert.assertEquals ( m10.times(2), m20 );
Assert.assertEquals ( m10.times(2), m20 );
Assert.assertEquals ( m10.times(10), m20.times(5));
}
Obviamente, también tendremos que definir times en Money:
public Money times (int n)
{
return new Money ( n*getCantidad(), getMoneda() );
}
Compilamos y ejecutamos los casos de prueba con JUnit:
Poco a poco, vamos añadiéndole funcionalidad a nuestra clase:
Cada vez que hacemos cambios, volvemos a ejecutar todos
los casos de prueba para confirmar que no hemos estropeado nada.
Técnicas útiles en el desarrollo de software - 22 - © Fernando Berzal
Una vez que hemos comprobado que ya somos capaces de hacer
operaciones cuando todo se expresa en la misma moneda, tenemos que
comenzar a trabajar con distintas monedas.
Por ejemplo:
public void testSumaCompleja ()
{
Money euros = new Money(100,"EUR");
Money dollars = new Money(130,"USD");
Money resultado = euros.add (dollars);
Money banco = Bank.exchange(dollars,"EUR")
Money esperado = euros.add(banco);
Assert.assertEquals ( resultado, esperado );
}
Para hacer el cambio de moneda, suponemos que tenemos acceso a un
banco que se encarga de hacer la conversión. Hemos de crear una
clase auxiliar Bank que se va a encargar de consultar los tipos de
cambio y aplicar la conversión correspondiente:
public class Bank
{
public static Money exchange
(Money dinero, String moneda)
{
int cantidad = 0;
if (dinero.getMoneda().equals(moneda)) {
cantidad = dinero.getCantidad();
} else if ( dinero.getMoneda().equals("EUR")
&& moneda.equals("USD")) {
cantidad = (130*dinero.getCantidad())/100;
} else if ( dinero.getMoneda().equals("USD")
&& moneda.equals("EUR")) {
cantidad = (100*dinero.getCantidad())/130;
}
return new Money(cantidad,moneda);
}
}
Técnicas útiles en el desarrollo de software - 23 - © Fernando Berzal
Por ahora, nos hemos limitado a realizar una conversión fija para
probar el funcionamiento de nuestra clase Money (en una aplicación
real tendríamos que conectarnos realmente con el banco).
Obviamente, al ejecutar nuestros casos de prueba se produce un error:
Hemos de corregir la implementación interna del método add de la
clase Money para que tenga en cuenta el caso de que las cantidades
correspondan a monedas diferentes:
public Money add (Money dinero)
{
Money convertido;
int total;
if (getMoneda().equals(dinero.getMoneda()))
convertido = dinero;
else
convertido = Bank.exchange(dinero, getMoneda());
total = getCantidad() + convertido.getCantidad();
return new Money( total, getMoneda());
}
Técnicas útiles en el desarrollo de software - 24 - © Fernando Berzal
Volvemos a ejecutar los casos de prueba
y comprobamos que, ahora sí, las sumas se hacen correctamente:
Si todavía no las tuviésemos todas con nosotros,
podríamos seguir añadiendo casos de prueba para adquirir
más confianza en la implementación que acabamos de realizar.
Por ejemplo, el siguiente caso de prueba comprueba
el funcionamiento del banco al realizar conversiones de divisas:
public void testBank ()
{
Money euros = new Money(10,"EUR");
Money dollars = new Money(13,"USD");
Assert.assertEquals (
Bank.exchange(dollars,"EUR"), euros );
Assert.assertEquals (
Bank.exchange(dollars,"USD"), dollars );
Assert.assertEquals (
Bank.exchange(euros, "EUR"), euros );
Assert.assertEquals (
Bank.exchange(euros, "USD"), dollars );
}
Técnicas útiles en el desarrollo de software - 25 - © Fernando Berzal
Comentarios finales: El método toString
Para que resulte más fácil interpretar
los mensajes generados por JUNIT,
resulta recomendable definir el método toString()
en todas las clases que definamos. Por ejemplo:
public class Money
{
…
public String toString ()
{
return getCantidad()+" "+getMoneda();
}
}
RECORDATORIO: toString() es un método
que se emplea en Java para convertir un objeto cualquiera
en una cadena de caracteres.
Teniendo definido el método anterior,
en nuestras aplicaciones podríamos escribir directamente…
…
Money share = new Money(13,"USD");
Money investment = share.times(200);
Money euros = Bank.exchange(investment,”EUR”);
System.out.println(investment + "(" + euros + ")");
…
y obtener como resultado en pantalla:
2600 USD (2000 EUR)
Técnicas útiles en el desarrollo de software - 26 - © Fernando Berzal
TDD
[Test-Driven Development]
Consiste en implementar las pruebas de unidad
antes incluso de comenzar a escribir el código de un módulo.
Las pruebas de unidad consisten en
comprobaciones (manuales o automatizadas)
que se realizan para verificar que el código
correspondiente a un módulo concreto de un sistema software
funciona de acuerdo con los requisitos del sistema.
Tradicionalmente,
las pruebas se realizan a posteriori
Los casos de prueba se suelen escribir después de implementar el
módulo cuyo funcionamiento pretenden verificar.
Como mucho, se preparan en paralelo si el programador
y la persona que realiza las pruebas [tester] no son la misma persona.
En TDD,
las pruebas se preparan antes de comenzar a escribir el código.
Primero escribimos un caso de prueba
y sólo después implementamos el código necesario
para que el caso de prueba se pase con éxito
Técnicas útiles en el desarrollo de software - 27 - © Fernando Berzal
Aunque pueda parecer extraño, TDD ofrece algunas ventajas:
Al escribir primero los casos de prueba, definimos de manera
formal los requisitos que esperamos que cumpla nuestra aplicación.
Los casos de prueba sirven de documentación del sistema.
Al escribir una prueba de unidad, pensamos en la forma correcta de
utilizar un módulo que aún no existe.
Hacemos hincapié en el diseño de la interfaz de un módulo antes
de centrarnos en su implementación (algo siempre bueno:
“la interfaz debe determinar la implementación, y no al revés”).
La ejecución de los casos de prueba se realiza de forma
automatizada (por ejemplo, con ayuda de JUNIT)
Al ejecutar los casos de prueba detectamos si hemos introducido
algún error al tocar el código para realizar cualquier cambio en
nuestra aplicación (ya sea para añadirle nuevas funciones o para
reorganizar su estructura interna [refactorización]).
Los casos de prueba nos permiten perder el miedo a realizar
modificaciones en el código
Tras realizar pequeñas modificaciones sobre el código,
volveremos a ejecutar los casos de prueba para comprobar
inmediatamente si hemos cometido algún error o no.
Los casos de prueba definen claramente cuándo termina nuestro
trabajo (cuando se pasan con éxito todas los casos de prueba).
Técnicas útiles en el desarrollo de software - 28 - © Fernando Berzal
El proceso de construcción de software se convierte en un ciclo:
1. Añadir un nuevo caso de prueba
que recoja algo que nuestro módulo debe realizar correctamente.
2. Ejecutar los casos de prueba
para comprobar que el caso recién añadido falla.
3. Realizar pequeños cambios en la implementación
(en función de lo que queremos que haga nuestra aplicación).
4. Ejecutar los casos de prueba
hasta que todos se vuelven a pasar con éxito.
5. Refactorizar el código para mejorar su diseño (eliminar código
duplicado, extraer métodos, renombrar identificadores…)
6. Ejecutar los casos de prueba
para comprobar que todo sigue funcionando correctamente.
7. Volver al paso inicial
Técnicas útiles en el desarrollo de software - 29 - © Fernando Berzal
Caso práctico: Bolera
El problema consiste en…
obtener la puntuación de un jugador en una partida de bolos.
Una posible solución…
y el proceso seguido para obtenerla en
“The Bowling Game. An example of test-first pair programming”
© Robert C. Martin & Robert S. Koss, 2001
http://www.objectmentor.com/resources/articles/xpepisode.htm
Robert C. Martin:
“Agile Software Development: Principles, Patterns, and Practices”.
Prentice Hall, 2003. ISBN 0-13-597444-5.
Técnicas útiles en el desarrollo de software - 30 - © Fernando Berzal