Activité : Tester des procédures et des fonctions avec effet de bord

Consigne

Prendre connaissance de l’activité avant de commencer. Ce travail a pour but de mettre en évidence les problèmes liés au test automatique de procédures et de fonctions avec effet de bord. Il ne s’agit donc pas simplement de copier le code fourni, mais de comprendre le problème posé par les variables globales. Lisez attentivement l’ensemble du texte.

Le code des tests unitaires doit être utilisé tel quel et ne doit pas être modifié. Le travail est individuel. Vous pouvez communiquer, mais en respectant le code d’honneur.

Situation

Vous êtes chargé de réaliser et de tester un programme en ligne de commande qui permet de résoudre des équations du deuxième degré.

À cette fin, vous recevez d’abord une spécification de la fonction de calcul des racines sous la forme de tests unitaires (class PolynomialTest). À l’aide de cette spécification, vous pouvez déterminer que le nom de la fonction est solveSecondDegreeEquation et que celle-ci doit se trouver dans une classe nommée Polynomial. Vous apprenez également que la fonction renvoie un tableau de solutions qui contient zéro, un ou deux éléments selon que l’équation n’a pas de racine réelle, une racine double ou deux racines.

Vous recevez ensuite une description de l’interface utilisateur sous la forme d’une série de trois exemples de déroulement du programme (figure 1) : un exemple pour une équation sans solution réelle, un exemple pour une équation avec une racine double et un exemple pour équation avec deux solutions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ java -jar target/polynomial-1.0-SNAPSHOT.jar

Résolution d’équation du deuxième degré de la forme : 
ax^2 + bx + c = 0

Veuillez saisir la valeur de a : 1.0
Veuillez saisir la valeur de b : 1.0
Veuillez saisir la valeur de c : 1.0

L’équation n’a pas de racine réelle. 

$ java -jar target/polynomial-1.0-SNAPSHOT.jar

Résolution d’équation du deuxième degré de la forme :
ax^2 + bx + c = 0

Veuillez saisir la valeur de a : 1.0
Veuillez saisir la valeur de b : 2.0
Veuillez saisir la valeur de c : 1.0

L’équation a une racine double : x1 = x2 = -1.000000

$ java -jar target/polynomial-1.0-SNAPSHOT.jar

Résolution d’équation du deuxième degré de la forme :
ax^2 + bx + c = 0

Veuillez saisir la valeur de a : 1.0
Veuillez saisir la valeur de b : 10.0
Veuillez saisir la valeur de c : -39.0

L’équation a deux racines : x1 = -13.000000, x2 = 3.000000
Fig. 1 – Exemples de déroulements du programme

Votre supérieur insiste pour que vous respectiez la spécification à la lettre, mais vous laisse libre pour les détails de l’implémentation. Il insiste également sur le fait que le respect de la spécification soit testé automatiquement à chaque build.

Résultat attendu

Un projet Maven contenant les classes suivantes :

  • Polynomial
  • App
  • UI
  • PolynomialTest
  • UITest

Objectifs

À la fin de ce travail, vous devez :

  1. Connaître la notion d’effet de bord.
  2. Connaître les notion de variable locale et de variable globale.
  3. Connaître les problèmes liés à l’utilisation de variables globales.
  4. Connaître le fait que les procédures et les fonction avec effet de bord sont généralement plus difficiles à tester que les fonctions pures.
  5. Connaître un moyen de rendre testable une procédure ou une fonction avec effet de bord qui utilise les entrés/sorties standard (System.in, System.out, System.console()).

Ressources

Logiciel :

  • Maven
  • Visual Studio Code

Documents :

Mise en route

Ouvrez une fenêtre de terminal (CMD ou PowerShell), rendez-vous dans votre répertoire de travail et lancez la commande suivante :

1
git clone https://gitlab.epai-ict.ch/m404/polynomial.git

Déplacez-vous dans le répertoire polynomial (avec la commande cd) et lancer la commande code . pour ouvrir ce répertoire dans VSC (Visual Studio Code).

Tâche

Votre première tâche est de réaliser la fonction solveSecondDegreeEquation pour trouver les solutions d’une équation du deuxième en utilisant le calcul du discriminant (delta). Pour cela, déplacez-vous dans le répertoire src/main/java/ch/epai/ict/m404/polynomial et créez la classe Polynomial avec la méthode solveSecondDegreeEquation.

Ne poursuivez pas tant que la méthode ne passe pas les trois tests avec succès. Demandez de l’aide en cas de besoin, mais essayez d’abord par vous-même et respectez toujours le code d’honneur !

Votre deuxième tâche est de réaliser le programme demandé. Une première approche consiste à écrire le programme entièrement dans la procédure principale. Le code de la figure 2 contient le squelette de ce programme.

Copiez le code de la figure 2 dans le fichier App.java et remplacez les commentaires TODO par votre code. Utilisez les objets writer et reader (voir encadré PrintWriter et Reader) pour écrire dans la sortie standard ou lire l’entrée standard.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package ch.epai.ict.m404.polynomial;

import java.io.Console;
import java.io.PrintWriter;
import java.io.Reader;
import java.util.Scanner;

public class App {
    public static void main(String[] args) {
        
        // Récupère le writer et le reader de la
        // console (stdout et stdin).
        Console cons = System.console();
        PrintWriter writer = cons.writer();
        Reader reader = cons.reader();

        //
        // Affiche le message d’accueil
        // 

        // TODO : Afficher le message d'accueil avec l'objet writer.

        // 
        // Demande la saisie des valeurs des paramètres a, b et c de l’équation
        //

        Scanner scan = new Scanner(reader);

        // TODO : Lire les valeurs de a, b et c. Au lieu de trois variables
        //        de type double, utilisez plutôt un tableau de trois éléments 
        //        de type double (p. ex. double[] params = new double[3];).

        scan.close();

        //
        // Résous l’équation du second degré 
        //

        // TODO : Appeler la fonction « solveSecondDegreeEquation » en utilisant 
        //        les valeurs des éléments du tableau.

        // 
        // Affiche le résultat
        //

        // TODO : afficher le résultat avec l'objet writer. Utilisez un switch pour 
        //        traiter les trois cas : tableau vide, tableau à un élément et 
        //        tableau à deux éléments.)
    }
}
Fig. 2 – Première implémentation du programme

Si l’on observe le code de ce programme, on constate qu’il est divisé en quatre parties mises en évidence par les commentaires. On peut également remarquer que le commentaire de la troisième partie est redondant puisqu’il correspond exactement au nom de la fonction. Ces deux considérations amènent une remarque importante que l’on peut ériger en principe :

Tout ce que l’on peut dire en code, on doit le dire en code et seulement en code. Le reste se dit à l’aide de commentaires.

Cela signifie deux choses :

  1. On doit éviter d’écrire des commentaires qui ne font que répéter ce que dit le code. En effet, dans un tel cas, soit le commentaire correspond au code et il est inutile, soit il ne correspond pas au code (parce que ce dernier a été modifié) et il est faux.
  2. Lorsqu’un commentaire court tel qu’« Affiche le résultat » précède une séquence d’instructions, il est probable que cette séquence peut devenir un sous‑programme dont le nom dit autant que le commentaire.

La refactorisation (refactoring) est une opération qui consiste à retravailler le code source d’un programme, non pas pour corriger des erreurs ou ajouter des fonctionnalités, mais pour en améliorer la lisibilité et la maintenabilité. La figure 3 montre le résultat de la refactorisation du code de notre programme en respectant ce nouveau principe.

1
2
3
4
5
6
7
8
9
10
package ch.epai.ict.m404.activity3;

public class App {
    public static void main(String[] args) {
        UI.displayWelcomMessage();
        double[] params = UI.promptParameterValues();
        double[] roots = Polynomial.solveSecondDegreeEquation(params[0], params[1], params[2]);
        UI.displayResult(roots);
    }
}
Fig. 3 – Deuxième implémentation du programme

Le code de la figure 4 est le squelette de la classe UI. Remarquez que les commentaires donnent des informations qui ne sont pas fournies par le code. Votre troisième tâche est de réaliser la classe UI. Pour cela, copier ce code dans un fichier UI.java, remplacez les // TODO par votre code et assurez-vous que le programme fonctionne avant de poursuivre.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package ch.epai.ict.m404.polynomial;

public class UI {

    /**
     * Affiche les informations nécessaires à l’utilisateur au démarrage du programme.
     */
    public static void displayWelcomMessage() {
        // Récupère le writer de la console (stdout).
        Console cons = System.console();
        PrintWriter writer = cons.writer();

        // TODO
    }

    /**
     * Demande la saisie des valeurs des paramètres a, b et c de 
     * l'équation du deuxième degré : y = ax^2 + bx + c.
     *
     * @return un tableau qui contient, dans l’ordre, les valeur de a, b et c.
     */
    public static double[] promptParameterValues() {
        // Récupère le writer et le reader de la
        // console (stdout et stdin).
        Console cons = System.console();
        PrintWriter writer = cons.writer();
        Reader reader = cons.reader();

        // TODO
    }

    /**
     * Affiche le résultat qui dépend du nombre de solutions (0, 1 ou 2).
     *
     * @param roots un tableau contenant les racines de l’équation.
     */
    public static void displayResult(double[] roots) {
        // Récupère le writer de la console (stdout).
        Console cons = System.console();
        PrintWriter writer = cons.writer();

        // TODO
    }
}
Fig. 4 – Squelette de la classe UI

Pour vérifier le bon fonctionnement de votre programme, vous n’avez pas d’autre moyen que de l’exécuter et de le tester de manière interactive. En effet, il n’est pas possible, en l’état, de tester automatiquement les procédures de la classe UI car celles-ci dépendent de variable globale (System.console()) qui dépendent de l’environnement (voir encadré ci-après). Pour réaliser des tests automatiques, il faudrait que les procédures de test puissent lire l’écran et actionner les touches du clavier.

Pour régler cette question, il faut regarder de plus près la manière dont on interagit avec le terminal. Pour lire les données en provenance du clavier, on utilise la méthode println ou la méthode printf d’un objet de type PrintWriter. Mais qu’est-ce qu’un objet ? Pour faire court, un objet est une valeur dont le type n’est pas un type simple (primitif). Pour rappel, un type est défini par un ensemble de valeurs et un ensemble d’opérations que l’on peut appliquer à ces valeurs. En programmation, c’est avant tout l’ensemble d’opérations qui nous intéresse. Pour les types simples, on peut écrire des opérations avec des opérateurs arithmétiques et logiques. Pour tous les autres types, on écrit des opérations avec des appels de méthodes. Par exemple, writer.println() est une opération que l’on applique à l’objet writer et dont l’effet est de modifier l’état de l’objet en écrivant une chaîne vide suivit d’un retour à la ligne. Dans notre cas, l’objet writer représente la sortie standard, l’effet est donc d’écrire une chaîne vide suivie d’un retour à la ligne dans la fenêtre de l’émulateur de terminal.

Il est important d’observer que l’appelle de writer.println() est différent des appelles des fonctions et des procédures que l’on a réalisées (p. ex. Polynomial.solveSecondDegreeEquation). En effet, dans le premier cas, l’identifiant qui précède le point est le nom d’une variable, dans le second cas, l’identifiant est le nom d’une classe. Dans le premier cas, il s’agit d’un appel de méthode d’instance, dans le second cas, il s’agit d’un appel de méthode de classe que l’on appelle également méthode statique (d’où le modificateur static).

La différence est importante. Dans le cas d’une méthode statique, le nom de la classe fait partie du nom de la méthode (la classe est juste un espace de noms). Dans le cas d’une méthode d’instance, le nom de la variable est l’objet sur lequel on applique l’opération, cette variable est en fait le premier argument de la méthode. À retenir : lorsque l’on appelle une méthode d’instance sur un objet, l’objet est en fait le premier paramètre de la méthode.

À titre d’exemple, examinons les deux manières de concaténer des chaînes. Soit s, s1 et s2, trois variables de type String :

  • s = s1 + s2; ici l’opération + est appliquée aux opérandes s1 et s2.
  • s = s1.concat(s2); ici la méthode d’instance (opération) concat est appliquée à la chaîne s1, les arguments de l’opération sont donc s1 et s2.

La classe String ne dispose pas d’une telle méthode, mais on pourrait imaginer une troisième solution avec une méthode statique :

  • s = String.concat(s1, s2) ici on appelle une méthode de classe avec les deux arguments s1 et s2.

Enfin, il n’est généralement pas possible de représenter des objets sous forme de texte comme on le fait pour représenter la valeur 2.0 pour un double. À l’exception des chaînes de caractères, on ne peut donc pas créer des objets avec des valeurs littérales. Pour instancier (créer) une valeur de type PrintWriter, on doit utiliser l’opérateur new, comme on le fait pour créer des tableaux. Comme pour un tableau, un objet est instancié dans le tas (heap) et la valeur d’une variable de type PrintWriter, par exemple, n’est pas l’objet lui-même, mais une référence à l’objet.

Avec ces informations à l’esprit, on peut maintenant refactoriser les méthodes de la classe UI pour les rendre testables. En effet, puisque l’objet writer et l’objet reader sont des valeurs dans une variable, on peut les passer en paramètre. On peut donc réécrire le code de l’application de la manière suivante :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package ch.epai.ict.m404.polynomial;

import java.io.Console;
import java.io.PrintWriter;
import java.io.Reader;

public class App {
    public static void main(String[] args) {

        // Récupère le writer et le reader de la
        // console (stdout et stdin).
        Console cons = System.console();
        PrintWriter writer = cons.writer();
        Reader reader = cons.reader();

        UI.displayWelcomMessage(writer);
        double[] params = UI.promptParameterValues(writer, reader);
        double[] roots = Polynomial.solveSecondDegreeEquation(params[0], params[1], params[2]);
        UI.displayResult(writer, roots);
    }
}
Fig. 5 – Troisième implémentation du programme

Votre quatrième tâche est de modifier la classe UI de telle manière que le programme de la figure 5 fonctionne.

Avec cette modification, les méthodes de la classe UI ne sont plus variable globale (l’objet renvoyé par System.console() est un objet global, il est créé au démarrage de l’application et est détruit lorsqu’elle se termine) et peuvent recevoir n’importe quels objets de type Reader et PrintWriter. Grâce à cela, il est désormais possible de réaliser des tests unitaires pour ces méthodes. En effet, l’objet reader et l’objet writer doivent être de type Reader et PrintWriter dans le sens où ils doivent avoir les mêmes opérations, mais ils ne doivent pas nécessairement modifier les entrées/sorties standard. Il est possible, par exemple, d’avoir un reader et un writer qui opère sur des chaînes de caractères.

Pour illustrer cela, rendez-vous dans le répertoire src/test/java/ch/epai/ict/m404/polynomial, créez un fichier UITest.java et copier le code de la figure 6. Lorsque tous les tests passent, vous savez que votre programme respecte parfaitement la spécification.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
package ch.epai.ict.m404.polynomial;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;

import org.junit.Assert;
import org.junit.Test;

public class UITest {

    @Test
    public void displayWelcomMessage_writer() {
        // Crée un objet de type ByteArrayOutputStream pour recevoir les
        // caractère affiché par la procédure. L'objet ainsi créé est de
        // type OutputStream comme le flux de sortie standard (System.out)
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        // Crée objet de type PrintWriter pour l'output stream.
        PrintWriter writer = new PrintWriter(outputStream);

        UI.displayWelcomMessage(writer);
        writer.flush();
        String actualOutput = outputStream.toString();

        // Vérifie que le texte du flux de sortie correspond
        // à ce qui est attendu.
        String expectedOutput = String.format("%nRésolution d’équation du deuxième degré de la forme :%nax^2 + bx + c = 0%n%n");
        Assert.assertEquals(expectedOutput, actualOutput);
    }

    @Test
    public void promptParameterValues_reader_writer() {

        // Crée une chaîne de caractère qui contient les valeurs
        // qui seraient saisies au clavier par un utilisateur.
        // (le %n représente un retour à la ligne).
        String inputString = String.format("1%n2%n-36%n");

        // Crée objet de type ByteArrayInputStream à partir de la chaîne
        // de caractères. L’objet ainsi créé est de type InputStream comme
        // le flux d’entrée standard (System.in)
        InputStream inputStream = new ByteArrayInputStream(inputString.getBytes());

        // Crée un objet de type Reader pour l'input stream
        Reader reader = new InputStreamReader(inputStream);

        // Crée un objet de type ByteArrayOutputStream pour recevoir les
        // caractère affiché par la procédure. L'objet ainsi créé est de
        // type OutputStream comme le flux de sortie standard (System.out)
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        // Crée objet de type PrintWriter pour l'output stream.
        PrintWriter writer = new PrintWriter(outputStream);

        // Appelle la méthode et récupère le contenu du flux de sortie.
        double[] actualParams = UI.promptParameterValues(writer,  reader);
        writer.flush();
        String actualOutput = outputStream.toString();

        // Vérifie que la fonction renvoie les bonnes valeurs pour
        // les paramètres a, b et c.
        double[] expectedParams = {1.0, 2.0, -36.0};
        double delta = 0;

        Assert.assertArrayEquals(expectedParams, actualParams, delta);

        // Vérifie que le texte du flux de sortie correspond
        // à ce qui est attendu.
        String expectedOutput = String.format("Veuillez saisir la valeur de a : Veuillez saisir la valeur de b : Veuillez saisir la valeur de c : %n");
        Assert.assertEquals(expectedOutput, actualOutput);
    }

    @Test
    public void displayResult_no_solution() {
        // Crée un objet de type ByteArrayOutputStream pour recevoir les
        // caractère affiché par la procédure. L'objet ainsi créé est de
        // type OutputStream comme le flux de sortie standard (System.out)
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        // Crée objet de type PrintWriter pour l'output stream.
        PrintWriter writer = new PrintWriter(outputStream);

        // Appelle la méthode et récupère le contenu du flux de sortie.
        double[] results = {};
        UI.displayResult(writer, results);
        writer.flush();
        String actualOutput = outputStream.toString();

        // Vérifie que le texte du flux de sortie correspond
        // à ce qui est attendu.
        String expectedOutput = String.format("L’équation n’a pas de racine réelle.%n%n");
        Assert.assertEquals(expectedOutput, actualOutput);
    }

    @Test
    public void displayResult_one_solution() {
        // Crée un objet de type ByteArrayOutputStream pour recevoir les
        // caractère affiché par la procédure. L'objet ainsi créé est de
        // type OutputStream comme le flux de sortie standard (System.out)
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        // Crée objet de type PrintWriter pour l'output stream.
        PrintWriter writer = new PrintWriter(outputStream);

        // Appelle la méthode et récupère le contenu du flux de sortie.
        double[] results = { -1.0 };
        UI.displayResult(writer, results);
        writer.flush();
        String actualOutput = outputStream.toString();

        // Vérifie que le texte du flux de sortie correspond
        // à ce qui est attendu.
        String expectedOutput = String.format("L’équation a une racine double : x1 = x2 = %f%n%n", results[0]);
        Assert.assertEquals(expectedOutput, actualOutput);
    }

    @Test
    public void displayResult_two_solutions() {
        // Crée un objet de type ByteArrayOutputStream pour recevoir les
        // caractère affiché par la procédure. L'objet ainsi créé est de
        // type OutputStream comme le flux de sortie standard (System.out)
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        // Crée objet de type PrintWriter pour l'output stream.
        PrintWriter writer = new PrintWriter(outputStream);

        // Appelle la méthode et récupère le contenu du flux de sortie.
        double[] results = { -13.0, 3.0 };
        UI.displayResult(writer, results);
        writer.flush();
        String actualOutput = outputStream.toString();

        // Vérifie que le texte du flux de sortie correspond
        // à ce qui est attendu.
        String expectedOutput = String.format("L’équation a deux racines : x1 = %f, x2 = %f%n%n", results[0], results[1]);
        Assert.assertEquals(expectedOutput, actualOutput);
    }
}
Fig. 6 – Test unitaire pour les méthodes de la classe UI