📘 Concevoir des scénarios de test

Les chapitres précédents ont défini le concept de scénario de test et son importance centrale dans l’activité de test logiciel. Ce chapitre s’intéresse à deux questions essentielles : qu’est-ce qu’un bon scénario de test et comment concevoir de bons scénarios de tests ?

1. Motivation

1.1. Problème : trop de choses à tester

Par nature, un même logiciel (ou une même partie d’un logiciel) peut s’exécuter d’un très grand (voire, un immense) nombre de manières différentes. Cela est dû à la taille presque toujours gigantesque du domaine d’entrée d’un logiciel, à savoir l’ensemble des valeurs possibles qu’il est susceptible de recevoir en entrée pour son exécution. Et, pour chaque lot de données d’entrée possible, un logiciel est susceptible de produire un comportement différent à vérifier.

Exemple 1. Taille du domaine d’entrée de PALINDROME

On rappelle la spécification de la méthode isPalindrome :

package app;

interface Palindrome {
    /**
     * Verifies that a word is a palindrome.
     * Ignores the character case.
     * 
     * @return true if word is a palindrome, false otherwise.
     * @throws NullPointerException if word is null
     * @throws IllegalArgumentException if word is empty 
     *                                  or contains a digit, 
     *                                  or contains a special character.
     **/
    boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException;
}

Nous avions établi que le domaine d’entrée de cette méthode est l’ensemble : String ∪ { null }

Puisque c’est une méthode objet, il faut également considérer l’objet appelant comme variable d’entrée, ce qui signifie que le domaine d’entrée est en réalité Palindrome × (String ∪ { null }). Cependant, on admettra ici que l’objet appelant n’a pas d’influence sur la méthode isPalindrome.

La taille de ce domaine d’entrée est donc assimilable à la taille de l’ensemble String.

En Java, une chaîne de caractère est stockée dans l’espace mémoire appelé le tas, dont la taille n’est pas théoriquement pas limitée, sauf par la mémoire physique disponible de l’ordinateur utilisé. Cependant, le nombre de caractères dans une chaîne est limité par la valeur Integer.MAX_VALUE qui est constante égale à 2147483647 (le nombre maximal que l’on peut encoder avec 32 bits, c’est à dire 2³¹-1).

Sachant qu’il y a 287472 caractères Unicode différents théoriquement utilisables en Java, on a donc en théorie 2147483647 × 287472 chaînes de caractères possibles, soit 6,17341419×10¹⁴ !

Ainsi, pour être certain de ne pas manquer le moindre comportement possible d’un logiciel, et de s’assurer qu’un logiciel est correct dans toutes les situations possibles, la seule solution serait de définir un scénario de test pour chaque possibilité du (généralement immense) domaine d’entrée. Cet effort étant irréalisable à la fois d’un point de vue humain (du fait du temps de conception d’un scénario) et d’un point de vue technique (du fait du temps d’exécution requis pour exécuter autant de tests), une autre manière de faire s’avère nécessaire.

1.2. Idée : définir et utiliser des cibles

Il faut donc se donner un objectif plus modeste que de couvrir l’intégralité que le domaine d’entrée. Or, intuitivement, on peut comprendre en lisant la spécification ou le code d’un logiciel qu’il existe assez clairement des cas que l’on pourrait qualifier de "plus importants" ou "prioritaires" (par exemple vérifier le cas "le mot est un palindrome"), et on peut comprendre également qu’il existe une forte redondance entre différentes données d’entrée (par exemple les entrées "abcd" et "abce", bien que différents, sont très vraisemblablement équivalents − et donc redondants − pour PALINDROME).

L’idée centrale pour répondre à notre problème est donc d’identifier, pour un logiciel testé donné, un ensemble de situations que l’on juge "intéressantes" et "importantes" de tester, au vu du contexte et des informations disponibles. Dans ce cours on appelle cible un objectif identifié que l’on souhaitera couvrir à l’aide d’un scénario de test. Une cible pourra aussi bien être déterminée à partir de la spécification du logiciel testé (quelles fonctionnalités tester ? quelles combinaisons de situations prévues tester ?), ou plus directement à partir du code du logiciel testé (quelles lignes de code tester ? quels potentiels défaut veut-on être capable de détecter ?).

Contrairement au domaine d’entrée qui est une réalité incontestable et insurmontable, la définition d’un ensemble de cibles, même méthodique, sera influencée par des choix et des jugements. Ainsi, pour un même problème de test, on peut juger nécessaire de définir un grand nombre de cibles différentes, ou un nombre plus restreint. Cela peut notamment dépendre des objectifs de qualité logicielle souhaités, ou encore des ressources humaines disponibles.
Exemple 2. Quelques cibles possibles pour isPalindrome

Dans un premier temps, on s’intéresse à la spécification de la méthode isPalindrome :

package app;

interface Palindrome {
    /**
     * Verifies that a word is a palindrome.
     * Ignores the character case.
     * 
     * @return true if word is a palindrome, false otherwise.
     * @throws NullPointerException if word is null
     * @throws IllegalArgumentException if word is empty 
     *                                  or contains a digit, 
     *                                  or contains a special character.
     **/
    boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException;
}

On choisit un premier ensemble de 6 cibles suivantes :

  1. le cas où word est un palindrome,

  2. le cas où word n’est pas un palindrome,

  3. le cas où word possède un caractère spécial,

  4. le cas où word possède un chiffre,

  5. le cas où word est vide,

  6. le cas où word est null.

Sans la nommer, nous avons ici énuméré des cibles en s’inspirant d’une approche dite combinatoire sur la base de la spécification de la méthode.

On prépare la suite de tests suivante :

  • Entrée : word = "anna" | Oracle : result = true

    • ce scénario couvre uniquement la cible 1

  • Entrée : word = "4n!n4" | Oracle : IllegalArgumentException

    • ce scénario couvre les cibles {1,3,4}

Cette suite de tests couvre un total de 3 cibles {1,3,4}, donc 50% des cibles considérées.

Dans un second temps, on s’intéresse au code de l’implémentation de isPalindrome :

public class BasicPalindrome implements Palindrome {
    @Override
    public boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException {
        Objects.requireNonNull(word);
        if (word.isEmpty() || !Pattern.matches("[\\p{Alpha}]*", word)) {
            throw new IllegalArgumentException();
        }
        for (int i = 0; i< word.length() / 2; i++ ) {
            char f = word.charAt(i);
            char l = word.charAt(word.length() - i - 1);
            if (f != l) {
                return false;
            }
        }

On choisit un second ensembles de 8 cibles suivantes :

  1. l’exécution de la ligne de code 4,

  2. l’exécution de la ligne de code 5,

  3. l’exécution de la ligne de code 7,

  4. l’exécution de la ligne de code 8,

  5. l’exécution de la ligne de code 9,

  6. l’exécution de la ligne de code 10,

  7. l’exécution de la ligne de code 11,

  8. l’exécution de la ligne de code 14.

Sans la nommer, nous avons ici énuméré des cibles en s’inspirant d’une approche dite structurelle sur la base de la structure du code de la méthode.

On reprend la même la suite de tests que au dessus :

  • Entrée : word = "anna" | Oracle : result = true

    • ce scénario couvre les cibles {4,7,8,9,14},

  • Entrée : word = "4n!n4" | Oracle : IllegalArgumentException

    • ce scénario couvre les cibles {4,5}

Cette suite de tests couvre un total de 6 cibles {4,5,7,8,9,14}, donc 75% des nouvelles cibles considérées.

Une fois un ensemble de cibles définies pour un problème de test donné, elles peuvent être utilisées de deux manières :

  • d’une part comme objectifs pour la conception de scénarios de tests, c’est à dire de chercher à couvrir un maximum de cibles avec chaque nouveau scénario de test défini,

  • d’autre part comme métrique pour mesurer la qualité d’une suite de tests, en calculant le pourcentage de cibles couvertes par l’ensemble des scénarios qui composent une suite de tests − on appelle cette valeur le taux de couverture.

Afin de limiter la taille d’une suite de tests, il sera souvent souhaitable qu’un scénario de test donné couvre un maximum de cibles identifiées. Notons cependant qu’il peut être parfois préférable d’isoler certaines cibles dans des scénarios isolés plutôt que de les couvrir dans un même scénario.

2. Principe d’une approche de test

Dans ce cours, on nomme approche de conception de scénarios de test (ou simplement approche de test) un procédé permettant de définir de manière méthodique un ensemble de cibles pour un problème de test donné. Une approche de test se définit par :

  • (si nécessaire) une manière de modéliser le problème de test considéré,

  • la forme que prend une cible dans l’approche de test considérée, ainsi que la définition de ce que signifie "couvrir" cette cible avec un scénario de test,

  • un ensemble de critères de couverture, chaque critère définissant quelles cibles considérer parmi l’ensemble (souvent grand) de cibles théoriquement possibles,

  • (si possible), un procédé pour générer des cibles à partir d’un critère de couverture donné.

Une fois définie, une approche de test se met en œuvre de la manière suivante :

conception scenarios.drawio
Figure 1. Conception de scénarios de test à partir d’une approche de test donnée
  1. À gauche, on représente le logiciel testé ainsi que la spécification qu’il doit satisfaire.

  2. Si l’approche le nécessite, on réalise une modélisation du logiciel et/ou de spécification.

  3. On définit un critère de couverture comme le demande l’approche choisie.

  4. On applique le critère de couverture pour énumérer, soit à partir du modèle, soit directement à partir du logiciel, un ensemble de cibles que l’on souhaite couvrir.

  5. On définit un ensemble de scénarios de test, chaque scénario allant "couvrir" un ensemble de cibles.

  6. On implémente un script de test pour chaque scénario de test défini.

  7. On mesure le taux de couverture (autrement dit la qualité) de la suite de tests résultante, en calculant le pourcentage de cibles atteintes parmi les cibles considérées.

Selon l’approche, il est parfois possible de reposer sur des outils logiciels pour nous aider dans certaines étapes, que ce soit la modélisation, la génération de cibles, ou la mesure du taux de couveture.

3. Approches étudiées dans le cours

Dans ce cours, si le temps le permet, nous étudierons trois approches de test :

  • L’approche combinatoire repose sur une analyse de la spécification d’un logiciel testé afin d’identifier l’ensemble des situations auxquelles doit pouvoir répondre le logiciel, chaque situation étant atteignable à l’aide de données d’entrée spécifiques. On réalise pour cela une modélisation en arbre de classification de la spécification, pour représenter l’ensemble des choix possibles concernant les données d’entrée (ex. "tel paramètre peut être soit absent, soit valide, soit invalide"). Une cible (c’est à dire une situation) prend la forme d’un groupe de plusieurs choix, qui peut être couverte si les données d’entrées d’un scénario satisfont ces choix. Cette approche vise à couvrir le mieux possible ce qui est définit dans la spécification du logiciel testé.

  • L’approche structurelle repose sur une analyse de la structure du code d’un logiciel testé afin d’identifier l’ensembles des exécutions pouvant se produire, chaque exécution étant atteignable à l’aide de données d’entrée spécifiques. On réalise pour cela une modélisation en graphe de flot de contrôle du code d’implémentation du logiciel, pour représenter l’ensemble des instructions atteignables dans le code, et les transitions possible d’une instruction à une autre. Une cible (c’est à dire une exécution) prend la forme d’un élément du graphe de flot de contrôle, qui peut être couvert si atteint par une exécution d’un scénario. Cette approche vise à couvrir le mieux possible le code d’implémentation du logiciel testé.

  • L’approche par mutation, repose sur l’idée d’anticiper les potentiels défauts qui pourraient être ajoutés dans le code dans le futur (cad des régressions). Sans modélisation préalable, on définit pour cela des opérateurs de mutation, chaque opérateur étant capable d’injecter un défaut (appelé mutation) dans le code d’un logiciel. On peut ainsi produire un ensemble de "versions alternatives" du logiciel testé appelées mutants, chacune contenant exactement une mutation. Une cible est ici une mutation dans le code source (et donc un mutant qui la contient), c’est à dire un défaut que les tests doivent être capables de détecter. Cette approche vise à couvrir le mieux posssible les potentiels défauts qui pourront être malencontreusesment ajoutés dans le code dans le futur.

On peut résumer ces trois approches sous la forme du tableau suivant :

Approche de test Modélisation employée Création d’un scénario de test Forme d’une cible Exemple de critère de couverture

Combinatoire

Arbre de classification qui définit les "choix" possibles

À partir d’une combinaison valide de choix, en déduire données d’entrées et oracle

Groupe de plusieurs choix

Couvrir toutes les paires de choix possibles (pairwise)

Structurelle

Graphe de flot de contrôle qui définit les exécutions possibles

À partir d’une chemin valide dans le graphe, en déduire données d’entrées et oracle

Élément (ou groupe d’éléments) du graphe

Couvrir tous les sommets du graphe

Par mutation

N/A

À partir d’un mutant non-équivalent, en déduire un scénario qui échoue

Mutation du code, cad un défaut ajouté dans le code

Couvrir toutes les mutations du type "remplacement de < par `>`"

Il est tout à fait posible que des approches de test différentes entrainent la définition de scénarios de test identiques. En effet, comme vu avec isPalindrome plus haut, un même scénario de test va toujours simultanément couvrir les cibles obtenus par différentes approches de test. Il est donc normal qu’un tel scénario puisse être obtenu via ces différentes approches.
En complément du point ci-dessus, on notera qu’il est tout à fait valable de combiner plusieurs approches de test sur un problème de test donné. Cela permet de "combiner de points de vue" et d’augmenter les chances de couvrir l’ensemble des cas importants, et donc d’augmenter la qualité globale de la suite de test.