3.5 Implementación de Pruebas de Integración con Spring y JUnit Índice Código de pruebas JUnit Spring TestContext Framework JUnit 4 Inicialización y borrado de datos Configuración Inyección de dependencias Transacciones Integración con JUnit 4 Spring + Junit Inicialización y borrado de datos Transacciones Código de Pruebas Con Maven el código de pruebas se ubica en el directorio src/test (que tiene una estructura de directorios paralela al directorio src/main) En el subsistema pojo-minibank el código de las clases de prueba está contenido en el paquete es.udc.pojo.minibank.test.model (y subpaquetes) Las pruebas se pueden ejecutar automáticamente desde Maven (a través del plugin surefire) haciendo que se ejecute la fase test (e.g. mvn test) Maven genera un informe detallado en target/surefire-reports JUnit JUnit es un framework para escribir pruebas de unidad o de integración automatizadas en Java http://www.junit.org/ Open Source Programado por Erich Gamma y Kent Beck Utiliza aserciones para comprobar resultados esperados Tras la ejecución de las pruebas genera un informe indicando el número de pruebas ejecutadas y cuales no se han ejecutado satisfactoriamente Existen dos tipos de fallos diferentes para una prueba Failure: Indica que ha fallado una aserción, es decir, el código que se está probando no está devolviendo los resultados esperados, por lo que falla la comparación Error: Indica que ha ocurrido una Excepción no esperada y que por tanto no se está capturando (e.g. NullPointerException o ArrayIndexOutOfBoundsException) es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (1) ... import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; ... public class AccountServiceTest { ... private AccountService accountService; ... @BeforeClass public static void populateDb() { DbUtil.populateDb(); } @AfterClass public static void cleanDb() throws Exception { DbUtil.cleanDb(); } @Test public void testCreateAccount() throws InstanceNotFoundException { Account account = accountService.createAccount(new Account(1, 10)); Account account2 = accountService.findAccount(account.getAccountId()); assertEquals(account, account2); } es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (2) @Test public void testWithdrawFromAccount() throws InstanceNotFoundException, InsufficientBalanceException { testAddWithdraw(false); } private void testAddWithdraw(boolean add) throws InstanceNotFoundException, InsufficientBalanceException { /* Perform operation. */ double amount = 5; double newBalance; Calendar startDate; Calendar endDate; if (add) { newBalance = DbUtil.getTestAccount().getBalance() + amount; } else { newBalance = DbUtil.getTestAccount().getBalance() - amount; } startDate = Calendar.getInstance(); es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (3) if (add) { accountService.addToAccount( DbUtil.getTestAccount().getAccountId(), amount); } else { accountService.withdrawFromAccount( DbUtil.getTestAccount().getAccountId(), amount); } endDate = Calendar.getInstance(); /* Check new balance. */ Account account = accountService. findAccount(DbUtil.getTestAccount().getAccountId()); assertTrue(newBalance == account.getBalance()); es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (4) /* Check account operation. */ List<AccountOperationInfo> accountOperations = accountService.findAccountOperationsByDate( DbUtil.getTestAccount().getAccountId(), startDate, endDate, 0, 2); assertTrue(accountOperations.size() == 1); AccountOperationInfo accountOperationInfo = accountOperations.get(0); if (add) { assertEquals(AccountOperation.Type.ADD, accountOperationInfo.getType()); } else { assertEquals(AccountOperation.Type.WITHDRAW, accountOperationInfo.getType()); } assertTrue(amount == accountOperationInfo.getAmount()); } es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (5) @Test(expected = InstanceNotFoundException.class) public void testWithdrawFromNonExistentAccountAccount() throws InstanceNotFoundException, InsufficientBalanceException { accountService.withdrawFromAccount(NON_EXISTENT_ACCOUNT_ID, 10); } es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (6) @Test public void testWithdrawWithInsufficientBalance() throws InstanceNotFoundException, InsufficientBalanceException { boolean exceptionCatched = false; Calendar startDate; Calendar endDate; /* Try to withdraw. */ startDate = Calendar.getInstance(); try { accountService.withdrawFromAccount( DbUtil.getTestAccount().getAccountId(), DbUtil.getTestAccount().getBalance() + 1); } catch (InsufficientBalanceException e) { exceptionCatched = true; } endDate = Calendar.getInstance(); assertTrue(exceptionCatched); es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest (7) /* Check balance has not been modified. */ Account account = accountService.findAccount( DbUtil.getTestAccount().getAccountId()); assertTrue( account.getBalance() == DbUtil.getTestAccount().getBalance()); /* Check account operation has not been registered. */ List<AccountOperationInfo> accountOperations = accountService.findAccountOperationsByDate( DbUtil.getTestAccount().getAccountId(), startDate, endDate, 0, 1); assertTrue(accountOperations.size() == 0); } ... } JUnit 4 (1) Requiere Java SE 5.0 o superior Utiliza anotaciones @Test para marcar un método como un caso de prueba El parámetro timeout permite especificar que el test falle si su ejecución no ha finalizado después de cierto tiempo El parámetro expected permite especificar el tipo de excepción que debe lanzar el test para que sea exitoso Comprobaciones Pueden realizarse varias comprobaciones (aserciones) por método La clase Assert proporciona un conjunto de métodos estáticos para realizar comprobaciones Para que una prueba se considere correcta tienen que cumplirse todas las aserciones especificadas JUnit 4 (2) Comprobaciones (cont): Ej: Assert.assertEquals(boolean) Ej: Assert.assertEquals(Object, Object) Compara utilizando el método equals (definido en Object) Si no se redefine, el método equals de la clase Object realiza una comparación por referencia Para las clases para las que se desee que la comparación sea por contenido es necesario redefinir el método equals La clase String y las correspondientes a los tipos básicos lo tienen redefinido para comparar por contenido En la clase Account se ha redefinido el método equals para poder comparar instancias utilizando este método JUnit 4 (3) En general, cada método @Test se corresponde con un caso de prueba de un caso de uso, aunque a veces puede tener sentido implementar varios casos de prueba dentro de un mismo método @Test (si con ello se evita repetir código) Para el caso de uso withdrawFromAccount se han implementado tres casos de prueba Retirar dinero de una cuenta con balance suficiente Retirar dinero de una cuenta inexistente testWithdrawFromAccount testWithdrawFromNonExistentAccountAccount Retirar dinero de una cuenta con balance insuficiente testWithdrawWithInsufficientBalance JUnit: Inicialización y Borrado de Datos (1) La idea de hacer las pruebas con un framework de pruebas automatizadas es que sean "pruebas automáticas" Por tanto, cuando se ejecuten, deben hacer con anterioridad todo lo que sea necesario (crear datos necesarios) y con posterioridad restaurar el estado inicial (eliminar datos) para que puedan volver a ejecutarse con posterioridad Utilizan anotaciones @Before y @After para definir los métodos a ejecutar antes y después de la ejecución de cada prueba Utilizan anotaciones @BeforeClass y @AfterClass para definir los métodos a ejecutar antes y después de la ejecución del conjunto de pruebas de una clase JUnit: Inicialización y Borrado de Datos (y 2) Cuando muchos casos de prueba necesitan crear los mismos datos en BD, la creación de esos datos es recomendable realizarla en el método anotado con @BeforeClass AccountServiceTest crea una cuenta de prueba en la BD, que muchos casos de prueba (e.g. testWithdrawFromAccount y testWithdrawWithInsufficientBalance) asumirán que existe, evitando así tener que crearla explícitamente La cuenta de prueba creada se almacena en una variable global (DBUtil.getTestAccount()) para evitar tener que leerla de BD en ciertos casos de prueba (e.g. testWithdrawFromAccount y testWithdrawWithInsufficientBalance) Cuando un caso de prueba necesita crear datos específicos (no comunes a un número significativo de casos de prueba) en BD, los crea directamente es.udc.pojo.minibank.test.model.account.Account public class Account { ... @Override public int hashCode() { return userId == null ? 0 : userId.hashCode(); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (!(obj instanceof Account)) return false; final Account other = (Account) obj; es.udc.pojo.minibank.test.model.account.Account if (accountId == null) { if (other.getAccountId() != null) return false; } else if (!accountId.equals(other.getAccountId())) return false; if (Double.doubleToLongBits(balance) != Double.doubleToLongBits(other.getBalance())) return false; if (userId == null) { if (other.getUserId() != null) return false; } else if (!userId.equals(other.getUserId())) return false; if (version != other.getVersion()) return false; return true; } } Redefinición del método equals (1) Cuando los objetos son entidades manejadas por Hibernate es necesario tener en cuenta que el objeto que recibe el método equals puede ser un proxy Para ver si el objeto es de la misma clase que la instancia con la que se está comparando es necesario utilizar el operador instanceof en lugar del método getClass El método getClass devolvería la clase del proxy En vez de acceder a las propiedades directamente es necesario acceder a ellas a través de los métodos get, ya que en caso de ser un proxy las propiedades podrían estar sin inicializar Al llamar a cualquier método get las propiedades se inicializan Nótese que para el caso del objeto sobre el que se está invocando el equals sí es seguro acceder directamente a las propiedades, porque en caso de ser un proxy se habrán inicializado al realizar la llamada al método Redefinición del método equals (2) El método equals de la clase Account es el que autogenera Eclipse modificado para tener en cuenta los aspectos comentados en la transparencia anterior Cuando los objetos son entidades manejadas por Hibernate que tienen relaciones con otras entidades, por escalabilidad, no se deben comparar las propiedades que mantienen las relaciones Si se comparasen se podrían cargar en memoria muchos objetos Por ejemplo en AccountOperation, en la comparación no se tiene en cuenta la propiedad de tipo Account Redefinición del método hashCode (1) En Java para poder tratar objetos dentro de colecciones indexadas es necesario que si el método equals indica que dos objetos son iguales, entonces el método hashCode devuelva el mismo valor para ambos No se requiere lo contrario (si hashCode devuelve lo mismo no implica que equals deba devolver true) Es deseable, por motivos de eficiencia, maximizar la probabilidad de que si equals indica que dos objetos no son iguales hashCode devuelva un valor distinto Una vez que un objeto ha sido insertado en una colección indexada su hashCode no debe variar mientras el objeto permanezca en la colección La implementación de hashCode en la clase Object tiene en cuenta la igualdad referencial (solamente asegura que devuelve el mismo valor para la misma instancia de una clase) Por tanto, si se redefine el método equals para que no realice igualdad por referencia, es necesario redefinir también el método hashCode Redefinición del método hashCode (2) Cuando los objetos son entidades manejadas por Hibernate cuyas claves son autogeneradas, hay que tener en cuenta que el objeto puede insertarse en una colección cuando aún no tiene valor para esa clave Por tanto hashCode no debe tener en cuenta la clave de la entidad a la hora de generar el valor devuelto (ya que con posterioridad la clave pasará a tener valor) ¿Cómo generarlo entonces? Este es un tema no exento de controversia en la comunidad de Hibernate En los ejemplos de la asignatura se ha optado por una aproximación segura pero no siempre la más eficiente Se genera en función de propiedades del objeto que sean inmutables y cuyos valores lo identifiquen lo más unívocamente posible Redefinición del método hashCode (3) ¿Cómo generarlo entonces? En los ejemplos de la asignatura se ha optado por una aproximación segura pero no siempre la más eficiente (cont) Si el objeto no tiene propiedades inmutables se genera en función de las que sea menos probable que vayan a variar En este caso hay que tener en cuenta que una vez insertado el objeto en una colección, las propiedades de las que depende el cálculo del hashCode no deben variar mientras permanezca el objeto en la colección En Account se ha generado en función de la propiedad userId y en AccountOperation en función de la propiedad date En caso de tener valor nulo la propiedad, se devuelve un valor constante Spring TestContext Framework Proporciona un soporte genérico, basado en anotaciones, para la realización de pruebas de unidad o de integración de la capa modelo, independiente del framework concreto de pruebas utilizado Además de la infraestructura genérica para la realización de pruebas, proporciona el soporte de integración con JUnit y TestNG es.udc.pojo.minibank.test.model.accountservice.AccountServiceTest @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = { GlobalNames.SPRING_CONFIG_FILE_LOCATION }) @Transactional public class AccountServiceTest { ... private AccountService accountService; ... @Autowired public void setAccountService(AccountService accountService) { this.accountService = accountService; } ... } GlobalNames.java public final class GlobalNames { public static final String SPRING_CONFIG_FILE_LOCATION = "classpath:/pojo-minibank-spring-config.xml"; private GlobalNames () {} } Beans de la capa modelo de MiniBank (pojo-minibank-spring-config.xml) <!-- DAOs. --> <bean id="accountDao" class="es.udc.pojo.minibank.model.account.AccountDaoHibernate" p:sessionFactory-ref="sessionFactory" /> <bean id="accountOperationDao" class= "es.udc.pojo.minibank.model.accountoperation.AccountOperationDaoHibernate" p:sessionFactory-ref="sessionFactory" /> <!-- Service layer. --> <bean id="accountService" class="es.udc.pojo.minibank.model.accountservice.AccountServiceImpl" p:accountDao-ref="accountDao" p:accountOperationDao-ref="accountOperationDao" /> Configuración Para que una clase de pruebas tenga acceso al contenedor (ApplicationContext) debe utilizar la anotación @ContextConfiguration a nivel de clase Por defecto se genera una localización para el fichero que contiene los metadatos de configuración (a partir de los cuales se crea el contenedor) basada en el nombre de la clase de test E.g. si la clase se llama com.example.MyTest se cargará la configuración de "classpath:/com/example/MyTest-context.xml" A través del atributo locations es posible especificar localizaciones alternativas del fichero Inyección de dependencias En las clases de test es posible realizar “autoinyección” de dependencias a través de anotaciones Anotación @Autowired Se anota el método set correspondiente a la propiedad que se desea autoinyectar La autoinyección se realiza por tipo Se busca un bean en el fichero de configuración que coincida con el tipo de la propiedad También es posible indicar que la autoinyección se realice por nombre utilizando la anotación @Resource Necesario si hay más de un bean con el mismo tipo que la propiedad que se desea inyectar Configuración de transacciones (pojo-minibank-spring-config.xml) <!-- For translating native persistence exceptions to Spring's DataAccessException hierarchy. --> <bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/> <!-- Data source. --> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource" p:driverClassName="${jdbcDriver.className}" p:url="${dataSource.url}" p:username="${dataSource.user}" p:password="${dataSource.password}" /> <!-- Hibernate Session Factory --> <bean id="sessionFactory" class="org.springframework.orm.hibernate3.annotation.AnnotationSessionFactoryBean" p:dataSource-ref="dataSource" p:configLocation="classpath:pojo-minibank-hibernate.cfg.xml"/> <!-- Transaction manager for a single Hibernate SessionFactory. --> <bean id="transactionManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager" p:sessionFactory-ref="sessionFactory" /> <!-- Enable the configuration of transactional behavior based on annotations. --> <tx:annotation-driven transaction-manager="transactionManager" /> Transacciones Para habilitar el soporte de transacciones debe declararse un gestor de transacciones (un bean de tipo PlatformTransactionManager) en el fichero de configuración especificado a través de la anotación @ContextConfiguration Por defecto se utiliza como gestor de transacciones el bean con nombre transactionManager Si se desea utilizar otro, se puede especificar a través de la anotación @TransactionConfiguration Se utiliza un DataSource definido directamente sobre un driver JDBC Integración con JUnit 4 Spring TestContext Framework se integra con JUnit 4 a través de un runner a medida Anotando las clases de pruebas con @Runwith(SpringJUnit4ClassRunner.class), es posible implementar tests de unidad o integración con JUnit 4 y al mismo tiempo beneficiarse del soporte que ofrece el Spring TestContext Framework para Carga del contenedor (“application context”) Inyección de dependencias en las clases de test Ejecución transaccional de métodos de test Etc. DbUtil.java (1) public class DbUtil { static { ApplicationContext context = new ClassPathXmlApplicationContext( GlobalNames.SPRING_CONFIG_FILE_LOCATION); transactionManager = (PlatformTransactionManager) context.getBean("transactionManager"); accountDao = (AccountDao) context.getBean("accountDao"); } private static Account testAccount; private static AccountDao accountDao; private static PlatformTransactionManager transactionManager; /** * IMPORTANT: the returned object is a global object. In consequence, it * must not be modified, since it can affect other tests. */ public static Account getTestAccount() { return testAccount; } DbUtil.java (2) public static void populateDb() { /* * Since this method is supposed to be called from a @BeforeClass * method, it works directly with "TransactionManager", since * @BeforeClass methods with Spring TestContext do not run in the * context of a transaction (which is required for DAOs to work). */ TransactionStatus transactionStatus = transactionManager.getTransaction(null); testAccount = new Account(1, 10); try { accountDao.create(testAccount); transactionManager.commit(transactionStatus); } catch (Throwable e) { transactionManager.rollback(transactionStatus); throw e; } } DbUtil.java (y 3) public static void cleanDb() throws Exception { /* * For the same reason as "populateDb" (with regard to @AfterClass * methods), this method works directly with "TransactionManager". */ TransactionStatus transactionStatus = transactionManager.getTransaction(null); try { accountDao.remove(testAccount.getAccountId()); testAccount = null; transactionManager.commit(transactionStatus); } catch (Throwable e) { transactionManager.rollback(transactionStatus); throw e; } } } Spring + JUnit: Inicialización y Borrado de Datos Los métodos anotados con @BeforeClass y @AfterClass no se ejecutan dentro del contexto de una transacción Los datos se crean y borran utilizando los DAOs Estos DAOs necesitan ejecutarse dentro del contexto de una transacción Por tanto es necesario trabajar directamente con la API de transacciones de Spring (cuyas principales clases e interfaces se vieron en el apartado 3.4) El gestor de transacciones está declarado en el archivo de configuración de Spring como un bean y es posible obtenerlo a través del método getBean después de instanciar el contenedor Los DAOs también se obtienen invocando el método getBean sobre el contenedor Spring + JUnit: Transacciones @Transactional en la clase de pruebas permite que Spring TestContext ejecute cada método @Test en una transacción y que hace un rollback al final Las modificaciones que haya hecho el caso de prueba (e.g. testWithdrawFromAccount) a la BD se deshacen El estado de la BD es el mismo que el que se estableció en @BeforeClass cuando se ejecuta el siguiente caso de prueba Como ya se comentó con anterioridad, AccountServiceTest cachea la cuenta de prueba creada en una variable global (DBUtil.getTestAccount()) para evitar tener que leerla de BD en ciertos casos de prueba (e.g. testWithdrawFromAccount) Esta variable global no debe ser modificada, dado que ello afectaría a la ejecución de los siguientes casos de prueba (el rollback que hace Spring TestContext después de la ejecución de cada método @Test sólo afecta a la BD y no al estado de las variables globales)