De nos jours, les tests unitaires sont tellement répandus que les développeurs qui n'en font pas tiennent leur tête baissée de honte. Wikipedia définit le test unitaire ainsi :
En programmation informatique, le test unitaire [...] est une procédure permettant de vérifier le bon fonctionnement d'une partie précise d'un logiciel ou d'une portion d'un programme [...].
Dans les langages Orienté-Objet et particulièrement en Java, l'usage général est que la portion de programme soit la méthode. Afin d'illustrer ce point, utilisons un exemple simple de l'application démo de Spring, la Clinique Vétérinaire. Un extrait du PetController est reproduit ici pour votre convenance :
@Controller @SessionAttributes("pet") public class PetController { private final ClinicService clinicService; @Autowired public PetController(ClinicService clinicService) { this.clinicService = clinicService; } @ModelAttribute("types") public Collection<PetType> populatePetTypes() { return this.clinicService.findPetTypes(); } @RequestMapping(value = "/owners/{ownerId}/pets/new", method = RequestMethod.GET) public String initCreationForm(@PathVariable("ownerId") int ownerId, Map<String, Object> model) { Owner owner = this.clinicService.findOwnerById(ownerId); Pet pet = new Pet(); owner.addPet(pet); model.put("pet", pet); return "pets/createOrUpdatePetForm"; } ... }
Comme on peut le voir, la méthode initCreationForm()
est conçue pour peupler le formulaire HTML qui permet de créer de nouvelles instances de Pet
. Notre objectif va être de tester cette méthode sur les points suivants :
- Mettre une instance de
Pet
dans le modèle - Valoriser le
Owner
de cette instance dePet
- Retourner une vue spécifique
Le Bon Vieux Test Unitaire Classique
public class PetControllerTest { private ClinicService clinicService; private PetController controller; @Before public void setUp() { clinicService = Mockito.mock(ClinicService.class); controller = new PetController(clinicService); } @Test public void should_set_pet_in_model() { Owner dummyOwner = new Owner(); Mockito.when(clinicService.findOwnerById(1)).thenReturn(dummyOwner); HashMap<String, Object> model = new HashMap<String, Object>(); controller.initCreationForm(1, model); Iterator<Object> modelIterator = model.values().iterator(); Assert.assertTrue(modelIterator.hasNext()); Object value = modelIterator.next(); Assert.assertTrue(value instanceof Pet); Pet pet = (Pet) value; Owner petOwner = pet.getOwner(); Assert.assertNotNull(petOwner); Assert.assertSame(dummyOwner, petOwner); } }
- La méthode
setUp()
initialise le contrôleur à tester et la dépendance enversClinicService
. Comme il s'agit d'une dépendance, Mockito permet de la mocker. - La méthode
should_set_pet_in_model()
vérifie qu'après l'exécution de la méthode, le modèle contient une instance dePet
et qu'il a pour propriétaire celui retourné par leClinicService
mocké. - Notez que le retour de la vue n'est pas testé, car le code de test serait exactement identique au code du contrôleur.
Ce qu'il manque au Test Unitaire
A ce point, nous avons confortablement atteint notre couverture de test de 100% pour la méthode et nous pourrions nous arrêter. Toutefois, s'arrêter juste après les tests unitaires reviendrait à commencer la production de masse de voitures après avoir testé chaque vis et chaque boulon d'une voiture. Bien sûr, personne ne songerait à prendre un tel risque ; dans la vraie vie, la voiture serait amenée sur de nombreux bancs d'essai puis sur circuit pour vérifier non seulement l'assemblage de chaque boulon mais également que toutes les autres pièces fonctionnent de manière coordonnée comme prévu. Dans le monde du développement logiciel, le test sur circuit garantit le bon fonctionnement de la collaboration entre les classes.
Dans le monde Java, le framework Spring et les plate-formes Java EE sont des containers qui fournissent des APIs par dessus des services disponibles, par exemple JDBC pour l'accès aux bases de données. Pour être sûr que les applications développées avec Spring ou Java EE fonctionnent de manière nominale, il est nécessaire qu'elles soient testées in-container pour tester les interactions avec les services offerts par le container.
Dans l'exemple de test ci-dessus, certaines choses ne sont pas testées - et ne peuvent pas l'être :
- La configuration Spring : l'assemblage de toutes les classes par l'autowiring
- La mise à disposition du
PetType
dans le modèle : la méthodepopulatePetTypes()
- Le mappage entre le contrôleur désiré et l'URL : l'annotation
@RequestMapping
- La mise à disposition de l'instance de
Pet
dans la session :@SessionAttributes("pet")
Tester In-Container
Les tests d'intégration et plus spécifiquement les "tests in-container" sont la solution pour tester les points ci-dessus. Heureusement, Spring fournit un framework de test complet pour cela, et avec le framework de test Arquillian, les utilisateurs de Java EE peuvent aussi être de la partie. Néanmoins, les applications Java EE ont une méthode d'assemblage spécifique avec CDI, et Arquillian fournit les moyens de faire face à de telles différences. Après ce rappel, retournons à notre Clinique Vétérinaire et créons des tests pour vérifier les points ci-dessus.
Intégration Spring JUnit
Comme son nom l'indique, JUnit est un framework pour les tests unitaires. Spring fournit un runner dédié à JUnit pour démarrer le container Spring au démarrage du test. Ceci est rendu possible via l'annotation @RunWith
sur la classe de test :
@RunWith(SpringJUnit4ClassRunner.class) public class PetControllerIT { ... }
Le framework Spring dispose de son propre ensemble de composants de configuration : soit les vieux fichiers XML ou plus récemment les classes Java de configuration. Une bonne pratique est d'avoir des composants de configuration de granularité fine de telle manière que l'on puisse choisir uniquement ceux qui sont nécessaires dans le contexte d'un test. Ces composants de configuration peuvent être choisis via l'annotation @ContextConfiguration
sur la classe de test.
@ContextConfiguration("classpath:spring/business-config.xml") public class PetControllerIT { ... }
Finalement, Spring permet à certains composants de configuration d'être activés (ou pas) en se basant sur un booléen au périmètre de l'application appelé un profil. L'utilisation d'un profil est aussi facile que d'ajouter l'annotation @ActiveProfile
sur la classe de test :
@ActiveProfiles("jdbc") public class PetControllerIT { ... }
Ceci est suffisant pour tester des beans Spring standard avec JUnit, mais pour le test de contrôleurs Spring, des efforts supplémentaires sont requis.
Contexte Web Spring pour les Tests
Pour les applications web, Spring crée un contexte structuré avec une relation parent-enfant dans une architecture en couches. L'enfant contient ce qui est relatif au web, comme les contrôleurs, les formateurs, les ressources internationalisées, etc. Le parent contient le reste, comme les services et les référentiels. Afin d'émuler cette relation, annotez la classe de test avec l'annotation @ContextHierarchy
et configurez cette dernière pour référencer les annotations @ContextConfiguration
nécessaires.
Dans l'extrait suivant, business-config.xml
est le parent et mvc-core-config.xml
l'enfant :
@ContextHierarchy({ @ContextConfiguration("classpath:spring/business-config.xml"), @ContextConfiguration("classpath:spring/mvc-core-config.xml") })
Il est également nécessaire d'utiliser l'annotation @WebAppConfiguration
pour émuler un WebApplicationContext
au lieu d'un simple ApplicationContext
.
Tester les Contrôleurs
Une fois le contexte web configuré comme décrit plus haut, il est enfin possible de tester les contrôleurs. Le point d'entrée pour ce faire est la classe MockMvc
qui contient les attributs suivants :
- Un
Request Builder
pour la création de requêtes Fake - Un
Request Matcher
pour la vérification du résultat de l'exécution d'une méthode de contrôleur - Un
Result Handler
pour effectuer des opérations arbitraires sur le résultat
Des instances de MockMvc
sont offertes via les méthodes statiques de la classe MockMvcBuilders
. L'une est dédiée à un ensemble de contrôleurs, l'autre à l'intégralité du contexte d'application. Dans ce dernier cas, une instance de WebApplicationContext
est un paramètre nécessaire. C'est plutôt facile : il suffit d'injecter un attribut de ce type dans la classe de test.
@RunWith(SpringJUnit4ClassRunner.class) public class PetControllerIT { @Autowired private WebApplicationContext context; }
L'exécution du MockMvc
configuré est alors faite via la méthode perform(RequestBuilder)
. A leur tour, des instances de RequestBuilder
sont rendues disponibles via des méthodes statiques de la classe MockMvcRequestBuilders
. Chaque méthode statique permet d'exécuter une méthode HTTP différente.
Pour résumer, voici comment l'on peut simuler un appel GET
de la ressource /owners/1/pets/new
.
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(PetController.class).build(); RequestBuilder getNewPet = MockMvcRequestBuilders.get("/owners/1/pets/new"); mockMvc.perform(getNewPet);
Enfin, Spring fournit un bon nombre d'assertions via l'interface ResultMatcher
et l'expressivité de l'API MockMvc
: cookies, contenu, modèle sous-jacent, code statut HTTP, tout ceci peut être vérifié et plus encore.
Assemblage Final
Tout est maintenant disponible pour tester le contrôleur précédent d'une manière qui ne pouvait l'être avec les tests unitaires.
1 @RunWith(SpringJUnit4ClassRunner.class) 2 @ContextHierarchy({ 3 @ContextConfiguration("classpath:spring/business-config.xml"), 4 @ContextConfiguration("classpath:spring/mvc-core-config.xml") 5 }) 6 @WebAppConfiguration 7 @ActiveProfiles("jdbc") 8 public class PetControllerIT { 9 10 @Autowired 11 private WebApplicationContext context; 12 13 @Test 14 public void should_set_pet_types_in_model() throws Exception { 15 MockMvc mockMvc = webAppContextSetup(context).build(); 16 RequestBuilder getNewPet = get("/owners/1/pets/new"); 17 mockMvc.perform(getNewPet) 18 .andExpect(model().attributeExists("types")) 19 .andExpect(request().sessionAttribute("pet", instanceOf(Pet.class))); 20 } 21 }
Voilà ce qui se passe dans cet extrait :
- Aux lignes 3 et 4, nous garantissons que les fichiers de configuration sont correctement configurés.
- A la ligne 16, nous nous assurons que l'application répond à un appel
GET
de l'URL de préparation du formulaire. - A la ligne 18, nous testons la présence de l'attribut
types
dans le modèle. - Enfin, à la ligne 19, nous testons la présence de l'attribut
pet
dans la session, et que son type est bienPet
.
(Notez que la méthode statique instanceOf()
provient de l'API Hamcrest).
Autres Défis
L'exemple précédent concernant la Clinique Vétérinaire était plutôt simple. Elle utilise déjà la base de données Hypersonic SQL, crée le schéma de base de données automatiquement et insère des données, et tout ceci au démarrage du container. Au sein d'applications classiques, on utilisera probablement des bases de données différentes pour la production et durant les tests. De plus, les données ne seront pas initialisées pour la production. Les défis comprennent la manière de basculer sur une autre base de données pour le test, comment mettre la base de données dans l'état requis avant l'exécution du test et comment vérifier celui-ci après.
De la même manière, le PetController
a été testé pour la seule méthode initCreationForm()
alors que le processus de création comprend également la méthode processCreationForm()
. Afin de réduire la quantité de code d'initialisation, il n'est pas déraisonnable de ne pas tester chaque méthode mais le cas d'utilisation lui-même. Cela implique probablement une méthode de test immense : si le test échoue, il sera difficile de localiser la cause de l'échec. Une autre approche serait de créer un ensemble de méthodes correctement nommées à granularité fine et de les exécuter dans l'ordre. Malheureusement, JUnit étant un framework de test unitaire véritable, il n'offre pas cette possibilité.
Chaque composant qui interagit avec une ressource d'infrastructure, comme une base de données, un serveur de mails, un serveur FTP, etc, doit répondre au même défi : mocker cette ressource n'ajoute pas de valeur au test. Par exemple, comment peut-on tester des requêtes JPA complexes ? Ceci nécessite plus que seulement le mocking de la base de données ; la pratique répandue est de mettre en place une base de données mémoire. Il peut y avoir de meilleures alternatives, en fonction du contexte. Le défi dans ce cas consiste à choisir la bonne alternative et de gérer le cycle de vie de la ressource à l'intérieur du test.
Parmi les ressources d'infrastructure, les services web représentent une part importante des dépendances d'une application web moderne. C'est encore plus vrai au regard de la tendance actuelle vers les microservices. Si une application est dépendante de services web externes, tester la collaboration d'une telle application avec ses dépendances devient une exigence. Bien sûr, la mise en place des dépendances de services web est très dépendante de leur nature, soit REST soit SOAP.
De plus, si l'application n'utilise pas Spring mais Java EE, les défis sont différents. Java EE fournit le service d'Injection de Contexte et de Dépendances, basé sur l'autowiring. Tester une telle application signifie l'assemblage de l'ensemble correct de composants - des fichiers et des classes de configuration. En outre, Java EE s'engage à ce que la même application puisse être exécutée sur différents serveurs d'application approuvés. Si une application a plusieurs plate-formes cibles différentes, par exemple car il s'agit d'un produit destiné à être déployé dans différents environnements client, cette compatibilité doit être testée en profondeur.
Conclusion
Dans cet article, j'ai montré comment les techniques de test d'intégration peuvent vous donner plus de confiance dans votre code en utilisant le framework Spring MVC comme illustration.
J'ai aussi brièvement montré que le test présente des défis qui ne peuvent pas être résolus avec uniquement des tests unitaires, mais qui nécessitent des tests d'intégration.
Il s'agit de techniques basiques. Pour approfondir, s'approprier d'autres techniques et des outils supplémentaires, merci de consulter "Integration Testing from the Trenches" par votre serviteur, où je présente de tels outils et techniques qui peuvent être utilisés pour mieux garantir la qualité de vos logiciels.
L'ouvrage a bénéficié d'une revue sur InfoQ et est disponible dans tous les formats électroniques classiques sur Leanpub et au format papier sur Amazon.
A propos de l'Auteur
Nicolas Fränkel est un architecte et développeur Java et Java EE avec plus de 12 ans d'expérience en tant que consultant pour différents clients. Il enseigne aussi comme formateur et vacataire dans des institutions d'éducation supérieure en France et en Suisse pour parfaire sa compréhension du génie logiciel. Nicolas a également donné des conférences dans plusieurs évènements liés à Java en Europe, comme Devoxx Belgique, JEEConf, JavaLand et des Java User Groups et est l'auteur de Learning Vaadin et Learning Vaadin 7. Le périmètre de ses intérêts dans le logiciel est étendu, depuis les Applications Client Riche à l'Open Source et l'Automatisation du Build via les Processus de Qualité et comprend plusieurs variétés de test.