Tester par l'exemple

Publié: 2022-02-15

Nous poursuivons notre série de blogs sur tout ce qui concerne les tests. Dans ce blog, nous nous concentrons sur des exemples réels.

Bien que les exemples de cet article soient écrits à l'aide de JUnit 5 et AssertJ, les leçons sont applicables à tout autre cadre de test unitaire.

JUnit est le framework de test le plus populaire pour Java. AssertJ est une bibliothèque Java qui aide les développeurs à écrire des tests plus expressifs.

Structure de test de base

Le premier exemple de test que nous allons examiner est une calculatrice simple pour additionner 2 nombres.

 classe CalculatriceDevrait {

     @Test // 1
     somme nulle() {
         Calculatrice calculatrice = new Calculatrice(); // 2
         int result = calculatrice.sum(1, 2); // 3
         assertThat(result).isEqualTo(3); // 4
     }
}

Je préfère utiliser la convention de dénomination ClassShould lors de l'écriture de tests pour éviter de répéter should ou test dans chaque nom de méthode. Vous pouvez en savoir plus ici.

A quoi sert le test ci-dessus ?

Décomposons le test ligne par ligne :

  1. L'annotation @Test permet au framework JUnit de savoir quelles méthodes doivent être exécutées en tant que tests. Il est parfaitement normal d'avoir des méthodes private dans la classe de test qui ne sont pas des tests.
  2. Il s'agit de la phase d'organisation de notre test, où nous préparons l'environnement de test. Tout ce dont nous avons besoin pour ce test est d'avoir une instance de Calculator .
  3. C'est la phase d'action où nous déclenchons le comportement que nous voulons tester.
  4. Il s'agit de la phase d' affirmation au cours de laquelle nous inspectons ce qui s'est passé et si tout s'est résolu comme prévu. assertThat(result) fait partie de la bibliothèque AssertJ et comporte plusieurs surcharges.

Chaque surcharge renvoie un objet Assert spécialisé. L'objet renvoyé a des méthodes qui ont un sens pour l'objet que nous avons passé à la méthode assertThat . Dans notre cas, cet objet est AbstractIntegerAssert avec des méthodes pour tester les entiers. isEqualTo(3) vérifiera si result == 3 . Si c'est le cas, le test réussira et échouera sinon.

Nous ne nous concentrerons sur aucune implémentation dans cet article de blog.

Une autre façon de penser à organiser , agir , affirmer est donné , quand , alors .

Après avoir écrit notre implémentation de sum , nous pouvons nous poser quelques questions :

  • Comment puis-je améliorer ce test ?
  • Y a-t-il d'autres cas de test que je devrais couvrir ?
  • Que se passe-t-il si j'ajoute un nombre positif et un nombre négatif ? Deux nombres négatifs ? Un positif et un négatif ?
  • Que se passe-t-il si je dépasse la valeur entière ?

Ajoutons ces cas et améliorons un peu le nom de test existant.

Nous n'autoriserons pas les débordements dans notre implémentation. Si sum déborde, nous lancerons une ArithmeticException à la place.

 classe CalculatriceDevrait {

     Calculatrice privée calculatrice = new Calculator();

     @Test
     void sumPositiveNumbers() {
         int somme = calculatrice.somme(1, 2);
         assertThat(somme).isEqualTo(3);
     }

     @Test
     void sumNegativeNumbers() {
         somme int = calculatrice.somme(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @Test
     void sumPositiveAndNegativeNumbers() {
         somme int = calculatrice.somme(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @Test
     void failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() -> calculatrice.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit créera une nouvelle instance de CalculatorShould avant d'exécuter chaque méthode @Test . Cela signifie que chaque CalculatorShould aura une calculator différente afin que nous n'ayons pas à l'instancier dans chaque test.

shouldFailWithArithmeticExceptionWhenOverflown test utilise un autre type d' assert . Il vérifie qu'un morceau de code a échoué. La méthode assertThatThrownBy exécutera le lambda que nous avons fourni et s'assurera qu'il a échoué. Comme nous le savons déjà, toutes les méthodes assertThat renvoient un Assert spécialisé nous permettant de vérifier quel type d'exception s'est produit.

Ceci est un exemple de la façon dont nous pouvons tester que notre code échoue lorsque nous nous y attendons. Si à un moment donné nous refactorisons Calculator et qu'il ne lance pas ArithmeticException sur un débordement, notre test échouera.

Modèle de conception ObjectMother

L'exemple suivant est une classe de validateur pour s'assurer qu'une instance Person est valide.

 class PersonValidatorShould {

    validateur de PersonValidator privé = new PersonValidator();

    @Test
    void failWhenNameIsNull() {
        Person person = new Person(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);
    }

    @Test
    void failWhenAgeIsNegative() {
        Personne person = new Person("John", -5, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);

    }
}

Le modèle de conception ObjectMother est souvent utilisé dans les tests qui créent des objets complexes pour masquer les détails d'instanciation du test. Plusieurs tests peuvent même créer le même objet mais tester différentes choses dessus.

Le test #1 est très similaire au test #2. Nous pouvons refactoriser PersonValidatorShould en extrayant la validation en tant que méthode privée, puis en lui transmettant des instances Person illégales en nous attendant à ce qu'elles échouent toutes de la même manière.

 class PersonValidatorShould {

     validateur de PersonValidator privé = new PersonValidator();

     @Test
     void failWhenNameIsNull() {
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @Test
     void failWhenAgeIsNegative() {
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     vide privé shouldFailValidation(Person invalidPerson) {
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

Tester le caractère aléatoire

Comment sommes-nous censés tester le caractère aléatoire de notre code ?

Supposons que nous ayons un PersonGenerator qui a generateRandom pour générer des instances Person aléatoires.

Commençons par écrire ce qui suit :

 class PersonGeneratorShould {

     générateur de PersonGenerator privé = new PersonGenerator();

     @Test
     void generatePersonValid() {
         Personne personne = generator.generateRandom();
         assertThat(personne).
    }
}

Et puis on devrait se demander :

  • Qu'est-ce que j'essaye de prouver ici ? Que doit faire cette fonctionnalité ?
  • Dois-je simplement vérifier que la personne générée est une instance non nulle ?
  • Dois-je prouver que c'est aléatoire ?
  • L'instance générée doit-elle suivre certaines règles métier ?

Nous pouvons simplifier notre test en utilisant Dependency Injection.

 interface publique RandomGenerator {
     Chaîne generateRandomString();
     int generateRandomInteger();
}

Le PersonGenerator a maintenant un autre constructeur qui accepte également une instance de cette interface. Par défaut, il utilise l'implémentation JavaRandomGenerator qui génère des valeurs aléatoires à l'aide java.Random .

Cependant, dans le test, nous pouvons écrire une autre implémentation plus prévisible.

 @Test
 void generatePersonValid() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     Générateur de PersonGenerator = new PersonGenerator(randomGenerator);
     Personne personne = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

Ce test prouve que le PersonGenerator génère des instances aléatoires comme spécifié par le RandomGenerator sans entrer dans les détails du RandomGenerator .

Tester le JavaRandomGenerator n'ajoute pas vraiment de valeur puisqu'il s'agit d'un simple wrapper autour de java.Random . En le testant, vous testeriez essentiellement java.Random à partir de la bibliothèque standard Java. L'écriture de tests évidents n'entraînera qu'une maintenance supplémentaire avec peu ou pas d'avantages.

Pour éviter d'écrire des implémentations à des fins de test, telles que PredictableGenerator , vous devez utiliser une bibliothèque factice telle que Mockito.

Lorsque nous avons écrit PredictableGenerator , nous avons en fait créé manuellement la classe RandomGenerator . Vous auriez également pu l'écraser en utilisant Mockito :

 @Test
 void generatePersonValid() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("John Doe");
     quand(randomGenerator.generateRandomInteger()).thenReturn(20);

     Générateur de PersonGenerator = new PersonGenerator(randomGenerator);
     Personne personne = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

Cette façon d'écrire les tests est plus expressive et conduit à moins d'implémentations pour des tests spécifiques.

Mockito est une bibliothèque Java pour écrire des mocks et des stubs. C'est très utile pour tester du code qui dépend de bibliothèques externes que vous ne pouvez pas facilement instancier. Il vous permet d'écrire le comportement de ces classes sans les implémenter directement.

Mockito permet également une autre syntaxe pour créer et injecter des mocks afin de réduire le passe-partout lorsque nous avons plus d'un test similaire à ce à quoi nous sommes habitués :

 @ExtendWith(MockitoExtension.class) // 1
 class PersonGeneratorShould {

     @Mock // 2
     RandomGenerator randomGenerator ;

     @InjectMocks // 3
     générateur privé PersonGenerator ;

     @Test
     void generatePersonValid() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         quand(randomGenerator.generateRandomInteger()).thenReturn(20);

         Personne personne = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5 peut utiliser des « extensions » pour étendre ses capacités. Cette annotation lui permet de reconnaître les mocks à travers des annotations et de les injecter correctement.

2. L'annotation @Mock crée une instance simulée du champ. Cela revient à écrire mock(RandomGenerator.class) dans le corps de notre méthode de test.

3. L'annotation @InjectMocks créera une nouvelle instance de PersonGenerator et injectera des simulations dans l'instance du generator .

Pour plus de détails sur les extensions JUnit 5, voir ici.

Pour plus de détails sur l'injection de Mockito, voir ici.

Il y a un écueil à utiliser @InjectMocks . Cela peut supprimer le besoin de déclarer manuellement une instance de l'objet, mais nous perdons la sécurité du constructeur au moment de la compilation. Si, à un moment donné, quelqu'un ajoute une autre dépendance au constructeur, nous n'obtiendrons pas l'erreur de compilation ici. Cela pourrait conduire à des tests défaillants qui ne sont pas faciles à détecter. Je préfère utiliser @BeforeEach pour configurer l'instance manuellement :

 @ExtendWith(MockitoExtension.class)
class PersonGeneratorShould {

     @Faux
     RandomGenerator randomGenerator ;

     générateur privé PersonGenerator ;

     @AvantChaque
     void setup() {
         générateur = new PersonGenerator(randomGenerator);
     }

     @Test
     void generatePersonValid() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         quand(randomGenerator.generateRandomInteger()).thenReturn(20);

         Personne personne = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

Tester les processus sensibles au facteur temps

Un morceau de code dépend souvent des horodatages et nous avons tendance à utiliser des méthodes telles que System.currentTimeMillis() pour obtenir l'horodatage de l'époque actuelle.

Bien que cela semble correct, il est difficile de tester et de prouver si notre code fonctionne correctement lorsque la classe prend des décisions pour nous en interne. Un exemple d'une telle décision serait de déterminer quelle est la journée actuelle.

 class IndexerShould {
     Indexeur privé indexer = new Indexer();
     @Test
     void generateIndexNameForTomorrow() {
         Chaîne indexName = indexer.tomorrow("mon-index");
         // ce test fonctionnerait aujourd'hui, mais qu'en est-il de demain ?
        assertThat(indexName)
           .isEqualTo("mon-index.2022-02-02");
     }
}

Nous devrions utiliser à nouveau l'injection de dépendance pour pouvoir "contrôler" le jour lors de la génération du nom de l'index.

Java a une classe Clock pour gérer des cas d'utilisation tels que celui-ci. Nous pouvons passer une instance d' Clock à notre Indexer pour contrôler l'heure. Le constructeur par défaut pourrait utiliser Clock.systemUTC() pour une compatibilité descendante. Nous pouvons maintenant remplacer les appels System.currentTimeMillis() par clock.millis() .

En injectant une Clock , nous pouvons imposer un temps prévisible dans nos classes et écrire de meilleurs tests.

Tester les méthodes de production de fichiers

  • Comment tester les classes qui écrivent leur sortie dans des fichiers ?
  • Où devons-nous stocker ces fichiers pour qu'ils fonctionnent sur n'importe quel système d'exploitation ?
  • Comment s'assurer que le fichier n'existe pas déjà ?

Lorsqu'il s'agit de fichiers, il peut être difficile d'écrire des tests si nous essayons de résoudre nous-mêmes ces problèmes, comme nous le verrons dans l'exemple suivant. Le test qui suit est un test ancien de qualité douteuse. Il devrait tester si un DogToCsvWriter sérialise et écrit des chiens dans un fichier CSV :

 class DogToCsvWriterShould {

     private DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @Test
     void convertToCsv() {
         writer.appendAsCsv(nouveau Chien(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         Chaîne csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,marron\nZoe,maltais,blanc");
     }
}

Le processus de sérialisation doit être dissocié du processus d'écriture, mais concentrons-nous sur la correction du test.

Le premier problème avec le test ci-dessus est qu'il ne fonctionnera pas sous Windows car les utilisateurs de Windows ne pourront pas résoudre le chemin /tmp/dogs.csv . Un autre problème est que cela ne fonctionnera pas si le fichier existe déjà car il n'est pas supprimé lorsque le test ci-dessus s'exécute. Cela peut fonctionner correctement dans un pipeline CI/CD, mais pas localement s'il est exécuté plusieurs fois.

JUnit 5 a une annotation que vous pouvez utiliser pour obtenir une référence à un répertoire temporaire qui est créé et supprimé par le framework pour vous. Bien que le mécanisme de création et de suppression de fichiers temporaires varie d'un framework à l'autre, les idées restent les mêmes.

 class DogToCsvWriterShould {

     @Test
     void convertToCsv(@TempDir Chemin tempDir) {
         Chemin chiensCsv = tempDir.resolve("chiens.csv");
         DogToCsvWriter writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(nouveau Chien(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         Chaîne csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,marron\nZoe,maltais,blanc");
     }
}

Avec ce petit changement, nous sommes désormais sûrs que le test ci-dessus fonctionnera sous Windows, macOS et Linux sans avoir à se soucier des chemins absolus. Il supprimera également les fichiers créés après le test afin que nous puissions maintenant l'exécuter plusieurs fois et obtenir des résultats prévisibles à chaque fois.

Test de commande vs requête

Quelle est la différence entre une commande et une requête ?

  • Commande : on demande à un objet d'effectuer une action qui produit un effet sans retourner de valeur (méthodes void)
  • Requête : on demande à un objet d'effectuer une action et de retourner un résultat ou une exception

Jusqu'à présent, nous avons testé principalement des requêtes où nous avons appelé une méthode qui a renvoyé une valeur ou a levé une exception dans la phase d'action. Comment tester les méthodes void et voir si elles interagissent correctement avec les autres classes ? Les frameworks fournissent un ensemble différent de méthodes pour écrire ces types de tests.

Les assertions que nous avons écrites jusqu'à présent pour les requêtes commençaient par assertThat . Lors de l'écriture des tests de commande, nous utilisons un ensemble différent de méthodes car nous n'inspectons plus les résultats directs des méthodes comme nous le faisions avec les requêtes. Nous voulons « vérifier » les interactions de notre méthode avec d'autres parties de notre système.

 @ExtendWith(MockitoExtension.class)
 classe FeedMentionServiceShould {

     @Faux
     référentiel FeedRepository privé ;

     @Faux
     émetteur FeedMentionEventEmitter privé ;

     service privé FeedMentionService ;

     @AvantChaque
     void setup() {
         service = new FeedMentionService (référentiel, émetteur);
     }

     @Test
     void insertMentionToFeed() {
         long feedId = 1L ;
         Mention mention = ...;

         quand(repository.upsertMention(feedId, mention))
             .thenReturn(UpsertResult.success(feedId, mention));

         Événement FeedInsertionEvent = new FeedInsertionEvent (feedId, mention);
         mentionService.insertMentionToFeed(event);

         vérifier(émetteur).mentionInsertedToFeed(feedId, mentionner);
         verifyNoMoreInteractions(émetteur);
     }
}

Dans ce test, nous nous sommes d'abord moqués de notre référentiel pour répondre avec un UpsertResult.success lorsqu'on nous a demandé d'upsert mentionner dans notre flux. Nous ne sommes pas concernés par le test du référentiel ici. Les méthodes de référentiel doivent être testées dans FeedRepositoryShould . En se moquant de ce comportement, nous n'avons pas réellement appelé la méthode du référentiel. Nous lui avons simplement dit comment réagir la prochaine fois qu'il sera appelé.

Nous avons alors indiqué à notre mentionService d'insérer cette mention dans notre flux. Nous savons qu'il ne doit émettre le résultat que s'il a réussi à insérer la mention dans le flux. En utilisant la méthode de verify , nous pouvons nous assurer que la méthode mentionInsertedToFeed a été appelée avec notre mention et notre flux et n'a pas été appelée à nouveau à l'aide verifyNoMoreInteractions .

Dernières pensées

La rédaction de tests de qualité vient de l'expérience, et la meilleure façon d'apprendre est de le faire. Les conseils écrits dans ce blog viennent de la pratique. Il est difficile de voir certains des pièges si vous ne les avez jamais rencontrés et, espérons-le, ces suggestions devraient rendre la conception de votre code plus robuste. Avoir des tests fiables augmentera votre confiance pour changer les choses sans transpirer chaque fois que vous devez déployer votre code.

Intéressé à rejoindre l'équipe de Mediatoolkit ?
Découvrez notre poste ouvert pour Développeur Frontend Senior !