Série Comment faire évoluer une application - Mise en place des Tests
Préambule
Dans l’article d’introduction de cette série (lien) nous avons présenté les différents problèmes de notre application (ajouter le lien). Le premier que je vous propose de résoudre dans cet article est l’ajout de tests.
Cet ajout de tests n’a pas pour unique but de couvrir du code et faire passer une quality gate ; il est là pour valider les règles métier et permettre une refactorisation avec moins de risques.
Dans cet article, nous verrons comment ajouter des tests à une application qui n’en a pas.
On abordera les tests unitaires, les tests d’intégrations et les tests de systèmes.
Définitions des différents types de tests
Un test unitaire est un test qui ne vérifie que la méthode d’une classe ou une classe. Les classes externes, souvent appelées “collaborateurs”, qui pourraient être appelées seront mockées. Nous placerons des tests unitaires essentiellement sur les services qui doivent contenir le code métier, c’est-à-dire le code avec de la valeur ajoutée. Un test d’intégration est un test qui à pour objectif de vérifier la bonne intégration de certaines parties d’un traitement et non celui-ci dans son ensemble. On concentrera nos tests d’intégration sur les endpoints et les repositories. Un test système est un test en boite noir qui va vérifier que l’ensemble du cas d’utilisation fonctionne.
Dans un cas comme celui de notre application, où aucun test n’existe, il est préférable de partir sur un test système pour vérifier rapidement le bon fonctionnement et la non-régression d’une fonctionnalité puis par la suite d’augmenter les tests unitaires et les tests d’intégration.
Tests unitaires
Notre application étant en java, nous utiliserons comme librairies pour les tests : Junit5, Mockito et AssertJ.
Paramétrage du test
Les dépendances sont à ajouter dans le build.gradle mais pour des raisons de lisibilité, je préfère créer un fichier unit-test.gradle qui contiendra toutes les configurations nécessaires. Ce fichier est appelé par le fichier build.gradle
Emplacement du fichier unit-test.gradle
Contenu du fichier unit-test.gradle
1dependencies {
2
3 testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter', version: '5.8.2'
4 testImplementation group: 'org.mockito', name: 'mockito-junit-jupiter', version: '4.2.0'
5 testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.21.0'
6 testImplementation group: 'org.mockito', name: 'mockito-core', version: '4.2.0'
7}
8
Dans le fichier build.gradle
1apply from: "gradle/unit-test.gradle"
Mise en place d’un test
Les règles pour écrire un test unitaire :
- Chaque test doit être créé dans le répertoire test
- Chaque classe de test se trouve dans le même package que la classe qu’il teste
- Le nom des méthodes de tests sont en snack_case (cf https://en.wikipedia.org/wiki/Snake_case )
- Le nombre de choses testées doit être limité, idéalement à 1.
Exemple de test
1
2@Test
3void should_add_question_to_form() {
4 // given
5 var formId = UUID.fromString("18b8f2e2-f41c-4ca4-a007-c328390cd099");
6 var savedForm = new Form();
7 var existingQuestion = new QuestionAnswer(formId, "What is your first name", null, savedForm);
8 var questions = new HashSet<QuestionAnswer>();
9 questions.add(existingQuestion);
10 savedForm.setQuestions(questions);
11 savedForm.setId(formId);
12
13 BDDMockito.when(formRepository.getById(formId)).thenReturn(savedForm);
14
15 var questionToSave =
16 new QuestionAnswer(null, "What is your objectives for the next year", null, null);
17
18 // when
19 formService.createQuestion(formId, questionToSave);
20
21 // then
22 var questionAnswerArgumentCaptor = ArgumentCaptor.forClass(QuestionAnswer.class);
23
24 BDDMockito.then(questionAnswerRepository).should().save(questionAnswerArgumentCaptor.capture());
25 var actualQuestions = questionAnswerArgumentCaptor.getValue();
26 BDDSoftAssertions.thenSoftly(
27 softly -> {
28 softly.then(actualQuestions).isNotNull();
29 softly.then(actualQuestions)
30 .extracting("form.questions", Assertions.as(InstanceOfAssertFactories.COLLECTION))
31 .hasSize(2);
32 });
33}
34
Détail du code
Les points les plus importants sont :
- L’utilisation de mock pour simuler le comportement des méthodes d’autre classes qui sont appelées
1BDDMockito.when(formRepository.getById(formId)).thenReturn(savedForm);
2
- L’utilisation d’ArgumentCaptor pour choper et ensuite valider le formulaire passé à la méthode save du repository
1 var questionAnswerArgumentCaptor = ArgumentCaptor.forClass(QuestionAnswer.class);
2
3 BDDMockito.then(questionAnswerRepository).should().save(questionAnswerArgumentCaptor.capture());
4 var actualQuestions = questionAnswerArgumentCaptor.getValue();
5
- L’utilisation de SoftAssertions pour pouvoir vérifier plusieurs choses. Contrairement à une validation via la classe Assertions qui va lever une erreur à la première erreur, SoftAssertions va tester toutes les conditions et remonter les erreurs par la suite.
1 BDDSoftAssertions.thenSoftly(
2 softly -> {
3 softly.then(actualQuestions).isNotNull();
4 softly.then(actualQuestions)
5 .extracting("form.questions", Assertions.as(InstanceOfAssertFactories.COLLECTION))
6 .hasSize(2);
7 });
Tests d’intégrations
Paramétrage du test
Pour la mise en place des tests d’intégration, nous allons créer un répertoire au même niveau que les répertoires test et main.
La séparation des tests unitaires et des tests d’intégration a l’avantage de nous permettre de distinguer quel type de tests on souhaite exécuter et quand cela aura son utilité lors de la mise en place de la CI/CD La configuration se fera comme dans les tests unitaires via un fichier gradle séparé.
Emplacement du fichier integration-test.gradle
Contenu du fichier integration-test.gradle
1sourceSets {
2 integrationTest {
3 java {
4 compileClasspath += sourceSets.main.output
5 runtimeClasspath += sourceSets.main.output
6 srcDir file('src/integration-test/java')
7 }
8 resources.srcDir file('src/integration-test/resources')
9 }
10}
11
12configurations {
13 integrationTestImplementation.extendsFrom implementation
14 integrationTestRuntimeOnly.extendsFrom runtimeOnly
15}
16
17dependencies {
18 integrationTestImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test'
19 integrationTestImplementation group: 'org.mockito', name: 'mockito-core', version: '4.2.0'
20 integrationTestImplementation group: 'org.mockito', name: 'mockito-inline', version: '4.2.0'
21}
22
23tasks.register('integrationTest', Test) {
24 description = 'Runs integration tests.'
25 group = 'verification'
26
27 testClassesDirs = sourceSets.integrationTest.output.classesDirs
28 classpath = sourceSets.integrationTest.runtimeClasspath
29 shouldRunAfter test
30 useJUnitPlatform()
31}
32
33check.dependsOn integrationTest
Dans le fichier build.gradle
1apply from: "gradle/unit-test.gradle"
2apply from: "gradle/integration-test.gradle"
3
Mise en place d’un test
La durée des tests d’intégrations étant plus longs, on va les limiter aux controllers et aux repositories ; les services, ont l’a vu plus haut, sont testés par des tests unitaires. Au niveau des controllers on aura pour objectifs de vérifier que les endpoints répondent correctement, c’est-à-dire qu’on vérifiera :
- la validité du statut retourné.
- la remontée d’erreur suite à une invalidité des paramètres ou dans le corp de la requête
Les services appelés dans les controllers seront mockés, les appels aux méthodes des services pourront être validés. Cette façon de tester nous fait comprendre pourquoi il faut limiter le code métier dans les controllers. Au niveau des repositories nous vérifierons la validité des requêtes, on se concentrera dans un premier temps sur les requêtes les plus complexes.
Exemple de test d’intégration d’un controller
1@WebMvcTest(controllers = InterviewController.class)
2public class InterviewControllerITest {
3
4 @Autowired Jackson2ObjectMapperBuilder mapperBuilder;
5
6 @Autowired private MockMvc mockMvc;
7
8 @MockBean private InterviewService interviewService;
9
10 @Test
11 @DisplayName("Should get all interviews")
12 void should_get_persons() throws Exception {
13 // when
14 mockMvc
15 .perform(
16 MockMvcRequestBuilders.get("/interviews")
17 .contentType(MediaType.APPLICATION_JSON)
18 .accept(MediaType.APPLICATION_JSON))
19 .andExpect(MockMvcResultMatchers.status().isOk());
20
21 // then
22 BDDMockito.then(interviewService).should().findAll();
23 }
24
25
Exemple de test d’intégration d’un repository
1
2@DataJpaTest
3@ActiveProfiles("it")
4public class FormRepositoryITest {
5 @Autowired
6 private FormRepository formRepository;
7
8 @Test
9 void should_create_form() {
10 // given
11 var questionAnswer = new QuestionAnswer(null, "What do you think about your work this year", null, null);
12 var formToSave = new Form(null, Set.of(questionAnswer));
13
14 // when
15 var actualForm = formRepository.save(formToSave);
16
17 // then
18 var idNotNull = new Condition<Form>((Form form) -> form.getId() != null, "form id not null");
19 var asOneQuestion = new Condition<Form>((Form form) -> form.getQuestions().size() == 1, "form has only one question");
20 BDDAssertions.then(actualForm)
21 .as("Check if a new form has been saved.")
22 .has(idNotNull)
23 .has(asOneQuestion);
24 }
25
26
Détail du code
Test des endpoints
Pour les tests des endpoints on annote les classes tests avec @WebMvcTest , qui permet de charger un environnement minimal afin de pouvoir tester uniquement le controller défini dans l’annotation.
1@WebMvcTest(controllers = InterviewController.class)
La classe MockMvc de spring permet de tester les endpoint sans pour autant charger tout le contexte de l’application.
1 mockMvc
2 .perform(
3 MockMvcRequestBuilders.get("/interviews")
4 .contentType(MediaType.APPLICATION_JSON)
5 .accept(MediaType.APPLICATION_JSON))
6 .andExpect(MockMvcResultMatchers.status().isOk());
Afin de tester uniquement l’endpoint (code retour, objet retourné …) on mock le service qui est appelé avec l’annotation @MockBean de spring
1 @MockBean private InterviewService interviewService;
Test des repositories
Les tests d’intégration entre un repository et la bases de données se fait avec l’annotation @DataJpaTest
Lors d’un test d’enregistrement d’un objet, il est possible de vérifier l’identifiant généré de l’objet en retour
Pour des méthodes de type find, findAll, findId nous pouvons utiliser l’annotation @Sql afin de peupler la base de données avant de faire le test. Ceci se révèle utile lorsque la base de données au démarrage est vide. Ici je ne l’ai pas fait, mais il est tout à fait possible de démarrer les tests avec une base de données pré-initialisée.
1@Sql(scripts = {"/sql_scripts/initialize_form.sql"} )
Tests system
Paramétrage du test
Tout comme pour les tests d’intégration les tests system sont dans un répertoire distinct ; La configuration se trouve dans le fichier system-test.gradle
Emplacement du fichier system-test.gradle
Contenu du fichiersystem-test.gradle
1sourceSets {
2 systemTest {
3 java {
4 compileClasspath += sourceSets.main.output
5 runtimeClasspath += sourceSets.main.output
6 srcDir file('src/system-test/java')
7 }
8 resources.srcDir file('src/system-test/resources')
9 }
10}
11
12configurations {
13 systemTestImplementation.extendsFrom implementation
14 systemTestRuntimeOnly.extendsFrom runtimeOnly
15}
16
17dependencies {
18 systemTestImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test'
19 systemTestImplementation group: 'io.rest-assured', name: 'rest-assured', version: '4.4.0'
20 systemTestImplementation group: 'org.mockito', name: 'mockito-core', version: '4.2.0'
21 systemTestImplementation group: 'org.mockito', name: 'mockito-inline', version: '4.2.0'
22
23
24}
25
Dans le fichier build.gradle
1
2apply from: "gradle/unit-test.gradle"
3apply from: "gradle/integration-test.gradle"
4apply from: "gradle/system-test.gradle"
5
6systemTest {
7 shouldRunAfter integrationTest
8}
9
Mise en place d’un test
1
2@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
3class PostInterviewTest {
4 @Autowired Jackson2ObjectMapperBuilder mapperBuilder;
5
6 @LocalServerPort
7
8 private Integer port;
9
10 @BeforeAll
11 public static void setup() {
12 RestAssured.baseURI = "http://localhost";
13 }
14 @Test
15 @Sql(scripts = "/sql_scripts/init_database.sql")
16 void should_create_interview() throws JsonProcessingException {
17 var manager =
18 new Person(UUID.fromString("8a37adbc-0c29-491a-8b6f-f10a4219fb91"), null, null, true);
19 var employee =
20 new Person(UUID.fromString("3556c835-3bcd-420e-bb6e-a8b7c0bb139d"), null, null, false);
21 var form = new Form(UUID.fromString("df9b2aad-d32e-469e-b434-be71a5531a35"), new HashSet<>());
22 var interview = new Interview(null, Instant.now(), manager, employee, form);
23
24 RestAssured.given()
25 .header("Content-type", "application/json")
26 .and()
27 .port(port)
28 .body(mapperBuilder.build().writeValueAsString(interview))
29 .when()
30 .post("/interviews")
31 .then()
32 .statusCode(HttpStatus.SC_CREATED);
33 }
34}
35
Détails du code
Les points à noter dans ce test sont :
- L’utilisation de l’annotation SpringBootTest qui permet de charger l’ensemble du contexte spring.
- L’utilisation de RestAssured pour tester les endpoints.
Résumé
- Il existe trois types de bases de tests : les tests unitaires qui testent une méthode et uniquement le contenu de la méthode, les tests d’intégration qui valident la bonne intégration entre deux couches (appel des endpoints, connexion repository / base de données) et enfin les tests systèmes qui permettent de valider le bon fonctionnement d’une fonctionnalité
- Pour une API existante et dépourvue de tests, il est possible de commencer par la mise en place de tests système sur les endpoints les plus critiques puis d’ajouter progressivement des tests d’intégration et des tests unitaires.
- Pour une nouvelle API il est préférable de commencer par des tests unitaires et de progresser vers les tests systèmes
Si vous avez des remarques sur le contenu, la forme vous pouvez laisser un commentaire… c’est en échangeant qu’on progresse.
Écrit par : Emmanuel Quinton Revue par : Daniele Cremonini
Commentaires