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.
- Escribir una clase de test de forma conjunta a la clase que queremos implementar nos ayuda a pensar en los requisitos que deben cumplir los métodos antes d e escribirlos.
- Un test unitario es además la mejor documentación que podemos dar a una clase. no solo muestra como se espera que se use, si no que además puede ejecutarse. Mientras que los documentos de texto suelen quedar rápidamente obsoletos un test siempre estará actualizado.
- Un conjunto de test unitarios nos da una herramienta potente para comprobar que no se han introducido errores después de la incorporación de código para añadir una nueva funcionalidad o la corrección de errores. Estos test de regresión reducen considerablemente coste del mantenimiento posterior al desarrollo.
- A medida que se avanza en la ejecución de un proyecto el código se degrada y se hace necesario refactorizar con el fin de mejorar la calidad del mismo. Sin un conjunto de tests de regresión que garanticen el éxito de la refactorización esta se vuelve una operación muy peligrosa y normalmente se prescinde de ella. Los test permiten que refactoricemos el código con seguridad.
- Corregir un fallo durante la fase de aceptación del producto cuesta varias veces más que corregirlo durante la fase 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.
- El código es demasiado difícil de testear.
- El desarrollo de los test está despriorizado y no forma parte integral del proceso de desarrollo.
- Los programadores no quieren escribir test.
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.
- Evitar métodos largos, son difíciles de mantener y testear.
- Evitar el uso de Singletons. No es posible hacer mocks de Singletons, no podremos aislar el método si depende del estado de un Singleton
- Escribir código que implemente interfaces de forma que se pueda cambiar la implementación de un colaborador por una implementación stub o un proxy dinámico.
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:
- AuthService: Servicio de validación de usuarios.
- AuthStrategy: Estrategia de autorización. Las implementaciones de esta interfaz serán las responsables finales de la autorización, permitiendonos disponer de varias estragias y poder incluir nuevos tipos de autorización facilmente con el menor impacto posible en el código. (Patrón Strategy).
- UserDao: Objeto de Acceso a Datos (DAO) que nos permite recuperar la información de usuarios de la base de datos, LDAP, etc...
Un diagrama de clases nos ayuda a verlo mejor:
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:
- Permite conocer bien las responsabilidades de la clase que vamos a implementar antes de empezar a escribirla.
- Permite saber cuando hemos terminado realmente la implemtación de la clase(cuando se pase el test) y nos guiará en el proceso de desarrollo.
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
- Expert One-on-One J2EE Design and Development
Rod Johnson – Wiley Publishing Inc, 2002
ISBN: 978-0-7645-4385-2 - Expert One-on-One J2EE Development without EJB
Rod Johnson, Juergen Hoeller - Wiley Publishing Inc, 2004
ISBN: 978-0-7645-5831-3 - Extreme Programming Explained: Embrace Change
Kent Beck - Addison-Wesley Professional, 2000
ISBN 978-0-2016-1641-5