Aller au contenu
BackJavaTestTest unitaireTesting

Découvrir les tests par propriété avec Java et jqwik

Et si vos tests pouvaient eux-mêmes inventer de nouveaux scénarios ? Grâce aux tests par propriété, il devient possible de vérifier des règles métier sur des centaines de données générées automatiquement et de détecter des bugs que les tests classiques laissent parfois passer.

Découvrez les tests par propriété

Dans un précédent article, nous avons découvert les tests paramétrés avec JUnit 5. Ceux-ci nous permettent d'exécuter un même test avec plusieurs jeux de données afin d'améliorer la couverture de nos tests.

Cependant, malgré leur puissance, les tests paramétrés possèdent la même limite que les tests unitaires classiques : les données utilisées sont toujours choisies par le développeur.

Et si nous laissions l'outil générer lui-même des centaines de scénarios afin de vérifier qu'une règle métier est toujours respectée ?

C'est précisément le principe des tests par propriété.

Qu'est-ce qu'un test par propriété ?

Un test unitaire classique vérifie qu'un comportement est correct pour une valeur donnée.

@Test
void shouldApplyTenPercentBonus() {
    assertEquals(110,
            ScoreTools.applyBonus(100, 10));
}

Un test paramétré permet de vérifier plusieurs cas.

@ParameterizedTest
@CsvSource({
        "100,10,110",
        "100,20,120",
        "500,10,550"
})
void shouldApplyBonus(int score,
                      int bonusPct,
                      int expected) {

    assertEquals(expected,
            ScoreTools.applyBonus(score, bonusPct));
}

Cette approche est déjà très intéressante.
Cependant, les données testées restent limitées à celles que nous avons imaginées.
Les tests par propriété adoptent une philosophie différente : au lieu de tester des exemples, nous testons une règle qui doit toujours être vraie.

Par exemple :

L'application d'un bonus positif ne doit jamais diminuer le score d'un joueur.

Cette propriété doit être respectée quelles que soient les valeurs utilisées.

Prérequis

Pour utiliser les tests par propriété avec JUnit 5, nous allons utiliser la bibliothèque jqwik.

Ajoutez la dépendance suivante dans votre fichier pom.xml :

<dependency>
    <groupId>net.jqwik</groupId>
    <artifactId>jqwik</artifactId>
    <version>${jqwik.version}</version>
    <scope>test</scope>
</dependency>

N'oubliez pas de remplacer ${jqwik.version} par la version de votre choix.

Un premier exemple

Prenons la méthode suivante :

public class ScoreTools {

    public static int applyBonus(int score,
                                 int bonusPct) {

        return score + (score * bonusPct / 100);
    }
}

Nous pouvons écrire le test suivant :

@Property
void bonusShouldNeverReduceScore(
        @ForAll @IntRange(min = 0, max = Integer.MAX_VALUE)
        int score,
        @ForAll @IntRange(min = 0, max = 100)
        int bonusPct) {
    int result = ScoreTools.applyBonus(score, bonusPct);

    assertTrue(result >= score);
}

Découvrons les nouveautés (principalement des annotations):

  • @Property remplace l'annotation @Test
  • @ForAll indique à jqwik qu'il doit générer automatiquement une valeur
  • @IntRange permet de définir les bornes des valeurs générées

Lorsque ce test est exécuté, jqwik va générer automatiquement des centaines de combinaisons différentes :

score=0 bonus=0
score=10 bonus=25
score=999 bonus=5
score=100000 bonus=50
...

Chaque combinaison sera utilisée pour vérifier que notre propriété reste vraie.

Ceux d'entre vous qui ont l'œil aiguisé auront remarqué que ce test est voué à échouer. Mais chut 🤫, nous allons découvrir pourquoi dans la suite de l'article.

Un bug que les tests classiques peuvent manquer

Imaginons maintenant que nos tests unitaires et paramétrés soient tous au vert.
Pourtant, notre implémentation contient un problème.

Si jqwik génère par exemple :

score = 2_000_000_000
bonusPct = 50

Le calcul suivant sera effectué :

score * bonusPct

Ce qui donne :

100_000_000_000

Malheureusement, cette valeur dépasse largement la capacité maximale d'un entier Java.

Integer.MAX_VALUE

vaut :

2_147_483_647

Nous sommes alors victimes d'un dépassement de capacité (overflow).
Le résultat du calcul devient incohérent et peut même devenir négatif.

Notre propriété :

result >= score

n'est alors plus respectée.

Le test échoue.

Et c'est précisément là que réside toute la force des tests par propriété : ils explorent des cas auxquels nous n'aurions probablement jamais pensé.

Combien d'entre nous auraient volontairement écrit ce test ?

applyBonus(2_000_000_000, 50);

Probablement très peu.
Le générateur, lui, n'a aucun préjugé sur les valeurs à tester.

Le shrinking

Lorsqu'un test par propriété échoue, jqwik tente automatiquement de simplifier les données d'entrée afin de produire le plus petit contre-exemple possible.

Ce mécanisme est appelé shrinking.

Par exemple, le framework peut commencer par trouver un problème avec :

score = 2_000_000_000
bonusPct = 50

Puis chercher une valeur plus simple produisant exactement le même bug :

score = 42_949_673
bonusPct = 100

Cela facilite énormément l'analyse et la correction du problème.
Dans notre exemple plus haut, la sortie en console était la suivante :

                              |-----------------------jqwik-----------------------
tries = 7                     | # of calls to property
checks = 7                    | # of not rejected calls
generation = RANDOMIZED       | parameters are randomly generated
after-failure = SAMPLE_FIRST  | try previously failed sample, then previous seed
when-fixed-seed = ALLOW       | fixing the random seed is allowed
edge-cases#mode = MIXIN       | edge cases are mixed in
edge-cases#total = 25         | # of all combined edge cases
edge-cases#tried = 2          | # of edge cases tried in current run
seed = -8764724880150792667   | random seed to reproduce generated values

Shrunk Sample (22 steps)
------------------------
  score: 21474837
  bonusPct: 100

Original Sample
---------------
  score: 1898746539
  bonusPct: 83

le test échouait avec la valeur 18 987 465 39 et l'application d'un bonus de 83%, après shrinking, jqwik a simplifié jusqu'à trouver le plus petit contre-exemple reproductible :

score: 21474837
bonusPct: 100

Personnaliser les données générées

jqwik fournit de nombreuses annotations permettant de contrôler les données produites.

Par exemple :

@Property
void generatedValuesShouldBePositive(
        @ForAll
        @IntRange(min = 1, max = 1000)
        int value) {

    assertTrue(value > 0);
}

Nous pouvons également générer des listes :

@Property
void sortingShouldPreserveSize(
        @ForAll List<Integer> values) {

    List<Integer> sorted =
            values.stream()
                    .sorted()
                    .toList();

    assertEquals(values.size(),
            sorted.size());
}

Ou encore des chaînes de caractères :

@Property
void upperCaseShouldNotChangeLength(
        @ForAll String value) {

    assertEquals(
            value.length(),
            value.toUpperCase().length());
}

Attention, jqwik peut générer des valeurs parfois surprenantes. Dans notre exemple, nous pourrions penser que la propriété suivante est toujours vraie :

assertEquals(value.length(), value.toUpperCase().length());

Pourtant, certains caractères Unicode ne respectent pas cette hypothèse. Lors de mes tests, jqwik a notamment généré le caractère (U+1E96, Latin Small Letter H with Line Below). Sa représentation en majuscule est composée de deux caractères Unicode : un H suivi d'un caractère combinant. La longueur de la chaîne passe alors de 1 à 2.

Le comportement de Java est parfaitement conforme à la spécification Unicode, mais notre propriété est malgré tout fausse. Ce type de découverte illustre parfaitement l'intérêt des tests par propriété : ils explorent des cas que nous n'aurions probablement jamais envisagés manuellement.

Si ce comportement n'est pas pertinent pour notre contexte métier, nous pouvons restreindre les données générées à l'aide d'annotations telles que @AlphaChars ou @CharRange.

@Property
void upperCaseShouldNotChangeLengthAlpha(
        @ForAll @AlphaChars
        String value) {

    assertEquals(
            value.length(),
            value.toUpperCase().length());
}

@Property
void upperCaseShouldNotChangeLengthRestricted(
        @ForAll @CharRange(from = 'a', to = 'z') String value) {
    assertEquals(
            value.length(),
            value.toUpperCase().length());
}

Configurer le nombre d'exécutions

Par défaut, jqwik exécute chaque propriété 1000 fois.
Il est possible de modifier cette valeur à différents niveaux.

Au niveau du test avec l'attribut tries de @Property :

@Property(tries = 500)
void generatedValuesShouldBePositive(
        @ForAll
        @IntRange(min = 1, max = 1000)
        int value) {

    assertTrue(value > 0);
}

Au niveau de la classe avec @PropertyDefaults :

@PropertyDefaults(tries = 500)
class ScoreToolsTest {
  ...
}

Ou encore au niveau du fichier de properties des tests

jqwik.tries.default=500

Il ya bien évidemment un ordre de priorité d'application de ces derniers :
@Property > @PropertyDefaults > jqwik.properties

Les avantages des tests par propriété

Une couverture beaucoup plus importante

Un seul test peut couvrir plusieurs centaines de scénarios.
L'effort d'écriture reste faible tandis que le nombre de cas testés augmente considérablement.

Une excellente détection des cas limites

Les générateurs explorent naturellement :

  • les valeurs minimales
  • les valeurs maximales
  • les collections vides
  • les chaînes vides
  • les valeurs atypiques

Autant de scénarios souvent oubliés lors de l'écriture manuelle des tests.

Une documentation des invariants métier

Les propriétés décrivent généralement les règles fondamentales de l'application.

Par exemple :

@Property
void loyaltyPointsShouldNeverBeNegative(...)

ou

@Property
void totalPriceShouldNeverBeNegative(...)

Ces règles deviennent visibles et exécutables.

Une approche idéale pour les algorithmes

Les tests par propriété sont particulièrement efficaces pour :

  • les calculs financiers
  • les algorithmes
  • les moteurs de règles
  • les moteurs de recherche
  • les conversions de données
  • les calculs de dates

Les inconvénients

Une approche moins naturelle

Trouver les bonnes propriétés n'est pas toujours simple.

La plupart des développeurs raisonnent spontanément en scénarios :

Que doit retourner cette méthode pour telle entrée ?

Les tests par propriété imposent un changement de perspective :

Quelle règle doit toujours être respectée ?

Des tests parfois moins lisibles

Un test métier classique reste souvent plus explicite.

@Test
void premiumCustomerShouldReceiveTenPercentBonus() {
    ...
}

Le comportement attendu est immédiatement identifiable.

Un temps d'exécution plus important

Un test par propriété est généralement exécuté plusieurs dizaines ou centaines de fois.

L'impact reste souvent négligeable pour des fonctions simples, mais peut devenir problématique sur des tests lents.

Une mauvaise solution pour certains types de tests

Tous les problèmes ne se prêtent pas aux propriétés.

Certaines parties d'une application gagnent peu à être testées de cette manière.

Quand utiliser les tests par propriété ?

Les tests par propriété sont particulièrement adaptés lorsque l'on peut exprimer une règle générale.

Par exemple :

  • un score ne doit jamais devenir négatif ;
  • une remise ne doit jamais augmenter un prix ;
  • une liste triée doit conserver le même nombre d'éléments ;
  • une conversion JSON puis désérialisation doit restituer l'objet d'origine.

Dès qu'un invariant métier existe, cette approche mérite d'être envisagée.

Quand les éviter ?

À l'inverse, ils apportent généralement peu de valeur pour :

  • les contrôleurs REST ;
  • les repositories JPA ;
  • les tests d'intégration ;
  • les écrans Vaadin ;
  • les services ne contenant aucune logique métier.

Dans ces cas, les tests unitaires et les tests d'intégration traditionnels restent généralement plus adaptés.

Conclusion

Les tests par propriété constituent un excellent complément aux tests unitaires et aux tests paramétrés.

Là où les tests classiques vérifient des scénarios que nous avons imaginés, les tests par propriété explorent automatiquement de nouvelles combinaisons afin de vérifier qu'une règle reste toujours vraie.

Ils excellent dans la détection des cas limites et permettent de découvrir des bugs particulièrement difficiles à anticiper, comme les dépassements de capacité ou certaines erreurs algorithmiques.

Comme souvent en ingénierie logicielle, il ne s'agit pas de remplacer une pratique par une autre, mais d'utiliser chaque outil là où il apporte le plus de valeur.


Les générateurs par défaut de jqwik couvrent déjà beaucoup de cas, mais saviez-vous que vous pouvez créer vos propres générateurs pour des types métiers (emails, dates, objets complexes) ?
Pour en savoir plus, lisez le prochain article

Tests par propriété : générateurs custom pour des données réalistes
Après avoir découvert les bases des tests par propriété avec jqwik, il est temps de passer à l’étape suivante : la génération de données réalistes. Emails, dates, objets métier ou utilisateurs complets, découvrez comment créer des générateurs personnalisés.

Tout le code relatif à cet article est consultable ici

GitHub - ErwanLT/springboot-demo: Demo project for spring-boot possibility
Demo project for spring-boot possibility. Contribute to ErwanLT/springboot-demo development by creating an account on GitHub.

Dernier