Desarrollo Dirigido por Tests. (Test Driven Development - TDD)


Introducción

El desarrollo y ejecución de test es una actividad fundamental en los proyectos de desarrollo de software y ayudan a mantener un código de calidad durante la vida de este.

El caso concreto de los test unitarios representa la mejor alternativa para encontrar y corregir la mayoría de los errores de codificación.

Aunque todos los tipos de test son importantes, este artículo está dedicado a especialmente a los test unitarios utilizando mocks y a la incorporación de los test en el proceso de desarrollo.

Los dos primeros apartados de este artículo son un pequeño resumen del capítulo 3 del libro J2EE Design and Development y del capítulo 14 del libro J2EE without EJB ambos del mismo autor, Rod Jhonson. Lectura obliglada para cualquier persona interesada en el desarrollo dirigido por tests. Los siguientes apartados muestran un ejemplo de como utilizar mocks en la realización del test unitiarios en un sistema simple de autenticación de usuarios.

Ventajas de Los Test Unitarios

Veamos en primer lugar que nos motiva a incorporar los test unitarios como parte fundamental en la metodología de desarrollo.

A pesar de que una buena parte de los profesionales involucrados en un proyecto de desarrollo está de acuerdo con la importancia de los test unitarios, es fácil (o muy fácil) encontrarse con proyectos que o bien carecen por completo de ellos o aparecen de forma simbólica. Veamos algunos de los motivos que dan lugar a esta situación.

Normalmente cuando el código es malo o bien está vinculado a arquitecturas inexplicablemente complejas (como EJB) es cuando aparecen problemas para escribir los Test. Siempre deberíamos escribir un código aceptable (aunque no vayamos a realizar test unitarios). Nunca deberíamos utilizar arquitecturas que impiden o dificultan terriblemente los test.

El segundo punto es un problema de dirección de proyectos y no debería de repercutir en los programadores. Es la correcta planificación del proyecto la que debe evitar frases como "esto tiene que estar funcionando ya, como sea".

El tercer y último punto es sin duda el máss difícil de solventar. Un programador convencido de que es un programador excelente, que domina un número enorme de tecnologías y que no necesita escribir tests. Este tipo de programador generalmente sobredimensiona técnicamente hasta los problemas más sencillos y suele ocurrir que es la única persona que entiende lo que ha escrito.

Un código bueno debe de ser simple y claro. La complejidad es el principal enemigo del programador. No debemos dejarnos llevar por nuestro ego pensando que podemos resolver los problemas más complejos. No es cierto. Debemos admitir nuestras limitaciones. Un código demasiado complejo para ser testeado debería de reescribirse.

Mi experiencia es que la mayoría de los programadores que empiezan a escribir test los aceptan con cierta resistencia, sin embargo, en relativamente poco tiempo los consideran indispinsables. (Sobre todo cuando les toca mantener una aplicación anterior, en la que no se hicieron tests).

Adaptar el código a los test unitarios

Es necesario adaptar nuestro estilo de programación para facilitar la escritura de los test unitarios.

No ha de sorprendernos que estas recomendaciones sean válidas incluso si no vamos a escribir test. Un código orientado a objetos es normalmente fácil de testear.

Autorización de Usuarios

Para facilitar explicación del uso de mocks en test unitarios usaremos un ejemplo con el que seguramente muchos de nosotros nos hemos encontrado. La autorización de un usuario mediante nombre de usuario y contraseña.

Definimos tres interfaces para diseño del servicio de autorización:

Un diagrama de clases nos ayuda a verlo mejor:

public interface AuthService {
 
	public boolean validate(String username, String password);
 
}
 
public interface AuthStrategy {
 
	public boolean validate(String suppliedPassword, String storedPassword);
}
 
public interface UserDao extends DAO {
 
	/**
	 * Find a User by username
	 * @param username
	 */
	public User findByUsername(String username);
}
 
 
public class User {
	/** Id */
	private Long id;
	/** Username */
	private String username;
	/** Real name */
	private String name;
	/** Surname */
	private String Surname;
	/** Stored password */
	private String password;
 
	... Getters and Setters
}
 
 

La clase AuthManager implementa la interfaz AuthService y es la clase para que vamos a escribir el test unitario utilizando mocks. Una vez definido el sistema, es importante escribir los test antes que el código de la clase. Disponer del test antes que la implementación tiene dos ventajas fundamentalmente:

Como primer paso antes de escribir el test, crearemos el esqueleto de la clase AuthManager.

public class AuthManager implements AuthService {
 
	private AuthStrategy authStrategy;
	private UserDao userDao;
 
 
	// AuthService Start
	public final boolean validate(String username, String password) {
		throw UnsupportedOperationException();
	}
	// AutService End
 
	... Getter And Setters
} 
 

Es conveniente lanzar UnsupportedOperationException en los métodos por implementar para que el test falle hasta que implementemos todos los métodos. Puede configurar su IDE de desarrollo para que genere de este modo los métodos al implementar una interfaz.

Implementación del test

Es el momento de aclarar ideas, pensamos que elegimos bien las interfaces pero aun no hemos hecho ningún intento de usarlas. Empecemos por codificar un test para el método validate de AuthManager que nos guíe en la implementación.

Es importante tener en cuenta que un test unitario no debe comprobar de forma indirecta los colaboradores de una clase. El test de la clase AuthManager debería fallar solo y solo si el error se encuentra en dicha clase. No deberíafallar si por ejemplo, falla la implementación de UserDao. (De hecho, esta implementación ni siquiera existe).



Mocks

Si recurrimos a la idea clásica de que los objetos se comunican enviándose mensajes, podemos pensar en un mock como una implementación de la interfaz que compara los mensajes que recibe con los que previamente se le han configurado y se quejará cuando reciba un mensaje inesperado o le falte alguno por recibir.

Un mock de una interfaz nos sirve para confirmar que los métodos de la interfaz se han llamado correctamente durante la ejecución del test.

En la primera parte del test, crearemos mocks dinámicos para las interfaces UserDao, AuthService y AuthStrategy. Para ello nos valdremos de la librería EasyMock. El test graba en los mocks los métodos a los que se les debe llamar y lo que deben responder. Para grabar el comportamiento sobre un mock, utilizamos el método estático EasyMock.expect(...). Posterioremente, se pone a los mocks en modo replay para que validen las llamadas (mensajes) que reciben con lo que se les ha grabado y se llama al método validate. Por último se verifica que los mocks recibieron todas las llamadas que debian recibir con el método verify.

public class TestAuth extends TestCase {
 
	private static final String USERNAME = "test username";
	private static final String SUPPLIED_PASS = "supplied password";
	private static final String STORED_PASS = "stored password";
	// create mocks
	UserDao userDao = EasyMock.createMock(UserDao.class);
	AuthStrategy authStrategy =  EasyMock.createMock(AuthStrategy.class);
 
 
	public void testAuthManagerValidate() throws Exception {
	  AuthManager authManager = newAuthManager();
	  // record messages 
          expect(userDao.findByUsername(USERNAME)).andReturn(newTestUser());
	  expect(authStrategy.validate(SUPPLIED_PASS, STORED_PASS)).andReturn(true);
	  // sets mocks in replay state
	  replay(authStrategy);
	  replay(userDao);
	  // and send message...
  	  boolean valid = authManager.validate(USERNAME, SUPPLIED_PASS);
	  // check that colaborators receive all messages
	  verify(authStrategy);
	  verify(userDao);
	}
 
 
	public void TestAuthPlain() throws Exception {
		AuthPlain auth = new AuthPlain();
		assertTrue(auth.validate(SUPPLIED_PASS, SUPPLIED_PASS));
		assertFalse(auth.validate(SUPPLIED_PASS, STORED_PASS));
		assertFalse(auth.validate(null, null));
	}
 
	public void testAuthHashMD5() throws Exception {
		AuthHashMD5 auth = new AuthHashMD5();                                            
		assertTrue(auth.validate(SUPPLIED_PASS, hashmd5(SUPPLIED_PASS)));
		assertFalse(auth.validate(SUPPLIED_PASS, STORED_PASS));
		assertFalse(auth.validate(null, null));
	}
 
	private String hashmd5(String suppliedPass)  throws Exception {
		MessageDigest md = MessageDigest.getInstance("MD5");
		md.update(SUPPLIED_PASS.getBytes());
		BASE64Encoder encoder = new BASE64Encoder();
		return encoder.encode(md.digest());
	}
 
 
	private User newTestUser() {
		User user = new User();
		user.setPassword(STORED_PASS);
		user.setUsername(USERNAME);
		return user;
	}
 
 
	private AuthManager newAuthManager() {
		AuthManager authManager = new AuthManager();
		reset(authStrategy);
		authManager.setAuthStrategy(authStrategy);
		reset(userDao);
		authManager.setUserDao(userDao);
 
		return authManager;
	}
}
 
 

Los métodos testAuthHashMD5 y testAuthPlain comprueban las dos estrategias de validación que vamos a implementar, aunque no son relevantes para el test de la clase AuthManager.

Implementación de AuthManager

Una vez escrito el test nos resultará más fácil escribir correctamente la implementación. Normalmente en implementaciones reales (más complejas) iremos modificando el test según vayamos refinando el diseño o la implementación de la clase en un ciclo de varias iteraciones.

public class AuthManager implements AuthService {
 
	private AuthStrategy authStrategy;
	private UserDao userDao;
 
	/**
	 * Autenticate a User that was identified by password
	 * @param user the user to autenticate
	 * @param password plain user supplied password
	 * @return true if password is valid 
	 */
	public boolean authUser(User user, String password) {
		return user == null ? false : authStrategy.validate(password, user.getPassword());
	}
 
	// AuthService Start
	/**
	 * Validate a user
	 */
	public final boolean validate(String username, String password) {
		User user = userDao.findByUsername(username);
 
		return user == null ? false : authUser(user, password);
	}
	// AutService End
 
 
	/**
	 * @return the authStrategy
	 */
	public AuthStrategy getAuthStrategy() {
		return authStrategy;
	}
 
	/**
	 * @param authStrategy the authStrategy to set
	 */
	public void setAuthStrategy(AuthStrategy authStrategy) {
		this.authStrategy = authStrategy;
	}
 
	/**
	 * @return the userDao
	 */
	public UserDao getUserDao() {
		return userDao;
	}
 
	/**
	 * @param userDao the userDao to set
	 */
	public void setUserDao(UserDao userDao) {
		this.userDao = userDao;
	}
}
 
 
public class AuthPlain implements AuthStrategy {
 
	/**
	 * Return true if both passwords are equals and not null
	 */
	public boolean validate(String suppliedPassword, String userPassword) {
		if (suppliedPassword == null || userPassword == null)
			return false;
 
		return suppliedPassword.equals(userPassword);
	}
 
}
 
 
 
public class AuthHashMD5 implements AuthStrategy {
 
	private static Log log = LogFactory.getLog(AuthHashMD5.class);
	/**
	 * Test if userPassword is a md5 hash of suppliedPassword
	 * 
	 * @true if passwords match
	 */
	public boolean validate(String suppliedPassword, String userPassword) {
		if (suppliedPassword == null || userPassword == null)
			return false;
 
		try {
			MessageDigest md = MessageDigest.getInstance("MD5");
			md.update(suppliedPassword.getBytes());
			BASE64Encoder encoder = new BASE64Encoder();
			return userPassword.
				 equals(encoder.encode(md.digest()));
		} catch (NoSuchAlgorithmException nsae) {
			log.error(nsae);
			return false;
		}
	}
}
 

Código fuente del ejemplo

En los enlaces siguientes puede descargar un workspace de eclipse con el código del ejemplo y las librerías necesarias para ejecutar el test.

articles_mocks_sample.tgz (tgz)
articles_mocks_sample.zip (zip)


Bibliografía