Qui ne teste rien, n'a rien - c'est comme ça qu'on pourrait paraphraser le fameux proverbe "qui ne tente rien, n'a rien" dans le monde du développement. Les tests sont obligatoires pour s'assurer du bon fonctionnement de l'application suite à des changements effectuées. Parfois ils font partie importante de certaines méthodes de développement (par exemple Test Driven Development).
Notre application d'exemple contient également une série des tests. Leur but est plutôt de vous présenter certaines fonctionnalités qui peuvent être implémentées pour s'assurer du bon fonctionnement du système. C'est la raison pour laquelle toutes ses parties ne sont pas couvertes des tests.
Test unitaires en théorie
On a abordé ce concept théorique très brièvement dans l'article consacré à des test sous Symfony2. Ici on va donc rappeler les fondamentaux et les élargir dans le contexte d'une application web Java.
Dans les tests unitaires il s'agit d'isoler chaque composant de l'application dans le but de vérifier son fonctionnement. Dans Spring ils sont très utiles dans la vérification du comportement des méthodes des services. Ils peuvent être également employés dans la validation des contrôleurs.
Qu'est-ce que c'est JUnit ?
En PHP, l'outil de tests incontournable est PHPUnit. Son homologue en Java s'appelle JUnit. Il s'agit d'un framework de tests unitaires pour les applications écrites en Java. Grâce à des annotations, on peut facilement manipuler le scénario d'exécution du test. Plusieurs méthodes (assertions) permettent de vérifier le comportement du système selon les résultats très variés.
Concernant les annotations, leurs noms sont très explicites :
- @Before : code est exécuté avant chaque test.
- @BeforeClass : code de la méthode annotée avec @BeforeClass est exécuté avant le premier test et avant la méthode @Before
- @After : code est exécuté après chaque test
- @AfterClass : code est invoquée après l'exécution de tous les tests
En ce qui concerne les méthodes utilisées pour la vérification des résultats, elles font partie de la classe org.junit.Assert. Les noms de la plupart d'entre elles commencent par assert. Grâce à elles on peut donc vérifier si les deux instances sont identiques, si une expression est vraie ou nulle. La seule fonction qui ne respecte pas cette règle de nommage est fail qui fait échouer le test.
Configurer JUnit dans Spring
L'implémentation des tests pour notre application se base sur les deux étapes. La première est la configuration du fichier build.xml qui est utilisé par ANT dans la préparation des JARs. En occurrence, on utilisera la commande ant test pour lancer des tests. Voici le fragment du fichier de configuration :
<target name="test" depends="jar"> <junit printsummary="yes" haltonfailure="yes"> <classpath refid="classpath" /> <classpath refid="classpathResin" /> <test fork="yes" name="library.test.SubscriptionTest" todir="${test.dir}" haltonfailure="no" outfile="SubscriptionTest"> <formatter type="plain"/> </test> <test fork="yes" name="library.test.XSSCleanerTest" todir="${test.dir}" haltonfailure="no" outfile="XSSCleanerTest"> <formatter type="plain"/> </test> <test fork="yes" name="library.test.SubscriptionControllerTest" todir="${test.dir}" haltonfailure="no" outfile="SubscriptionControllerTest"> <formatter type="plain"/> </test> <test fork="yes" name="library.test.BookingControllerTest" todir="${test.dir}" haltonfailure="no" outfile="BookingControllerTest"> <formatter type="plain"/> </test> </junit> </target>
Dans la partie <unit /> on précise si l'on veut voir les résultats affichés dans la console (printsummary). Plus loin on définit les classpaths et les tests à lancer. Chaque test possède le nom de la classe (name), le repértoire de sauvegarde du résultat (todir) et le nom du fichier avec le résultat (outfile).
La seconde étape consiste à écrire les tests définis par l'attribut name. On commencera par le contrôleur abstrait qui va charger toute la configuration nécessaire dans l'annotation @ContextConfiguration et déterminer quelle classe doit exécuter les tests (@RunWith) :
@ContextConfiguration(locations={"file:///D:/resin-4.0.32/webapps/ROOT/META-INF/spring/test-config.xml"}) @RunWith(SpringJUnit4ClassRunner.class) public class AbstractControllerTest extends AbstractTransactionalJUnit4SpringContextTests { }
On voit que le @ContextConfiguration charge un nouveau fichier de contexte :
<bean id="servletContext" class="org.springframework.mock.web.MockServletContext" /> <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"> <property name="driverClass" value="com.mysql.jdbc.Driver" /> <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/library_test" /> <property name="user" value="root" /> <property name="password" value="root" /> </bean> <import resource="./jpa-tx-config.xml"/> <context:annotation-config/> <context:component-scan base-package="library.service.impl"/> <import resource="tools.xml"/> <bean id="mailerToolTest" name="mailerTool" class="library.tools.MailerTool"> <property name="mailSender" ref="mailSender" /> <property name="velocityEngine" ref="velocityEngine"/> <property name="sender" value="bartkonieczny@gmail.com" /> <property name="localeResolver" ref="localeResolver" /> <property name="servletContext" ref="servletContext" /> </bean> <import resource="security.xml"/><import resource="blogapp-webmvc-config.xml"/> <import resource="conv-app-context.xml"/> <import resource="jsr303-app-context.xml"/> <bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager"> <property name="caches"> <set> <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="default"/> <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="books"/> </set> </property> </bean>
Un point important se cache sous la balise <import />. Comme son nom indique, elle charge les fichiers de contexte déjà définis. Juste dans une seule situation, mailerTool, on surcharge le bean y précisé. La raison pour laquelle on le fait se cache dans le bean servletContext représenté par la classe org.springframework.mock.web.MockServletContext. Nos tests ne sont pas lancés via l'interface web, mais un outil de commande en ligne (il n'y a donc pas de contexte de l'utilisateur - les requêtes, les sessions...). On doit donc fournir un substitut à des requêtes et réponses générées correctement par les servlets. Il s'agit des objets qu'on appelle mock objects.
Quelle définition on pourrait associer à ces objets "mock" ? Dans la programmation orientée objet il s'agit des objets de substitution qui imitent le comportement de réels objets selon des méthodes strictement définies. Ces objets sont le plus souvent utilisés dans le cas des tests où la récupération de réels objets se révèle impossible. Dans le cas de nos tests, on emploiera les objets mocks pour remplacer l'instance HttpServletRequest. Le mock utilisé sera MockServletContext.
Ecrire un JUnit test sous Spring
Dans notre application on a créé les tests pour 4 scénarios. Cependant ici on n'évoquera qu'un seul. Il regroupe toutes les difficultés rencontrées dans d'autres scénarios. Le contrôleur testé concerne l'enregistrement de l'utilisateur :
public class SubscriptionControllerTest extends AbstractControllerTest { @Autowired private SubscriberController subscriberController; @Autowired private SubscriberRepository subscriberRepository; @Autowired private Validator validator; @Autowired private ConversionService conversionService; @Autowired private EntityManagerFactory emf; private static AnonymousAuthenticationToken anonymousUser; private static AuthenticationFrontendUserDetails frontendUser; private static Subscriber existingSubscriber; @BeforeClass public static void initParticipants() { anonymousUser = new AnonymousAuthenticationToken("anonymous", "anonymous", new ArrayList(Arrays.asList(new GrantedAuthorityImpl("ROLE_ANONYMOUS")))); frontendUser = new AuthenticationFrontendUserDetails("bartosz", "bartosz", true, true, true, true, new ArrayList(Arrays.asList( new GrantedAuthorityImpl("ROLE_USER")))); System.out.println("Test launches with anonymousUser :"+anonymousUser); System.out.println("Test launches with frontendUser :"+frontendUser); } @Test public void testRegisterInvalid() { existingSubscriber = subscriberRepository.loadByUsername("kamil"); System.out.println("Test launches with existingSubscriber :"+existingSubscriber); long subscriberCount = subscriberRepository.countAllUsers(); // MockHttpServletRequest request = new MockHttpServletRequest("POST", "/register"); SecurityContextHolder.getContext().setAuthentication(anonymousUser); Subscriber subscriber = new Subscriber(); subscriber.setLogin("test00"); subscriber.setPassword("testeurPass"); subscriber.setPasswordRepeated("testeurPass"); subscriber.setEmail(existingSubscriber.getEmail()); // subscriber.setEmail("bartkonieczny+35904033@gmail.com"); System.out.println("Testing subscriber "+subscriber); TraversableResolver traversableResolver = new JPATraversableResolver(); ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); ValidatorContext validatorContext = validatorFactory.usingContext(); validatorContext.traversableResolver(traversableResolver); validatorContext.constraintValidatorFactory(new TestConstraintValidatorFactory(emf)); Validator validatorHibernate = validatorContext.getValidator(); // Validator validatorHibernate = Validation.buildDefaultValidatorFactory().usingContext().traversableResolver(traversableResolver).getValidator(); Set> constraintViolations = validatorHibernate.validate(subscriber, SubscriberRegisterCheck.class); BindingResult bindingResult = conversionService.convert(constraintViolations, BeanPropertyBindingResult.class); if(bindingResult == null) bindingResult = new BeanPropertyBindingResult(subscriber, "subscriber"); System.out.println("BindingResult after conversion " + bindingResult); Assert.assertEquals("Invalid validation errors quantity", 1, bindingResult.getErrorCount()); String viewResult = subscriberController.registerHandle(subscriber, bindingResult, new RedirectAttributesModelMap()); subscriberCount++; Assert.assertThat("Subscriber count is invalid", subscriberCount, not(subscriberRepository.countAllUsers())); } @Test public void testRegisterValid() { long subscriberCount = subscriberRepository.countAllUsers(); // MockHttpServletRequest request = new MockHttpServletRequest("POST", "/register"); SecurityContextHolder.getContext().setAuthentication(anonymousUser); Subscriber subscriber = new Subscriber(); subscriber.setLogin("test00"); subscriber.setPassword("testeurPass"); subscriber.setPasswordRepeated("testeurPass"); subscriber.setEmail("bartkonieczny+35904033@gmail.com"); System.out.println("Testing subscriber "+subscriber); TraversableResolver traversableResolver = new JPATraversableResolver(); ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); ValidatorContext validatorContext = validatorFactory.usingContext(); validatorContext.traversableResolver(traversableResolver); validatorContext.constraintValidatorFactory(new TestConstraintValidatorFactory(emf)); Validator validatorHibernate = validatorContext.getValidator(); Set > constraintViolations = validatorHibernate.validate(subscriber, SubscriberRegisterCheck.class); BindingResult bindingResult = conversionService.convert(constraintViolations, BeanPropertyBindingResult.class); if(bindingResult == null) bindingResult = new BeanPropertyBindingResult(subscriber, "subscriber"); System.out.println("BindingResult after conversion " + bindingResult); Assert.assertEquals("Invalid validation errors quantity", 0, bindingResult.getErrorCount()); String viewResult = subscriberController.registerHandle(subscriber, bindingResult, new RedirectAttributesModelMap()); subscriberCount++; Assert.assertEquals("Subscriber count is invalid", subscriberCount, subscriberRepository.countAllUsers()); }
Dans notre commentaire on ne va pas se focaliser sur toutes les assertions de cette classe. Par contre, on abordera les aspects qui posent souvent les soucis lors de l'écriture et de l'exécution des tests unitaires et fonctionnels sous Spring.
Tout d'abord, comment simuler l'activité d'un utilisateur connecté dans l'application ? Cela se fait via SecurityContextHolder.getContext().setAuthentication(). Quand cette ligne est absente et on tente d'accéder à des méthodes qui nécessitent l'instance de l'utilisateur connecté, on reçoit l'exception suivante : An Authentication object was not found in the SecurityContext.
Un autre problème rencontré est lié à la validation. L'exception lancée était HV000041: Call to TraversableResolver.isReachable() threw an exception. javax.validation.ValidationException: HV000041: Call to TraversableResolver.isReachable() (...) Caused by: java.lang.NullPointerException at javax.persistence.Persistence$1.isLoaded(Persistence.java:93). C'est un bug qu'il faut contourner par la surcharge du traversable resolver. La nouvelle classe implémentant javax.validation.TraversableResolver doit être passée au contexte de validation de cette manière : validatorContext.traversableResolver(traversableResolver);. Le traversable resolver surchargé se présente ainsi :
public class JPATraversableResolver implements TraversableResolver { @Override public boolean isReachable(Object traversableObject, Path.Node traversableProperty, Class<?> rootBeanType, Path pathToTraversableObject, ElementType elementType) { return traversableObject == null || Hibernate.isInitialized(traversableObject); } @Override public boolean isCascadable(Object traversableObject, Path.Node traversableProperty, Class<> rootBeanType, Path pathToTraversableObject, ElementType elementType) { return true; } }