Tests I
Dans cette unité, vous apprendrez les bases des tests, notamment le rapport derrière le test, quels types de tests existent, l'utilisation de base de JUnit et comment écrire des tests significatifs.
Résumé de la lecture
Les tests sont ce qui maintient le logiciel en vie. Un logiciel sans tests n'est pas maintenable, et tout effort investi dans sa création est probablement perdu, car le logiciel ne survivra pas. Si vous tenez à votre logiciel, écrivez des tests et surtout écrivez de bons tests.
Essentiels
Avant d'examiner les détails techniques, passons en revue quelques potentialités, limitations et intérêts généraux des tests logiciels.
Ce que nous pouvons tester
- Étant donné un SUT (sujet sous test), par exemple une classe, une méthode, etc.
- Les tests peuvent prouver qu'un SUT a actuellement certaines propriétés.
- Les tests peuvent montrer la présence de bogues, mais pas leur absence.
Les tests sont rarement intelligents
Les tests unitaires n'interprètent pas les résultats des tests, ni n'ont de forme d'intelligence cognitive. Nous ne pouvons tester que des questions claires et déterministes avec des réponses dichotomiques (vrai / faux).
Intérêt pour la programmation
- Ajout de fonctionnalités : Quoi que vous ajoutiez en fonctionnalités, vous n'avez pas interféré avec quoi que ce soit d'existant.
- Tests de régression : Quoi que vous ayez amélioré, cela n'a pas endommagé ce qui fonctionnait déjà.
- Refactoring : Quoi que vous ayez changé, vous n'avez rien cassé.
Exemple de test de régression
Supposons que j'ai une fonction (pas très optimale) pour tester si un nombre est premier :
public class PrimeChecker {
public boolean isPrime(int number) {
boolean result = true;
for (int factor = 2; factor < number; factor++)
if (number % factor == 0)
result = false;
return result;
}
}
Alors, je peux tester avec une série de scénarios de test :
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class PrimeCheckerTest {
private final PrimeChecker checker = new FasterPrimeChecker();
/**
* Tests if the number 23 is correctly identified as a prime number.
*/
@Test
public void testIsPrime23() {
assertTrue(checker.isPrime(23));
}
}
L'implémentation est-elle toujours correcte si je rends mon vérificateur de nombres premiers plus efficace ?
public class FasterPrimeChecker extends PrimeChecker {
public boolean isPrime(int number) {
boolean result = true;
for (int factor = 2; factor * factor < number; factor++)
if (number % factor == 0)
result = false;
return result;
}
}
Intérêt pour le développement
Les tests sont également une forme de documentation :
- Tout ce qui est testé : exigence garantie, spécification claire du comportement attendu du programme.
- Tout ce qui n'est pas testé : exigence inconnue, pas de spécification du comportement attendu du programme.
Alternatives :
- Rédaction de documentation
- Commentaires dans le code
- Nommage des variables / méthodes
Pourquoi ne pas simplement se fier à d'autres formes de documentation du comportement ?
Les tests sont la seule forme de documentation que vous pouvez vérifier de manière automatisée.
Types de tests
Principale différence entre les types de tests : Horizon de test (ce qu'il faut tester).
Horizon | Type de test | Exemple |
---|---|---|
Module isolé | Test unitaire | L'appel d'une classe Java avec l'entrée x retourne y . |
Interplay de plusieurs modules | Tests d'intégration | Le système envoie un email à alert@uqam.ca lorsque la condition critique survient. |
Interplay de l'ensemble du système | Test système | Cliquer sur accepter finalise la réservation de vol et génère le PDF de la carte d'embarquement. |
Aspect non fonctionnel | Test d'acceptation | Le système réagit suffisamment vite pour un usage productif. |
Moyens de test
Type de test | Moyens de test |
---|---|
Test unitaire | Cadres de test unitaires, e.g. JUnit. |
Tests d'intégration | Cadres de simulation (plus de détails plus tard). |
Test système | Utiliser réellement le système, e.g. via des scripts. |
Test d'acceptation | Des humains utilisant réellement le système. |
En général
En général, il devient plus difficile (et coûteux) de tester plus l'horizon est grand. Par exemple, les tests unitaires ne sont pas coûteux comparés à l'embauche de testeurs qui tentent d'interagir avec votre système.
Développement pilotée par les tests
Le développement piloté par les tests (anglais: Test-Driven Development, ou simplement TDD) vise le problème de l'écart entre le code de production et les tests : "Le code de production évolue constamment, comment attraper vos tests ?"
- TDD : Faites-le à l'envers !
- Les trois lois de TDD :
- Quelle que soit la fonctionnalité dont vous avez besoin, écrivez d'abord des tests échoués.
- Ne rédigez pas plus de tests que ceux nécessaires pour provoquer un échec.
- Ne rédigez pas plus de code que ce qui est nécessaire pour passer tous les tests.
Idéalement, en suivant TDD, vous ne "vous lancez jamais à coder beaucoup de nouvelles fonctionnalités". De même, vous ne "vous lancez jamais à écrire des tonnes de nouveaux tests". Les deux avancent au même rythme.
Tests unitaires
- Les tests unitaires supposent une correspondance stricte 1:1 entre SUT (une classe) et tests (une classe de tests).
- Exemple : Lors de l'évaluation d'une classe avec une fonctionnalité pour tester les nombres premiers (SUT), nous écrivons une classe de tests unitaires correspondante :
Préliminaires
Lorsque vous configurez un nouveau projet Maven, Maven anticipe déjà que vous voudrez probablement tester votre projet :
mvn archetype:generate \
-DgroupId=ca.uqam.info \
-DartifactId=MavenHelloWorld \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false
Note: quelques systèmes (windows) n'aiment pas des commandes répartis sur plusieurs lignes. Enlevez les
\
et mettez tout dans une seule ligne.
Crée la structure de dossier :
MavenHelloWorld
├── pom.xml
└── src
├── main
│ └── java
│ ...
│ └── App.java
└── test
└── java
...
└── AppTest.java
Eh bien... vous avez déjà votre premier SUT et le test correspondant !
Mais Maven fait en réalité plus ! Par défaut, votre pom.xml
contient également une dépendance pour JUnit
:
<project>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
La version est un peu dépassée cependant, entre-temps, nous en sommes à la version 4 de JUnit (et même 5). La première
chose que vous voulez faire est de mettre à jour la version de JUnit à 4.13.2
.
Y a-t-il quelque chose d'inhabituel avec le bloc de dépendance ?
La dépendance montre un tag scope
supplémentaire, qui ne fait pas partie des dépendances que nous avons vues précédemment, par exemple, les bibliothèques. Cela est dû au fait que toutes les dépendances ne sont pas toujours pertinentes. Le scope test
indique qu'une dépendance n'est nécessaire que pour les tests, mais pas en temps d'exécution. Par conséquent, Maven ne packagera pas la dépendance comme partie de la construction lors de la création d'un exécutable. Votre client n'aura pas besoin de cette dépendance lorsqu'il utilisera votre produit logiciel.
Exécution des tests
Les deux fonctionneront :
mvn clean package
: Compiler le code, exécuter tous les tests, créer un JAR.mvn clean test
: Compiler le code, exécuter tous les tests. (Un peu plus rapide, mais pas toujours ce que vous voulez.)
Plus des infos sur la syntax et les mots-clés maven bientôt, dans une unité de course sur le sujet.
Syntaxe JUnit
Les tests unitaires sont (principalement) contrôlés via des annotations. Nous allons maintenant passer en revue les annotations les plus courantes et les paramètres d'annotation.
Test
@Test
définit un test unitaire atomique. Nous avons déjà vu un exemple initial.@Test
décore une méthode.- La méthode doit être
public void ...
- Il peut y avoir autant de méthodes annotées
@Test
que vous le souhaitez, par classe de test.
- JUnit crée un nouveau SUT pour chaque méthode
@Test
! Vous ne pouvez pas passer d'informations entre les tests en utilisant des champs de classe !- Exemple :
import org.junit.Test;
public class DemoTest {
private int internValue = 0;
@Test
public void foo() {
System.out.println(internValue);
internValue += 3;
}
@Test
public void bar() {
System.out.println(internValue);
internValue += 5;
}
}
Que s'affiche dans la console lors de l'exécution des tests ?
Deux fois 0
. Les tests sont exécutés sur des objets séparés.
Assertions
En général, vous ne voulez pas seulement invoquer des fonctions, mais aussi tester les résultats.
Y a-t-il un sens à des tests sans vérification des résultats ?
Oui, des tests sans vérification des résultats peuvent toujours avoir du sens. Par exemple, pour exclure l'occurrence d'exceptions à l'exécution.
- Dans JUnit4, les assertions vérifient si une variable donnée (ou la valeur de retour d'une méthode) correspond à un résultat attendu.
- Syntaxe générale :
1) Premier argument : Message lisible par l'homme, au cas où la valeur ne serait pas celle attendue.
2) Deuxième argument : La valeur récupérée, par exemple, le résultat de la fonction testée
foo()
. 3) Troisième argument : La valeur attendue, par exemple, 42. - Donc, si vous aviez un test pour la fonction
foo()
et que le test vérifie que le résultat est42
, vous écririez :
/**
* Verifies if calling foo returns 42.
*/
@Test
public void testFoo() {
Assert.assertEquals("Calling foo did not return expected value 42!", foo(), 42);
}
Utilisez assertEquals
et fournissez un message
Sémantiquement, vous pouvez prendre le raccourci et appeler simplement assertTrue(foo() == 42)
. Cependant, si le test
échoue, il peut ne pas être évident de savoir quel est le problème. Fournissez toujours un message lisible par l'homme
pour vos assertions.
Voici quelques variantes utiles de assertEquals
:
assertNotEquals(...)
assertNull(...)
assertNotNull(...)
assertArrayEquals(...)
Avant
Souvent, vous avez du code dupliqué dans vos méthodes de test, par exemple :
- Ouverture d'une connexion à une base de données
- Assurer que le programme est dans un état testable (par exemple, pour tester un contrôleur Halma, le modèle doit être initialisé)
- Préparation du système de fichiers
- ...
Au lieu de copier-coller le même code dans toutes les méthodes de test (ou même de commencer chaque test par le même
appel de méthode commun), vous pouvez décorer une méthode d'initialisation dédiée avec @Before
et initialiser les
champs de classe locaux.
public class DataBaseTest {
private DataBase db;
/**
* Method to call before every test.
*/
@Before
public void initializeDatabase() {
db = connectToDatabase();
Logger.info("Connection to DB established.");
}
@Test
public void databaseWriteTest() {
db.callSomethingImportant(); // db has been initialized by @Before
}
@Test
public void databaseWriteTest() {
db.callSomethingElseImportant(); // db has been initialized by @Before
}
}
Remarque : Dans Junit5,
@Before
a été renommé en@BeforeEach
, pour éviter toute confusion avec@BeforeClass
(qui exécute une méthode UNE FOIS avant que tous les tests ne soient exécutés. C'est par exemple utile pour UNE FOIS créer une connexion à une base de données). Plus de détails ici.
Après
- Il n'y a absolument aucune garantie concernant l'ordre des tests.
- Vous ne pouvez pas supposer que vos méthodes annotées
@Test
seront exécutées dans un ordre donné. - Tout ordre doit mener au même résultat. Si ce n'est pas le cas, il y a un problème avec vos tests !
- Vous ne pouvez pas supposer que vos méthodes annotées
- Parfois, pour tester un objet, vous devez modifier l'état.
- Lorsque vous travaillez avec des objets, vous pouvez créer un nouveau SUT pour chaque méthode
@Test
. - Mais si vous travaillez avec quelque chose de persistant, par exemple une base de données, l'exécution d'un test peut laisser un état "sale".
- Lorsque vous travaillez avec des objets, vous pouvez créer un nouveau SUT pour chaque méthode
Exemple :
- D'abord un test pour vérifier la lecture de la base de données :
@Test
public void testReadStudent() throws IOException {
Set<String> students = db.readDataBase();
Set<String> expectedResult = new LinkedHashSet<>();
expectedResult.add("Max");
Assert.assertEquals("DataBase read did not provide expected result.", students, expectedResult);
}
Que faire à ce sujet ?
- Solution suboptimale : Nettoyer l'état après chaque test.
- Vous pouvez soit inclure des actions "annuler" à la fin de chaque test, par exemple, supprimer l'étudiant que vous
avez essayé d'ajouter :
@Test public void testAddStudent() throws IOException { db.addStudent("Ryan"); db.removeStudent("Ryan"); // <-- We do not want to test this, but we have to, so other tests work. }
- Mais si votre test échoue (ou plante !), les actions "annuler" ne seront pas exécutées !
- Vous pouvez soit inclure des actions "annuler" à la fin de chaque test, par exemple, supprimer l'étudiant que vous
avez essayé d'ajouter :
- Meilleure solution :
- Utilisez une méthode annotée
@After
. - La méthode sera appelée après chaque exécution de méthode
@Test
. - L'état est déterministe.
- Utilisez une méthode annotée
Puis-je avoir plusieurs annotations @After
?
Oui, mais vous ne pouvez pas supposer qu'elles seront exécutées dans un ordre déterministe. Ce n'est généralement pas ce que vous voulez.
Exceptions
- La programmation défensive signifie lancer des exceptions lorsque quelqu'un essaie de détourner vos fonctions (par erreur ou intentionnellement).
- Il est tout à fait logique d'écrire des cas de test pour vérifier si votre code de production est suffisamment défensif.
Exemple :
- Vous avez déjà appris que les Getters devraient être exploitables pour manipuler l'état de l'objet.
- (Les analyseurs de code statique vous avertiront en fait si votre code présente ce type de vulnérabilité. Voir SpotBugs.)
- Plus précisément, un getter retournant une liste devrait protéger la liste en tant que "non modifiable" :
import java.util.Collections;
public class Inf2050 {
private final List<Student> students;
// [...] constructor etc...
/**
* Getter for read-only list with all students enrolled in class.
* @returns unmodifiable list of student objects.
*/
public List<Student> getStudents() {
return Collections.unmodifiableCollection(students);
}
}
- Mais alors, un test correspondant pour une implémentation défensive échouerait toujours, car le comportement attendu est une exception !
@Test
public void testHijackGetter() {
Inf2050 course = new Inf2050();
List<Student> students = getStudents();
students.add(new Student("Alan Turing")); // <-- Must trow an exception... test will fail.
}
- Heureusement, JUnit propose une solution à ce scénario : nous pouvons décorer l'annotation pour attendre une exception :
@Test(expected = UnsupportedOperationException.class)
public void testHijackGetter() {
Inf2050 course = new Inf2050();
List<Student> students = getStudents();
students.add(new Student("Alan Turing")); // <-- Test will only fail if there is NO exception.!
}
Attention à ce que vous souhaitez
Ne vous contentez pas d'attendre Exception.class
. Cela correspondra à n'importe quelle exception et votre test pourrait passer bien qu'une exception complètement différente ait été levée (Exception.class
est la superclasse commune à toutes les autres exceptions). Attendez toujours aussi spécifiquement que possible.
Timeouts
- Parfois, les tests sont sensibles au temps, ou vous ne voulez pas que les exécutions de test prennent une éternité.
- Vous pouvez "décorer" l'annotation
@Test
avec une information detimeout
. - Votre test sera arrêté une fois le délai dépassé. Notez cependant que dépasser le délai échouera le test.
Exemple :
/**
* Testing a really big number for prime could take a moment, so we set a 1 millisecond timeout.
*/
@Test(timeout = 1)
public void testIsPrimeMaxInt_1() {
assertFalse(checker.isPrime(Integer.MAX_VALUE - 1));
}
Notre CPU est rapide, mais pas si rapide, donc le test échouera :
Couverture
- Idéalement, vos tests couvrent tous les chemins d'exécution possibles de votre programme.
- Chaque classe
- Chaque méthode
- Chaque bifurcation
if
dans votre logique de programme
- Nous pouvons exécuter tous les tests et marquer toutes les lignes qui ont été touchées par au moins un test.
- La couverture (de lignes) est alors définie comme
nombre total de lignes testées / nombre total de lignes de code
- La couverture de classe et de méthode n'est pas vraiment utilisée, car elle peut être trompeuse, par exemple, en cas de faible modularité du code.
Attention à l'interprétation des pourcentages de couverture
Une bonne couverture ne signifie pas nécessairement que votre programme est bien testé. En principe, vous pouvez atteindre une couverture élevée simplement en appelant chaque méthode, sans jamais affirmer quoi que ce soit. Cependant, si une bonne couverture n'implique pas un bon test, une faible couverture implique un mauvais test.
Rapports de couverture avec IntelliJ
IntelliJ dispose d'un rapporteur de couverture de test intégré.
- La meilleure option est de faire un clic droit sur le paquet de test, dans l'explorateur de structure du projet.
- Au lieu de simplement exécuter des tests, choisissez
l'option
Plus d'exécutions/Débogage -> Exécuter avec couverture
. - Vous recevrez un rapport de test pour :
- Couverture de classe
- Couverture de méthode
- Couverture de lignes
De plus, l'éditeur de code vous donne un retour visuel sur les lignes exactes couvertes (ou non couvertes) par les tests :
⏹ : La ligne a été exécutée par au moins un test.⏹ : La ligne n'a pas été exécutée.⏹ : La ligne a été exécutée partiellement, par exemple, une seule branche d'une instruction if-else a été visitée.
Vous pouvez également survoler le marquage coloré pour voir le nombre d'exécutions.
Hacking des tests
Le développement piloté par les tests (TDD) amène parfois les développeurs à "hacker" autour des tests.
- C'est-à-dire qu'ils développent du code de production adapté aux tests plutôt qu'à l'objectif.
- Exemple :
- Les nombres parfaits sont définis comme : "Un entier positif qui est égal à la somme de ses diviseurs propres"
6 a des diviseurs : 1, 2, 3.
1 + 2 + 3 = 6
6
est un nombre parfait.- D'autres nombres parfaits sont :
28
,496
,8128
, ... (ils deviennent assez rares, bientôt)
- Les nombres parfaits sont définis comme : "Un entier positif qui est égal à la somme de ses diviseurs propres"
Y a-t-il des nombres parfaits impairs ?
Si vous trouvez la réponse, veuillez me le faire savoir. C'est un problème non résolu en mathématiques.
- Un développeur senior a créé un simple test unitaire, espérant une implémentation TDD d'une fonction de vérification.
- Le code de test, écrit par le développeur :
@Test
public void testPerfectNumber3() {
PerfectNumberChecker checker = new PerfectNumberChecker();
assertFalse("3 is not a perfect number, but checker mistakenly said it is.", checker.isPerfect(3));
}
@Test
public void testPerfectNumber6() {
PerfectNumberChecker checker = new PerfectNumberChecker();
assertTrue("6 should be identified as perfect number, but checker did not recognize it.", checker.isPerfect(6));
}
@Test
public void testPerfectNumber20() {
PerfectNumberChecker checker = new PerfectNumberChecker();
assertFalse("20 is not a perfect number, but checker mistakenly said it is.", checker.isPerfect(20));
}
@Test
public void testPerfectNumber28() {
PerfectNumberChecker checker = new PerfectNumberChecker();
assertTrue("28 should be identified as perfect number, but checker did not recognize it.", checker.isPerfect(28));
}
PerfectNumberChecker
.
* Après 3 minutes, ils ont trouvé une solution qui réussit tous les tests :
public boolean isPerfect(int number) {
if (number == 3)
{ return false; }
if (number == 6)
{ return true; }
if (number == 20)
{ return false; }
else
{ return true; }
}
Attention lors du partage des tests
Clairement, le nouvel employé n'a pas compris le but du TDD. Leur solution est adaptée aux tests, mais cela devrait être l'inverse. Dans le pire des cas, les programmeurs coderont intentionnellement autour des tests pour créer une illusion d'achèvement de la tâche. C'est pourquoi je garde certains tests de TP non divulgués jusqu'à après la soumission.
Tests de singe
Les tests de singe signifient : "Tester avec des entrées aléatoires".
- Les entrées aléatoires sont efficaces contre le hacking de tests.
- Cependant, il y a deux défis :
- Vous ne pouvez pas affirmer pour des entrées aléatoires.
- Vous devez être en mesure de reproduire les erreurs.
Bonnes pratiques :
- Pour le premier, vous pouvez implémenter une logique de test réduite. Par exemple, si vous testez le vérificateur de nombres premiers avec des nombres aléatoires, vous pouvez facilement exclure les nombres pairs, même sans réimplémenter tout le vérificateur de nombres premiers dans votre classe de test.
- Pour le second, vous pouvez utiliser un générateur de nombres aléatoires pseudo-aléatoires (PRNG) à semence : Ce sont
des fonctions qui produisent des valeurs qui semblent aléatoires en termes de distribution, mais peuvent être
reproduites de manière déterministe.
- Si vous soupçonnez un hacking de tests, vous pouvez simplement changer la semence.
-
Java propose un PRNG intégré :
Random
Info
Une forme plus extrême de Monkey Testing est le Fuzzing. Le Fuzzing bombarde également le code logiciel avec des tests, mais au lieu de se contenter de nombres aléatoires, il "améliore" progressivement la recherche d'entrées pathologiques, par exemple en mesurant les temps de réponse ou les plantages. De nouvelles entrées pathologiques sont recherchées en se basant sur des mutations des pires entrées précédentes.
Littérature
Inspiration et lectures supplémentaires pour les esprits curieux :