Comment Faire Évoluer Une Application Pour La Rendre Plus Robuste Et Maintenable - Mise en Place De Tests

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.

test_pyramid4.png

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

unit-test-gradle-file-location

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}

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}

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);
  • 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();
  • 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.

integration-test-folder.png

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

unit-test-gradle-file-location.png

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"

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 }

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   }

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

system-test-folder.png

Emplacement du fichier system-test.gradle

system-test-gradle-file-location.png

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}

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}

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}

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


CC BY-NC-ND 4.0

Hors Ligne
Comment Faire Évoluer Une API Pour La Rendre Plus Robuste Et Maintenable - Presentation

Commentaires