Programmation par contrat
Pour chacune des méthodes d'une classe, on écrit une Javadoc décrivant son comportement (arguments attendus, action de la méthode, valeur de retour, éventuelles exceptions levées).
Au niveau du code, on vérifie :
- Si les valeurs des arguments sont valides;
- Si des invariants algorithmiques sont vérifiés;
- Et si la valeur de retour ou les arguments (s'ils sont mutables) vérifient certaines conditions
Exemple : suppression des doublons d'une liste
- Condition sur l'argument : la liste doit être préalablement triée et ne doit pas contenir de références nulles
- Invariant algorithmique : le nombre d'élements de la liste est égal à la taille initiale moins le nombre de doublons trouvés
- Post-condition sur la liste : elle reste triée, il n'y a plus de doublons
package fr.upem.jacosa.safety; import java.util.Iterator; import java.util.List; public class DuplicateRemover { /** * Determine if a list is sorted (and does not contain null references) * @param list the list to check * @return if the list is sorted according to the default comparator */ public static <T extends Comparable<T>> boolean isSorted(List<T> list) { T previous = null; for (T element: list) if (element == null || (previous != null && previous.compareTo(element) > 0)) return false; else previous = element; return true; } /** * Get the number of duplicates in a sorted list (without null references) * @param list the sorted list * @return the number of duplicates */ public static <T extends Comparable<T>> int getDuplicateNumber(List<T> list) { assert(isSorted(list)); T previous = null; int duplicates = 0; for (T element: list) if (previous != null && previous.equals(element)) duplicates++; else previous = element; return duplicates; } /** * Remove the duplicates from a sorted list * @param list the sorted list (without null references) */ public static <T extends Comparable<T>> void removeDuplicates(List<T> list) { assert(isSorted(list)):"The list is not sorted (or contain null references)"; int initialSize = list.size(); T previous = null; int duplicates = 0; for (Iterator<T> it = list.iterator(); it.hasNext(); ) { T element = it.next(); if (previous != null && element.equals(previous)) { it.remove(); duplicates++; } else previous = element; assert(initialSize == list.size() + duplicates); } assert(getDuplicateNumber(list) == 0); } }
Les assertions (Java 1.5)
- assert(condition booléene):"Message à afficher si false"
-
Mécanisme embrayable sur demande :
- java -ea pour activer les assertions partout (sauf dans la JDK)
- java -ea:fr.upem... pour activer les assertions uniquement dans le paquetage \texttt{fr.upem}
-
Assertion ou exception ?
- Assertion : pour vérifier des invariants, tester des post-conditions, tester des pré-conditions de méthodes d'usage interne
- IllegalArgumentException : pour tester des arguments de méthodes exposées publiquement
Tests unitaires
- Pour tester des classes et méthodes isolément
- Permet de séparer le code des tests qui sont réalisés sur celui-ci (permet une meilleure généricité et réutilisation des tests)
- Permet une automatisation des tests après la compilation de nouvelles versions : répérage des regressions (avec des systèmes d'intégration continue tel que Jenkins)
- Framework de tests unitaires le plus répandu en Java : JUnit
Principe général de JUnit
- Module de test : classe sans constructeur (constructeur par défaut) dont le nom se termine par Test
- Chaque méthode représente un test à exécuter
-
Des annotations indiquent dans quel ordre exécuter les tests :
- @BeforeAll, @AfterAll : à exécuter à l'initialisation du module, à la fin de la série de tests (méthodes statiques)
- @BeforeEach, @AfterEach : à exécuter avant, après chaque test
-
@Test : annote une méthode de test (ne prend plus de paramètre depuis JUnit 5) ; on peut compléter cette annotation avec d'autres annotations pour préciser les conditions du test :
-
@Timeout(N) : indique que le test doit s'exécuter en moins de N secondes (possibilité aussi d'utiliser d'autres unités, par exemple @Timeout(value = 5, unit = TimeUnit.MILLISECONDS))
- ⚠ Le temps d'exécution d'une méthode peut être très variable et dépend de l'environnement (machine, autres processus actifs...)
- Cette annotation permet de repérer d'éventuelles boucles infinies
-
@Timeout(N) : indique que le test doit s'exécuter en moins de N secondes (possibilité aussi d'utiliser d'autres unités, par exemple @Timeout(value = 5, unit = TimeUnit.MILLISECONDS))
-
Les méthodes de test (et classes) peuvent être annotées avec des étiquettes arbitraires pour permettre de filtrer les tests à exécuter
- @Tag("importantTest")...
-
Dans les méthodes, on vérifie des assertions en appelant des méthodes statiques de Assertions (classe fournie par JUnit) ; si l'assertion n'est pas vérifiée, le test échoue avec le message indiqué :
- assertTrue(condition, message) : vérifie la condition booléenne
- assertEquals(expected, actual, message) : vérifie si expected et actual sont égaux
- assertSame(expected, actual, message) : vérifie si les deux références sont identiques
- assertThrows(exceptionClass, lambda, message) : vérifie si le code exécuté dans la lambda lève bien une exception dont la classe est précisée en 1er argument
- Les méthodes d'assertions acceptent aussi un Supplier<String> comme message pour générer le message paresseusement (uniquement en cas d'erreur)
Voici un squelette de classe pour réaliser des tests :
public class MyTests { @BeforeAll public static void initializeAllTests() { // ... } @BeforeEach public void initializeEachTest() { // ... } @Test @Tag("important") public void firstTest() { // ... make some assertions } @Test @Tag("futile") @Timeout(value = 10, unit = TimeUnit.SECONDS) // protection against infinite loops public void secondTest() { // ... other assertions } @AfterEach public void doAfterEachTest() { } @AfterAll public static void afterAllTests() { } }
Tests paramétrés
- Par défaut une méthode de test n'a pas d'argument
- Ajout d'un argument à un test avec l'annotation @ParameterizedTest
-
Nécessité de spécifier comment les arguments doivent être générés pour les tests
- Génération avec une @MethodSource("nameOfMethod") pointant vers une méthode statique retournant un Stream d'arguments
- Spécification des arguments avec du texte CSV (lignes avec plusieurs arguments séparés par des virgules) avec @CsvSource({"ligne1", "ligne2",...} ou @CsvSourceFile("/chemin/vers/le/fichier/depuis/le/classpath")
- Spécification d'un tableau de String ou de primitifs pour les arguments avec @ValueSource({2,3,5,7,11,...})
Quelques exemples de tests paramétrés :
package fr.upem.jacosa.safety; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import java.util.stream.IntStream; /** Introduce some parameterized tets using JUnit */ public class SomeParameterizedTests { /** Is the indexOf method of string working correctly? * We use a CSV source to test it with some examples */ @DisplayName("Search a needle into a haystack") @ParameterizedTest @CsvSource({"bonjour,jour,3", "au revoir,rev,3", "foobar,foo,0"}) void testStringIndexOf(String s, String needle, String expectedIndex) { Assertions.assertEquals(expectedIndex, s.indexOf(needle)); } /** Test if the method Math.sqrt works flawlessly with some integers */ @DisplayName("Test the Math.sqrt method") @MethodSource("intGenerator") void testSqrt(int i) { Assertions.assertEquals(i, Math.sqrt(i*i)); } /** Return the integers from 0 to 1000 */ public static IntStream intGenerator() { return IntStream.iterate(0, x -> x + 1).limit(1000); } }
Testons le supprimeur de doublons
package fr.upem.jacosa.safety; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Random; import java.util.Set; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class DuplicateRemoverTest { public static final int LIST_SIZE = 128; public static final int MAX_NUMBER = 10; private List<Integer> list = null; private static Random random = null; @BeforeAll public static void initClass() { random = new Random(); } @BeforeEach public void initList() { list = new ArrayList<Integer>(); for (int i = 0; i < LIST_SIZE; i++) list.add(random.nextInt(MAX_NUMBER+1)); Collections.sort(list); } @Test public void testDuplicateRemover() { Assertions.assertTrue(DuplicateRemover.isSorted(list), "Initial list not sorted"); int initialSize = list.size(); int duplicateNumber = DuplicateRemover.getDuplicateNumber(list); DuplicateRemover.removeDuplicates(list); Assertions.assertTrue( initialSize == list.size() + duplicateNumber, "Incoherent number of removed duplicates"); Assertions.assertTrue(DuplicateRemover.isSorted(list), "Not sorted list"); } @Test public void testDuplicateRemover2() { Set<Integer> set = new HashSet<Integer>(list); DuplicateRemover.removeDuplicates(list); Set<Integer> set2 = new HashSet<Integer>(list); Assertions.assertFalse(set2.size() > set.size(), "Some elements have been added"); Assertions.assertFalse(set2.size() < set.size(), "Some elements have been removed"); Assertions.assertEquals(set, set2, "Some elements have been modified"); } }
Lançons les tests avec JUnit
- JUnit utilisable également nativement depuis la plupart des IDE Java : Eclipse, IntelliJ, Netbeans...
- Support natif pour l'outil de construction Gradle en ajoutant dans le fichier build.gradle :
test { useJUnitPlatform() }