CodeGym /Blog Java /Random-FR /Tout sur les tests unitaires : techniques, concepts, prat...
John Squirrels
Niveau 41
San Francisco

Tout sur les tests unitaires : techniques, concepts, pratique

Publié dans le groupe Random-FR
Aujourd'hui, vous ne trouverez pas une application qui ne soit pas drapée de tests, donc ce sujet sera plus que jamais d'actualité pour les développeurs novices : vous ne pouvez pas réussir sans tests. Considérons quels types de tests sont utilisés en principe, puis nous étudierons en détail tout ce qu'il y a à savoir sur les tests unitaires. Tout savoir sur les tests unitaires : techniques, concepts, pratique - 1

Types de tests

Qu'est-ce qu'un essai ? Selon Wikipedia : "Les tests de logiciels impliquent l'exécution d'un composant logiciel ou d'un composant système pour évaluer une ou plusieurs propriétés d'intérêt." En d'autres termes, il s'agit de vérifier l'exactitude de notre système dans certaines situations. Eh bien, voyons quels types de tests il existe en général :
  • Tests unitaires — Tests dont le but est de vérifier chaque module du système séparément. Ces essais doivent s'appliquer aux plus petites parties atomiques du système, par exemple les modules.
  • Tests du système — Tests de haut niveau pour vérifier le fonctionnement d'une plus grande partie de l'application ou du système dans son ensemble.
  • Test de régression — Test utilisé pour vérifier si de nouvelles fonctionnalités ou corrections de bogues affectent les fonctionnalités existantes de l'application ou introduisent d'anciens bogues.
  • Tests fonctionnels — Vérifier si une partie de l'application satisfait aux exigences énoncées dans les spécifications, les user stories, etc.

    Types de tests fonctionnels :

    • Tests en boîte blanche — Vérifier si une partie de l'application satisfait aux exigences tout en connaissant l'implémentation interne du système ;
    • Test boîte noire — Vérifier si une partie de l'application satisfait aux exigences sans connaître l'implémentation interne du système.

  • Tests de performance — Tests écrits pour déterminer comment le système ou une partie du système fonctionne sous une certaine charge.
  • Test de charge — Tests conçus pour vérifier la stabilité du système sous des charges standard et pour trouver la charge maximale à laquelle l'application fonctionne toujours correctement.
  • Tests de résistance — Tests conçus pour vérifier les performances de l'application sous des charges non standard et pour déterminer la charge maximale avant la défaillance du système.
  • Tests de sécurité - Tests utilisés pour vérifier la sécurité du système (contre les pirates, les virus, l'accès non autorisé aux données confidentielles et autres attaques délicieuses).
  • Test de localisation — Tests de localisation de l'application.
  • Tests d'utilisabilité - Tests visant à vérifier l'utilisabilité, la compréhensibilité, l'attractivité et la facilité d'apprentissage.
Tout cela semble bien, mais comment cela fonctionne-t-il en pratique ? Simple! Nous utilisons la pyramide de test de Mike Cohn : Tout savoir sur les tests unitaires : techniques, concepts, pratique - 2Il s'agit d'une version simplifiée de la pyramide : elle est maintenant divisée en parties encore plus petites. Mais aujourd'hui, nous ne deviendrons pas trop sophistiqués. Nous allons considérer la version la plus simple.
  1. Unité — Cette section fait référence aux tests unitaires, qui sont appliqués dans différentes couches de l'application. Ils testent la plus petite unité divisible de la logique d'application. Par exemple, des classes, mais le plus souvent des méthodes. Ces tests essaient généralement autant que possible d'isoler ce qui est testé de toute logique externe. Autrement dit, ils essaient de créer l'illusion que le reste de l'application fonctionne comme prévu.

    Il devrait toujours y avoir beaucoup de ces tests (plus que tout autre type), car ils testent de petits morceaux et sont très légers, ne consommant pas beaucoup de ressources (c'est-à-dire de RAM et de temps).

  2. Intégration — Cette section fait référence aux tests d'intégration. Ce test vérifie les plus gros morceaux du système. Autrement dit, soit il combine plusieurs éléments de logique (plusieurs méthodes ou classes), soit il vérifie l'exactitude de l'interaction avec un composant externe. Ces tests sont généralement plus petits que les tests unitaires car ils sont plus lourds.

    Un exemple de test d'intégration pourrait être la connexion à une base de données et la vérification de l'exactitude du fonctionnement des méthodes pour travailler avec elle.

  3. UI — Cette section fait référence aux tests qui vérifient le fonctionnement de l'interface utilisateur. Ils impliquent la logique à tous les niveaux de l'application, c'est pourquoi ils sont aussi appelés tests de bout en bout. En règle générale, ils sont beaucoup moins nombreux, car ils sont les plus encombrants et doivent vérifier les chemins (utilisés) les plus nécessaires.

    Dans l'image ci-dessus, nous voyons que les différentes parties du triangle varient en taille : à peu près les mêmes proportions existent dans le nombre de différents types de tests dans le travail réel.

    Aujourd'hui, nous allons nous intéresser de plus près aux tests les plus courants, les tests unitaires, puisque tout développeur Java qui se respecte devrait pouvoir les utiliser à un niveau basique.

Concepts clés des tests unitaires

La couverture de test (couverture de code) est l'une des principales mesures de la qualité des tests d'une application. C'est le pourcentage du code qui est couvert par les tests (0-100%). En pratique, beaucoup poursuivent ce pourcentage comme objectif. C'est quelque chose avec lequel je ne suis pas d'accord, car cela signifie que les tests commencent à être appliqués là où ils ne sont pas nécessaires. Par exemple, supposons que nous ayons des opérations CRUD standard (créer/obtenir/mettre à jour/supprimer) dans notre service sans logique supplémentaire. Ces méthodes sont purement des intermédiaires qui délèguent le travail à la couche travaillant avec le référentiel. Dans cette situation, nous n'avons rien à tester, sauf peut-être si la méthode donnée appelle une méthode DAO, mais c'est une blague. Des outils complémentaires sont généralement utilisés pour évaluer la couverture des tests : JaCoCo, Cobertura, Clover, Emma, ​​etc. Pour une étude plus détaillée de ce sujet, TDD signifie développement piloté par les tests. Dans cette approche, avant de faire quoi que ce soit d'autre, vous écrivez un test qui vérifiera un code spécifique. Cela s'avère être un test de boîte noire : nous savons ce qu'est l'entrée et nous savons ce que devrait être la sortie. Cela permet d'éviter la duplication de code. Le développement piloté par les tests commence par la conception et le développement de tests pour chaque fonctionnalité de votre application. Dans l'approche TDD, nous créons d'abord un test qui définit et teste le comportement du code. L'objectif principal de TDD est de rendre votre code plus compréhensible, plus simple et sans erreur. Tout savoir sur les tests unitaires : techniques, concepts, pratique - 3L'approche se compose des éléments suivants :
  • Nous écrivons notre test.
  • Nous effectuons le test. Sans surprise, cela échoue, car nous n'avons pas encore implémenté la logique requise.
  • Ajoutez le code qui fait passer le test (nous relançons le test).
  • Nous refactorisons le code.
TDD est basé sur des tests unitaires, car ce sont les plus petits blocs de construction de la pyramide d'automatisation des tests. Avec les tests unitaires, nous pouvons tester la logique métier de n'importe quelle classe. BDD est synonyme de développement axé sur le comportement. Cette approche est basée sur TDD. Plus précisément, il utilise des exemples en langage clair qui expliquent le comportement du système pour toutes les personnes impliquées dans le développement. Nous n'approfondirons pas ce terme, puisqu'il touche principalement les testeurs et les analystes métiers. Un scénario de test est un scénario qui décrit les étapes, les conditions spécifiques et les paramètres requis pour vérifier le code testé. Un montage de test est un code qui configure l'environnement de test pour qu'il ait l'état nécessaire pour que la méthode testée s'exécute avec succès. Il s'agit d'un ensemble prédéfini d'objets et de leur comportement dans des conditions spécifiées.

Étapes du test

Un test comporte trois étapes :
  • Spécifiez les données de test (appareils).
  • Exercez le code sous test (appelez la méthode testée).
  • Vérifiez les résultats et comparez avec les résultats attendus.
Tout savoir sur les tests unitaires : techniques, concepts, pratique - 4Pour garantir la modularité des tests, vous devez vous isoler des autres couches de l'application. Cela peut être fait en utilisant des stubs, des simulacres et des espions. Les maquettes sont des objets qui peuvent être personnalisés (par exemple, adaptés à chaque test). Ils nous permettent de spécifier ce que nous attendons des appels de méthode, c'est-à-dire les réponses attendues. Nous utilisons des objets fictifs pour vérifier que nous obtenons ce que nous attendons. Les stubs fournissent une réponse codée en dur aux appels pendant les tests. Ils peuvent également stocker des informations sur l'appel (par exemple, des paramètres ou le nombre d'appels). Ceux-ci sont parfois appelés espions. Parfois, les gens confondent les termes stub et mock : la différence est qu'un stub ne vérifie rien — il ne fait que simuler un état donné. Une maquette est un objet qui a des attentes. Par exemple, qu'une méthode donnée doit être appelée un certain nombre de fois. Autrement dit,

Environnements de test

Alors, maintenant au point. Il existe plusieurs environnements de test (frameworks) disponibles pour Java. Les plus populaires d'entre eux sont JUnit et TestNG. Pour notre examen ici, nous utilisons : Tout savoir sur les tests unitaires : techniques, concepts, pratique - 5Un test JUnit est une méthode dans une classe qui est utilisée uniquement pour les tests. La classe porte généralement le même nom que la classe qu'elle teste, avec "Test" ajouté à la fin. Par exemple, CarService -> CarServiceTest. Le système de construction Maven inclut automatiquement ces classes dans la portée du test. En fait, cette classe s'appelle une classe de test. Passons brièvement en revue les annotations de base :

  • @Test indique que la méthode est un test (essentiellement, une méthode marquée avec cette annotation est un test unitaire).
  • @Before signifie une méthode qui sera exécutée avant chaque test. Par exemple, pour remplir une classe avec des données de test, lire des données d'entrée, etc.
  • @After est utilisé pour marquer une méthode qui sera appelée après chaque test (par exemple pour effacer des données ou restaurer des valeurs par défaut).
  • @BeforeClass est placé au-dessus d'une méthode, analogue à @Before. Mais une telle méthode n'est appelée qu'une seule fois avant tous les tests pour la classe donnée et doit donc être statique. Il est utilisé pour effectuer des opérations plus gourmandes en ressources, telles que la création d'une base de données de test.
  • @AfterClass est l'opposé de @BeforeClass : il est exécuté une fois pour la classe donnée, mais seulement après tous les tests. Il est utilisé, par exemple, pour effacer des ressources persistantes ou se déconnecter d'une base de données.
  • @Ignore indique qu'une méthode est désactivée et sera ignorée pendant l'exécution du test global. Ceci est utilisé dans diverses situations, par exemple, si la méthode de base a été modifiée et que le test n'a pas encore été retravaillé pour s'adapter aux modifications. Dans de tels cas, il est également souhaitable d'ajouter une description, c'est-à-dire @Ignore("Some description").
  • @Test(expected = Exception.class) est utilisé pour les tests négatifs. Ce sont des tests qui vérifient le comportement de la méthode en cas d'erreur, c'est-à-dire que le test s'attend à ce que la méthode lève une sorte d'exception. Une telle méthode est indiquée par l'annotation @Test, mais avec une indication de l'erreur à intercepter.
  • @Test(timeout = 100) vérifie que la méthode est exécutée en 100 millisecondes maximum.
  • @Mock est utilisé au-dessus d'un champ pour attribuer un objet fictif (il ne s'agit pas d'une annotation JUnit, mais plutôt de Mockito). Au besoin, nous définissons le comportement du mock pour une situation spécifique directement dans la méthode de test.
  • @RunWith(MockitoJUnitRunner.class) est placé au-dessus d'une classe. Cette annotation indique à JUnit d'invoquer les tests de la classe. Il existe différents exécuteurs, notamment ceux-ci : MockitoJUnitRunner, JUnitPlatform et SpringRunner. Dans JUnit 5, l'annotation @RunWith a été remplacée par l'annotation @ExtendWith plus puissante.
Examinons quelques méthodes utilisées pour comparer les résultats :

  • assertEquals(Object expects, Object actuals) — vérifie si les objets passés sont égaux.
  • assertTrue(boolean flag) — vérifie si la valeur passée est vraie.
  • assertFalse(boolean flag) — vérifie si la valeur passée est fausse.
  • assertNull(Object object) — vérifie si l'objet passé est null.
  • assertSame(Object firstObject, Object secondObject) — vérifie si les valeurs transmises font référence au même objet.
  • assertThat(T t, Matcher matcher) — Vérifie si t satisfait la condition spécifiée dans matcher.
AssertJ fournit également une méthode de comparaison utile : assertThat(firstObject).isEqualTo(secondObject) . Ici, j'ai mentionné les méthodes de base - les autres sont des variantes de ce qui précède.

Tester en pratique

Examinons maintenant le matériel ci-dessus dans un exemple spécifique. Nous allons tester la méthode de mise à jour d'un service. Nous ne considérerons pas la couche DAO, puisque nous utilisons la valeur par défaut. Ajoutons un starter pour les tests :

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <version>2.2.2.RELEASE</version>
   <scope>test</scope>
</dependency>
Et ici, nous avons la classe de service :

@Service
@RequiredArgsConstructor
public class RobotServiceImpl implements RobotService {
   private final RobotDAO robotDAO;

   @Override
   public Robot update(Long id, Robot robot) {
       Robot found = robotDAO.findById(id);
       return robotDAO.update(Robot.builder()
               .id(id)
               .name(robot.getName() != null ? robot.getName() : found.getName())
               .cpu(robot.getCpu() != null ? robot.getCpu() : found.getCpu())
               .producer(robot.getProducer() != null ? robot.getProducer() : found.getProducer())
               .build());
   }
}
Ligne 8 — extrayez l'objet mis à jour de la base de données. Lignes 9 à 14 — créez un objet via le constructeur. Si l'objet entrant a un champ, définissez-le. Sinon, nous laisserons ce qui est dans la base de données. Regardez maintenant notre test:

@RunWith(MockitoJUnitRunner.class)
public class RobotServiceImplTest {
   @Mock
   private RobotDAO robotDAO;

   private RobotServiceImpl robotService;

   private static Robot testRobot;

   @BeforeClass
   public static void prepareTestData() {
       testRobot = Robot
               .builder()
               .id(123L)
               .name("testRobotMolly")
               .cpu("Intel Core i7-9700K")
               .producer("China")
               .build();
   }

   @Before
   public void init() {
       robotService = new RobotServiceImpl(robotDAO);
   }
Ligne 1 — notre Runner. Ligne 4 — nous isolons le service de la couche DAO en lui substituant un mock. Ligne 11 - nous définissons une entité de test (celle que nous utiliserons comme cobaye) pour la classe. Ligne 22 - nous définissons l'objet de service, c'est ce que nous allons tester.

@Test
public void updateTest() {
   when(robotDAO.findById(any(Long.class))).thenReturn(testRobot);
   when(robotDAO.update(any(Robot.class))).then(returnsFirstArg());
   Robot robotForUpdate = Robot
           .builder()
           .name("Vally")
           .cpu("AMD Ryzen 7 2700X")
           .build();

   Robot resultRobot = robotService.update(123L, robotForUpdate);

   assertNotNull(resultRobot);
   assertSame(resultRobot.getId(),testRobot.getId());
   assertThat(resultRobot.getName()).isEqualTo(robotForUpdate.getName());
   assertTrue(resultRobot.getCpu().equals(robotForUpdate.getCpu()));
   assertEquals(resultRobot.getProducer(),testRobot.getProducer());
}
Ici, nous voyons que le test a trois divisions claires : Lignes 3-9 — spécifiant les luminaires. Ligne 11 - exécution du code testé. Lignes 13-17 — vérification des résultats. Plus en détail : Lignes 3-4 : définissez le comportement de la simulation DAO. Ligne 5 - définissez l'instance que nous mettrons à jour en plus de notre standard. Ligne 11 — utilisez la méthode et prenez l'instance résultante. Ligne 13 — vérifiez qu'elle n'est pas nulle. Ligne 14 - comparez l'ID du résultat et les arguments de méthode donnés. Ligne 15 — vérifiez si le nom a été mis à jour. Ligne 16 — voir le résultat CPU. Ligne 17 — nous n'avons pas spécifié ce champ dans l'instance, il devrait donc rester le même. Nous vérifions cette condition ici. Exécutons-le :Tout savoir sur les tests unitaires : techniques, concepts, pratique - 6L'épreuve est verte ! Nous pouvons pousser un soupir de soulagement :) En résumé, les tests améliorent la qualité du code et rendent le processus de développement plus flexible et fiable. Imaginez les efforts nécessaires pour reconcevoir un logiciel impliquant des centaines de fichiers de classe. Lorsque nous avons des tests unitaires écrits pour toutes ces classes, nous pouvons refactoriser en toute confiance. Et surtout, cela nous aide à trouver facilement les bogues pendant le développement. Les gars et les filles, c'est tout ce que j'ai aujourd'hui. Laisse moi un j'aime et laisse un commentaire :)
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION