📘 Testabilité, dépendances et doublures
1. Problème : situations difficiles de test logiciel
Bien que les exemples et exercices étudiés dans ce cours jusqu’ici aient permis de mettre lumière des problèmes non triviaux (comment automatiser ? comment choisir des données d’entrée ? comment implémenter des oracles ?), il existe de manière transversale un ensemble de situations qui rendent le test logiciel très difficile, surtout face à un logiciel réel ou réaliste.
Supposons que nous cherchions à tester le serviceA d’un composant logiciel dénoté A (par exemple une classe Java).
Pour fonctionner, ce composant dépend de deux composants B et C (par exemple d’autres classes Java, provenant du même logiciel ou d’une bibliothèque séparée) que nous appelerons les dépendances de notre composant logiciel.
Plus précisément, serviceA nécessite le serviceB fourni par B, et serviceC fourni par C.
| Dans le cas général, une dépendance peut prendre des formes très diverses, comme un serveur distant ou une dépendance matérielle (capteur, moteur, etc). |
Intéressons nous à trois situations qui vont rendre le test de ce composant difficile :
-
Le composant logiciel A peut être difficilement testable, autrement dit ce composant peut faire preuvre d’une testabilité insuffisante. On peut distinguer deux grands cas de figure :
-
Un problème de testabilité endogène au composant testé (ici A), du fait de sa conception / de son architecture (peu configurable, état non accessible, etc.),
-
Un problème de testabilité exogène au composant testé, du fait des dépendances utilisées par ce composant (dépendances peu fiables, non contrôlables, etc), ici B et C. Dit autrement, les caractéristiques de B et C rendent difficile le test de A, qui est par conséquent moins testable.
-
-
Le composant logiciel peut être inutilisable car certaines dépendances sont indisponibles, que ce soit parce qu’elles ne sont pas implémentées, ou bien parce qu’on considère qu’il n’est pas possible de mettre en place ces dépendances dans le cadre d’un test. Par exemple si B n’a pas encore été implémenté, il n’est pas possible d’exécuter le code de A et donc de tester que ce code est correct.
-
On désire tester notre composant de manière isolée de l’ensemble de ses dépendances, afin de garantir que les tests réalisés sur ce composant n’exécutent et ne vérifient que le code de ce composant. Par exemple on peut souhaiter tester A sans exécuter une seule ligne de B et C, pour ne pas qu’un défaut dans l’une de ces dépendances vienne influencer les conclusions sur la qualité de A.
Il existe une pratique consistant à systématiquement tester chaque composant de manière isolée des autres, mais cette pratique ne fait pas l’unanimité du fait de son coût très important. Ce cas de figure sera mentionné à nouveau dans la partie sur les doublures de test, mais nous n’allons pas étudier plus en détail cette pratique dans ce cours.
Dans ce chapitre, nous allons d’abord définir et illustrer plus en détail la notion de testabilité (situations 1.a et 1.b), puis nous étudierons la notion de doublure de test qui permet de faire face aux différentes situations affectées par les dépendances du composant testé (situations 1.b, 2, 3)
2. Testabilité
2.1. Définition
On appelle testabilité la capacité d’un logiciel à être testé de manière automatisée dans un contexte donné. C’est un facteur de qualité d’un logiciel qui s’intéresse à l’effort nécessaire pour le tester.
Il existe un large spectre de niveaux de testabilité. Par exemple, une bibliothèque logicielle proposant une riche API permettant d’analyser tous les recoins des services qu’elle offre sera elle facilement testable. À l’opposé, un logiciel ne proposant qu’une interface graphique et aucun moyen d’interaction textuel ou programmatique sera difficile à tester correctement de manière automatisée. Entre ces deux extrêmes on trouve également des situations courantes comme l’usage d’une base de donnée ou la dépendance à un service totalement externe (ex. une API REST tierce sur le web), qui compliquent forcément l’activité de test.
On parlera de problème de testabilité lorsqu’une situation réduit la testabilité d’un composant donné.
La testabilité peut être décomposée en deux caractéristiques :
-
Contrôlabilité : peut-on contrôler le logiciel testé, c’est à dire peut-on influencer (facilement) ses variables d’entrée (état initial, arguments, accès disque, etc) ?
-
Observabilité : peut-on observer qu’a accompli le logiciel testé, et plus précisément peut-on (facilement) accéder à toutes ses variables de sortie (nouvel état atteint, valeurs de sortie, actions sur l’environnement, écritures disque réalisées, etc) ?
| Une bonne observabilité est requise pour implémenter l'oracle d’un scénario qui teste le logiciel. |
Un composant logiciel fait preuve d’une bonne contrôlabilité s’il est facilement possible d’influencer les valeurs des différentes données qui vont influencer son comportement (autrement dit influencer les données d’entrée du composant). En outre, la contrôlabilité sera améliorée s’il est possible de modifier ces valeurs sans nécessiter trop d’étapes de configuration ou d’actions.
Un composant logiciel fait preuve d’une bonne observabilité s’il fournit des services permettant de l’interroger sur son état ou sur les actions (et leurs conséquences) qu’il a pu réaliser lors de son exécution (autrement dit, observer ses données de sortie). En outre, l’observabilité sera améliorée si ce qu’on peut observer est disponible dans des données structurées sur lesquelles on peut facilement écrire des oracles (entiers, objets, collections) plutôt que sous forme de données "brut" (chaîne de caractère, binaire).
Dans les sections qui suivent, nous allons étudier ces deux caractéristiques d’abord dans le contexte des problèmes de testabilité endogènes, puis des problèmes de testabilité exogènes.
2.2. Problèmes de testabilité endogènes
Dans ce cours on nomme problème de testabilité endogène un problème de testabilité au sein d’un composant qui n’est pas lié aux dependances du dit composant, mais qui est lié aux choix de conception du composant logiciel testé.
On souhaite tester la méthode play d’une classe TicTacToeGameImpl qui réalise un jeu de morpion.
Cette classe et cette méthode sont spécifiés par l’interface TicTacToeGame suivante :
public interface TicTacToeGame {
/**
* Prints the current state of the TicTacToe grid
* in text format in the console.
*
* Example:
* --o
* --o
* -x-
*/
void printGrid();
/**
* Plays a move as the current player, then switches to the other
* player. Does nothing if the action is invalid (out of bounds
* or occupied cell).
*
* @param x The x coordinate.
* @param y The y coordinate.
*/
void play(int x, int y);
}
La classe TicTacToeGameImpl que l’on souhaite tester est implémentée de la manière suivante
import java.util.Random;
public class TicTacToeGameImpl implements TicTacToeGame {
private final PLAYER[][] gridOccupancy;
private PLAYER currentPlayer;
private enum PLAYER {
CROSS('x'), CIRCLE('o'), NONE('-');
private char symbol;
private PLAYER(char symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return this.symbol + "";
}
}
private static PLAYER[][] createInitialOccupancy() {
PLAYER[][] result = new PLAYER[3][3];
for (int i = 0; i < result.length; i++) {
for (int j = 0; j < result[0].length; j++) {
result[i][j] = PLAYER.NONE;
}
}
return result;
}
/**
* Creates a new game of TicTacToe. The first player is random.
*/
public TicTacToeGameImpl() {
this.gridOccupancy = createInitialOccupancy();
this.currentPlayer = new Random().nextBoolean() ?
PLAYER.CIRCLE : PLAYER.CROSS;
}
@Override
public void printGrid() {
System.out.println("Grid :");
for (int i = 0; i < this.gridOccupancy.length; i++) {
for (int j = 0; j < this.gridOccupancy[0].length; j++) {
System.out.print(gridOccupancy[i][j]);
}
System.out.println();
}
}
@Override
public void play(int x, int y) {
if (x >= 0 && x <= 2 && y >= 0 && y <= 2
&& gridOccupancy[x][y] == PLAYER.NONE) {
this.gridOccupancy[x][y] = this.currentPlayer;
this.currentPlayer = this.currentPlayer ==
PLAYER.CROSS ? PLAYER.CIRCLE : PLAYER.CROSS;
}
}
}
On réalise un diagramme de classes UML pour y voir plus clair :
Pour rappel on souhaite tester la méthode play de la classe TicTacToeGameImpl.
On tente de s’intéresser pour cela aux entrées et aux sorties :
-
this∈TicTacToeGameImpl-
l’état actuel du jeu ? (appels antérieurs à
play)
-
-
this∈TicTacToeGameImpl-
printGrid? mais type de retourvoid?
-
Pas très clair ! En effet, on constate qu’un objet ayant pour type TicTacToeGame n’offre comme services que :
-
L’affichage de la grille dans son état courant via
printGrid, -
La possibilité pour le joueur courant de jouer dans une case via
play.
Concernant la contrôlabilité, Il est seulement possible de créer une grille dans un état initial (constant), puis faire joueur les joueurs un par un.
Ainsi, si on souhaite un scénario de test visant à vérifier le comportement du jeu lorsque la grille est dans un état particulier, la seule manière d’atteindre cet état est d’effectuer de multiples appels à play.
Cela pose à nouveau de nombreux problèmes :
-
Préparer une séquence correcte d’appels à
playpeut être source d’erreur. -
On peut constater qu’il n’est pas possible de contrôler quel sera le joueur initial car c’est déterminé de manière aléatoire ! Ainsi, chaque exécution du logiciel amènera à un résultat différent.
-
Il est par conséquent difficile d’incluencer quel est le joueur courant, du fait de l’aléatoire et de la nécessité d’appeler
playun bon nombre de fois.
Concernant l’observabilité, la seule manière pour un utilisateur de cette interface (et a fortiori de la classe TicTacToeGameImpl) de savoir ce qu’il se passe − et donc la seule manière de pouvoir écrire un oracle − est l’affichage de la grille dans la console.
Cela pose de nombreux problèmes :
-
Un affichage console n’est pas facilement programmatiquement analysable. Il est à minima possible de récupérer une
Stringcontenant tout ce qui a été affiché dans la console, mais cela implique rediriger tout le fluxSystem.outdans une variable, ce qui n’est ni souhaitable ni facile. -
Même si on récupére le contenu de la grille dans une
String, il va être nécessaire d’analyser et décomposer cetteStringpour en déduire quel est l’état du jeu en terme de cases occupées ou bien en terme de joueur courant (en comptant le nombre de coups de chaque joueur). Il est donc difficile de vérifier que les bons coups sont joués au bon endroit, ou bien que le bon joueur est en train de jouer. -
Même si on réussit à analyser le contenu de la grille textuelle, lors de l’état initial du jeu, il n’est pas possible de savoir quel est le joueur courant. On ne peut donc pas vérifier facilement que le premier joueur est bien choisi aléatoirement.
Comment résoudre des problèmes de testabilité endogènes ? Il peut être difficile d’améliorer la testabilité d’un composant sans nuire à la conception et/ou à l’implémentation de ce composant. En particulier, le principe d’encapsulation est un important principe de conception qui améliorer la maintenabilité d’un composant logiciel en limitant au maximum ce que le composant expose (comme services et comme données) à l’extérieur, et par conséquent en cachant son état (ou en exposant uniquement les parties requises) au monde extérieur. Or, l’état d’un objet va à la fois contribuer aux variables d’entrées d’une méthode, et faire partie des variables de sortie. Si le composant testé suit rigoureusement le principe d’encapsulation, cela peut parfois représenter un obstacle à la fois pour la contrôlabilité et pour l’observabilité.
Une solution possible est de réduire légèrement l’encapsulation du composant en ajoutant une quantité minimale de services qui faciliteront le test :
-
Pour améliorer la contrôlabilité, il est possible d’ajouter à une classe des moyens de faciliter la création d’un état souhaité. Cela peut être accompli à l’aide de constructeurs supplémentaires qui permettent de directement mettre l’objet dans l’état souhaité, ou bien à l’aide de setters qui permettent de modifier l’état à fortiori − notons qu’un setter est une moins bonne solution qu’un constructeur car il rend possible le fait de dégrader l’état d’un objet sain existant, situation que l’on préfère rendre impossible.
-
Pour améliorer l’observabilité, il est courant d’ajouter une quantité minimale de getters sur le composant testé. Pour limiter l’impact sur l’encapsulation, ces getters doivent optimalement prendre soin de ne permettre aucun effet de bord sur le composant, que ce soit en modifiant son état, ou en donnant accès à un pointeur (ex. une collection) qui permettrait à un objet extérieur de modifier l’état.
| Nous verrons par la suite que les problèmes de testabilité endogènes ne peuvent pas être résolus à l’aide de doublures de test : la seule solution est de modifier la conception du composant. |
On considère le même jeu de morpion que celui présenté dans l’exemple précédent.
Tous les problèmes de testabilité identifiés sont causés par la conception logicielle du jeu : l'encapsulation est maximale et ne sont offertes publiquement que les deux opérations essentielles au fonctionnement du jeu.
Concernant la contrôlabilité, il serait souhaitable de pouvoir directement modifier les données encapsulées :
-
On pourrait ajouter des opérations pour exceptionnellement modifier les données privées encapsulées, par exemple
addPlayerSymbol(int x, int y, PLAYER player). Cependant cela crée le risque de créer des états de jeu en principe "impossibles", voire de casser une partie déjà lancée. -
Une meilleure possibilité serait d’ajouter de nouveaux constructeurs permettant de créer une partie qui débuterait dans un état choisi. Ainsi on ne pourrait pas "tricher" dans une partie déjà commencée, mais on pourrait débuter une partie dans l’état souhaité à des fins de test.
-
Pour ce qui est du joueur initial aléatoire, on peut soit donner la possibilité de rentre l’aléatoire déterministe, en fixant la seed du générateur d’aléatoire, soit tout simplement permettre de choisir le joueur de départ arbitrairement (dans le constructeur par exemple), à des fins de test.
Concernant l’observabilité, toutes les informations internes sont cachées au sein de membres privés, à savoir le joueur courant et l’état de la grille. Bien qu’il soit argumentable qu’une API épurée soit plus facilement maintenable ou durable dans le temps, ici cela rend l’écriture d’oracles très difficile. Pour améliorer cela, on pourrait :
-
imaginer des opérations publiques sûres permettant d’accéder au joueur courant et à la grille.
-
éventuellement stocker le dernier coup joué et rendre également ce dernier coup accessible publiquement.
2.3. Problèmes de testabilité exogènes
Dans ce cours on nomme problème de testabilité exogène un problème de testabilité au sein d’un composant qui est causé par les dépendances du dit composant. En effet, le composant testé peut soit avoir besoin de données fournies par cette dépendance (qui font alors partie des variables d’entrée), soit réaliser des actions qui affectent l’état de cette dépendance (ce qui contribue aux variables des sorties), soit les deux à la fois. Un problème se pose alors dès lors que cette dépendance est peu (ou laborieusement) contrôlable, ou bien peu (ou laborieusement) observable, car on ne peut alors pas facilement influencer son état, ni facilement accéder à ce dernier.
PaymentCardManagerDans une entreprise imaginaire chargée de gérer des paiements par carte bancaire (similaire à VISA), on souhaite tester un composant DefaultPaymentCardManager logiciel qui doit traiter un paiement effectué via une carte bancaire en allant interroger la banque rattachée à cette carte.
La spécification de la classe testée DefaultPaymentCardManager est l’interface PaymentCardManager suivante:
public interface PaymentCardManager {
/**
* Makes a payment with a given credit card.
* This operation should reach the credit card's bank
* to check that the card is able to make the payment,
* and if so charge the card with the required amount .
*
* @throws NullPointerException if cardID is null
* @throws IllegalArgumentException if cardID is empty or contains a
* character that is not a digit, or if amount is negative
* @throws PaymentException if the card is invalid, or does not have
* enough money, or has an insufficient maximum payment value, or if
* the payment failed for an unknown reason
**/
void pay(String cardID, double amount) throws
IllegalArgumentException, PaymentException;
}
Le code de la classe testée DefaultPaymentCardManager est le suivant :
public class DefaultPaymentCardManager implements PaymentCardManager {
SecureHTTPBankConnector bankConnector;
public DefaultPaymentCardManager(String bankServerHTTPAddress) {
this.bankConnector = new SecureHTTPBankConnector(
"https://api.my.securebank.com");
}
@Override
public void pay(String cardID, double amount) throws
IllegalArgumentException, PaymentException {
// Validate arguments
Objects.requireNonNull(cardID)
if (cardID.equals("") || !cardID.matches("[0-9]+")) {
throw new IllegalArgumentException("cardID is not valid");
}
if (amount < 0) {
throw new IllegalArgumentException("amount cannot be negative");
}
// Ask bank if the card is valid and able to pay
if (!this.bankConnector.isValid(cardID)) {
throw new PaymentException("Invalid card");
}
if (!this.bankConnector.hasEnoughMoney(cardID, amount)
|| !this.bankConnector.getMaxPossiblePayment(cardID)
< amount ) {
throw new PaymentException("""
Card is not able to handle
the amount""");
}
// Perform the payment, throw an exception if it failed
try {
this.bankConnector.charge(cardID, amount);
} catch (BankConnectorError e) {
throw new PaymentException("""
Something went wrong with the
payment: """ + e.getMessage());
}
}
}
On peut voir que la classe DefaultPaymentCardManager utilise la classe SecureHTTPBankConnector, cette dernière gérant les interactions concrètes avec le serveur bancaire.
On ne montre pas ici le code de SecureHTTPBankConnector (qui existe et qui fonctionne), par contre voici sa spécification au travers de l’interface BankConnector :
public interface BankConnector {
/**
* Verifies that the card exists and is not disabled or expired
* @return true if the card is valid, false otherwise
**/
boolean isValid(String cardID);
/**
* Verifies that there is enough money on the bank account of
* the card.
* @return true if the card has enough money to pay the amount,
* false otherwise
* @throws IllegalArgumentException if cardID is not valid
* (see isValid)
**/
boolean hasEnoughMoney(String cardID, double amount);
/**
* Get the maximum amount of money that the card can pay in a
* single transaction.
* @return the maximum payment value for the card
* @throws IllegalArgumentException if cardID is not valid
* (see isValid)
**/
double getMaxPossiblePayment(String cardID);
/**
* Charge a certain amount of money on a valid credit card
* @throws IllegalArgumentException if cardID is not valid
* (see isValid) or if amount is negative
* @throws BankConnectorError if the payment was not possible,
* for any reason
**/
void charge(String cardID, double amount);
}
Pour y voir plus clair, on représente le code dans un diagramme de classes UML :
Pour rappel, on souhaite tester la méthode pay implémentée dans la classe DefaultPaymentCardManager.
On commence par s’intéresser à ses entrées et ses sorties :
-
cardID∈String∪ {null} -
amount∈double -
bankConnector∈SecureHTTPBankConnector-
isValid∈boolean -
hasEnoughMoney∈boolean -
getMaxPossiblePayment∈double
-
-
bankConnector∈SecureHTTPBankConnector-
effet de la l’appel à l’opération
charge… ?
-
-
exception ∈
NullPointerException∪IllegalArgumentException∪PaymentException
On souhaite mettre en œuvre le scénario de test suivant :
Soit une carte valide avec pour numéro
4701322211111234, associée à un compte avec 5000€ en réserve, et dont le paiement maximal autorisé est de 1000€.Si on appelle
payavec cette carte pour un montant de 50€, alors le paiement se produit avec succès.
Ce qui correspond aux données d’entrée suivantes :
-
cardID:"4701322211111234" -
amount:50.0 -
bankConnector:-
isValid:true -
hasEnoughMoney:true -
getMaxPossiblePayment:1000.0
-
On réalise un diagramme de séquence pour bien comprendre ce qu’il se passerait lors d’une exécution qui suit ce scénario :
On constate différents problèmes de testabilité exogènes pour le test de la méthode pay :
-
Une partie importante des variables d’entrée proviennent de l’objet
SecureHTTPBankConnectorauquel est connectée notre instanceDefaultPaymentCardManager. Or, la seule manière d’influencer les valeurs obtenues par cette instance serait d’aller modifier la base de donnée de la banque ! Pire, les valeurs stockées par la banque pourraient être modifiées par un tiers sans qu’on le sache, ce qui irait influencer nos données d’entrée de manière indétectacle. C’est un problème majeur de contrôlabilité. -
On constate que le code actuel de
DefaultPaymentCardManagerne permet pas de contrôler quel objet on souhaite utiliser pour jouer le rôlebankConnector: la construction d’une instanceSecureHTTPBankConnectorest réalisée dans le constructeur à l’aide d’une constante. C’est un problème additionnel de contrôlabilité. -
Le domaine de sortie de
payest flou car il repose sur l’impact qu’a eu l’opérationchargelorsque cette dernière a été appelée sur l’objetSecureHTTPBankConnector. À nouveau, cet impact concerne la base de donnée de la banque à laquelle nous n’avons pas accès. C’est un problème majeur d'observabilité.
Comment résoudre un problème de testabilité exogène ? Comme le problème provient d’une dépendance, il est nécessaire de s’intéresser à cette dernière :
-
Une première piste est de modifier la dépendance problématique pour améliorer sa contrôlabilité et/ou son observabilité. Cette voie possède cependant plusieurs limites :
-
La dépendance peut être extérieure au logiciel testée (par exemple une bibliothèque tierce), et donc non modifiable.
-
La dépendance peut posséder un fonctionnement qui est intrinsèquement peu testable, et donc non améliorable. C’est le cas par exemple d’un composant logiciel qui agit au delà de l’ordinateur sur lequel il s’exécute, par exemple qui agit sur un capteur (ex. un thermomètre) ou un actionneur physique (ex. un bras robot) , ou bien qui agit sur un serveur distant (ex. un serveur mail), ou bien qui agit sur un second logiciel (ex. une base de donnée). Dans toutes ces situations, ce n’est pas tant l’état même de la dépendance qui nous intéresse, mais l’état du monde extérieur affecté par la dépendance. Modifier le composant pourra difficilement résoudre cela.
-
-
Une seconde piste est de remplacer la dépendance problématique par une dépendance alternative disponible qui ne pose pas les mêmes problèmes de testabilité. Pour cela, deux conditions doivent être satisfaites :
-
Il faut qu’une dépendance alternative soit prête/disponible, ce qui est rarement le cas.
-
Il faut que le composant testé soit suffisament contrôlable pour qu’on puisse remplacer la dépendance problématique par une alternative. Pour cela, il est courant de reposer sur une technique appelée l’injection de dépendances pour instancier les différents objets nécessaires au fonctionnement du programme.
-
Dans la suite du cours nous allons étudier une technique qui permet (entre autre) de faire face aux problèmes de testabilité exogènes : la doublure de test.
3. Doublures de test
3.1. Définition et principe
La technique de la doublure de test consiste à remplacer une dépendance dont dépend le composant testé par un composant artificiel qui "simule" un comportement suffisant pour la tâche de test à accomplique. On appelle un tel composant artificiel la doublure de la dépendance.
La mise en œuvre d’une doublure de test nécessite que le composant testé soit suffisament contrôlable pour permettre de substituer la dépendance problématique par la doublure préparée. Cette contrôlabilité peut être atteinte à l’aide du principe d’injection de dépendance.
Enfin, une doublure doit s’efforcer de respecter les propriétés suivantes :
-
Une doublure doit être aussi simple que possible. En effet, il faut éviter de "réimplémenter" complètement la dépendance problématique, auquel cas non seulement il ne sert à rien de faire une doublure, mais il y a le risque d’introduire des défauts dans la doublure, ce qui va rendre le test et le débogage difficiles.
-
Une doublure doit impérativement être fidèle au composant imité, c’est à dire produire une comportement analogue que comportement réel qu’aurait eu ce composant dans la même situation. Cette propriété peut être satisfaite en s’assurant qu’une doublure satisfait toujours la même spécification du composant imité, pour le contexte dans lequel la doublure est utilisée.
-
Une doublure doit être aussi indépendante que possible de l’implémentation (du composant testé comme de la dépendance imitée). Il faut ainsi, lorsque c’est possible, éviter de préparer une doublure dont la logique est entièrement calée sur l’usage exact qui est fait de la dépendance par l’implémentation du composant testé. Cela limite le travail de maintenance requis pour maintenir une doublure à jour au fur et à mesure de l’évolution du logiciel et de sa spécification.
DefaultPaymentCardManagerOn souhaite toujours réaliser le même scénario de test que prévu tout à l’heure pour la méthode pay de la classe DefaultPaymentCardManager, à savoir :
Soit une carte valide avec pour numéro
4701322211111234, associée à un compte avec 5000€ en réserve, et dont le paiement maximal autorisé est de 1000€.Si on appelle
payavec cette carte pour un montant de 50€, alors le paiement se produit avec succès.
Ce qui correspond aux données d’entrée suivantes :
-
cardID:"4701322211111234" -
amount:50.0 -
bankConnector:-
isValid:true -
hasEnoughMoney:true -
getMaxPossiblePayment:1000.0
-
Puisque SecureHTTPBankConnector pose problème, on choisir de réaliser une doublure de test extrêmement simple appelée BankConnectorMock1 qui nous permette de réaliser ce scénario de test :
public class BankConnectorMock1 implements BankConnector {
private double amountCharged;
@Override
public boolean isValid(String cardID){
return true;
}
@Override
public boolean hasEnoughMoney(String cardID, double amount){
return true;
}
@Override
public double getMaxPossiblePayment(String cardID){
return 1000.0;
}
@Override
public void charge(String cardID, double amount){
this.amountCharged = amount;
}
public double getAmountCharged() {
return amountCharged;
}
}
Mais comment faire pour utiliser notre doublure ?
Il est nécessaire de permettre une injection de dépendance dans DefaultPaymentCardManager, qui pour le moment construit "en dur" sa dépendance problématique DefaultPaymentCardManager, et possède une référence en dur vers cette même classe.
On choisit de modifier la réféfence pour pointer vers le type abstrait BankConnector, et de modifier le constructeur pour lui ajouter un paramètre de ce même type :
public class DefaultPaymentCardManager implements PaymentCardManager {
BankConnector bankConnector;
public DefaultPaymentCardManager(BankConnector bankConnector) {
this.bankConnector = bankConnector;
}
…
}
En UML, on obtient donc la situation suivante :
On peut ensuite écrire notre scénario de test :
@Test
public void testSuccessfulPayment() {
// Préparation du mock
BankConnectorMock bankConnectorMock = new BankConnectorMock1();
// Préparation de l'objet à tester + injection du mock
DefaultPaymentCardManager paymentCardManager =
new DefaultPaymentCardManager(bankConnectorMock);
// Exécution (qui indirectement fera appel au mock)
paymentCardManager.pay("4701322211111234", 50.0);
// Oracle: la "banque" a retiré le bon montant de la carte
assertEquals(50.0, bankConnectorMock.getAmountCharged());
}
| La doublure que nous avons préparée et utilisée ici correspond au style mock qui sera présenté dans la section suivante. |
| On peut remarquer que le terme "doublure" est ici véritablement le même que celui employé par l’industrie du cinéma. En effet, lorsqu’il est nécessaire dans un film de tourner un plan dans lequel un personnage effectue une tâche dangereuse, on fait appel à une doublure (un cascadeur par exemple) qui ne serait certes pas capable de remplacer le véritable acteur sur le reste du film, mais qui possède parfaitement les compétences nécessaires pour de tels plans dangereux. |
On peut remarquer que la technique de la doublure de test peut nous permettre de faire face aux trois situations difficiles identifiées précédemment dans le chapitre :
-
(1.b) Un problème de testabilité exogène, peut être résolu à l’aide en remplaçant la dépendance par une doublure adaptée.
-
(2) Une dépendance non disponible (ex. non implémentée) pour la tâche de test considérée, peut être résolue en remplaçant temporairement la dépendance absente par une doublure adaptée.
-
(3) La volonté de tester un composant en isolation peut être accomplie en remplaçant toutes ses dépendances par des doublures adaptées.
3.2. Styles de doublures
Il existe différents styles de doublures qui peuvent être appliqués en fonction des besoins et des situations rencontrées.
-
Le style mock (aussi appelé bouchon ou simulacre en français, ou bien stub en anglais) consiste à créer une doublure qui possède un comportement partiel et constant, et qui n’est donc capable de remplacer une dépendance que dans le cadre d’un seul (ou d’un nombre bien identifié) de scénarios de tests. Ce style permet de s’assurer que chaque doublure est individuellement très simple et permet de simuler efficacement des situations complexes. Cependant ce style implique potentiellement créer un grand nombre de doublures.
L'Exemple 4 appliquait ce style de doublure : la classe BankConnectorMock1possède un comportement partiel et constant entièrement dédié à la mise en œuvre du scénario de test considéré. -
Le style object factice (en anglais fake) consiste à créer une doublure qui satisfait intégralement la spécification de la dépendance remplacée, mais de manière simplifiée. Cela permet de ne créer qu’une seule doublure unique pour tous les scénarios de test qui impliquent la dépendance problématique, mais le coût en développement d’une telle doublure peut être important. Il est en outre possible qu’une telle doublure contienne des défauts ce qui va rendre le test difficile.
Un exemple très connu de doublure "objet factice" sont les bases de données "en mémoire" (en anglais in-memory databases) qui sont très communément utilisées en phase de développement pour ne pas nécessiter de serveur de base de données lors de tests manuels ou de tests unitaires. Le framework Spring Boot utilise par exemple H2 comme base de données "en mémoire" lorsqu’on n’exécute pas l’application en mode production. -
Le style test d’interaction consiste à créer une doublure de type "mock" capable de mémoriser tous les appels de méthode qui ont été exécutés sur elle. Ce style a pour objectif de permettre de résoudre certains types de problèmes d’observabilité où il n’est pas possible ou trop difficile de vérifier un changement d’état dans une dépendance, et où on préfère alors, à défaut, vérifier que les bonnes opérations ont été exécutées sur la dépendance. Par exemple, si on réalise une doublure de serveur d’envoi d’email, on peut vérifier que l’opération
send()a bien été appelée sur le serveur, plutôt que de demander à la doublure de stocker un état avec les messages qui lui ont été envoyés.Un exemple de doublure "test d’interaction" sera présenté avec l’introduction du framework Mockito. À utiliser avec modération : le test d’interaction a pour inconvénient majeur de rendre un oracle de test trop spécifique aux choix d’implémentation, plutôt que d’être spécifique à la spécification !
|
Il existe dans la littérature et dans la pratique un vocubulaire très diversifié − et parfois contradictoire − pour parler des différentes sortes de doublures de test. Ainsi, par exemple, certains auteurs distinguent le terme stub ("bouchon") du terme mock ("simulacre") en considérant que le premier terme ne désigne "que" les doublures qui fournissent des réponses constantes, tandis que le second désigne les doublures qui permettent le test d’interaction. Cependant, cette distinction prête rapidement à confusion car l’ensemble des outils permettant de faire des doublures s’appellent des mocking frameworks (donc en écho au terme mock), et ces outils permettent aussi bien d’appliquer le style "mock" que le style "test d’interaction". Par conséquent, dans ce cours, on choisit de considérer que les termes stub ("bouchon") et mock ("simulacre") sont synonymes, et que ce qui varie c’est simplement le style appliqué. |
3.3. Implémenter des doublures de style mock
Bien qu’une doublure mock (aussi appelée simplement mock) ne soit pas un morceau de logiciel très complexe, une suite de tests peut en nécessiter un grand nombre. Dans ce cas, implémenter des mocks peut rapidement devenir coûteux en temps, et peut introduire de nombreux fichiers dans le répertoire contenant les tests (dans le cas de Java, une classe non-interne donne un fichier). Pire, si une erreur se glisse dans un mock, cela peut compromettre de nombreux résultats de test, et rendre difficile la localisation d’erreur (puisque cette dernière ne se situe pas dans le logiciel testé).
Pour remédier à cela, nous allons voir deux approches :
-
L’utilisation de classes anonymes afin d’alléger le code sans nécessiter d’outil ou de dépendance supplémentaire.
-
L’utilisation de frameworks dédiés aux mocks, afin de créer des mocks sans avoir à explicitement préparer de nouvelles classes (anonymes ou non). Ces framework vont en outre nous permettre d’appliquer le style "test d’interaction".
3.3.1. Classes anonymes
Certains langages de programmation orientés objet permettent de créer une classe "à la volée" au moment d’instancier un objet. La classe créée est alors unique et propre à cet objet, et n’a pas d’identifiant, d’où le terme classe anonyme.
Comme un mock est susceptible d’être unique à un cas de test, il apparait vite fastidieux de créer une classe Java normale qui ne sera utilisée qu’une seule fois. À la place, on peut définir une classe anonyme directement dans le scénario de test, ce qui a l’avantage de garder toutes les données de test (ce qui inclut le mock) au même endroit, à savoir le script de test.
En Java, il suffit d’utiliser le mot-clé new suivi du nom d’une interface ou d’une classe abstraite, et de mettre entre accolades le contenu de la classe.
DefaultPaymentCardManagerOn réécrit notre scénario de test pour DefaultPaymentCardManager utilisant à nouveau un mock pour BankConnector, cette fois avec une classe anonyme Java :
@Test
public void testSuccessfulPayment_v2() {
// Préparation du mock via une classe anonyme
BankConnector bankConnectorMock = new BankConnector() {
private double amountCharged;
@Override
public boolean isValid(String cardID){
return true;
}
@Override
public boolean hasEnoughMoney(String cardID, double amount){
return true;
}
@Override
public double getMaxPossiblePayment(String cardID){
return 1000.0;
}
@Override
public void charge(String cardID, double amount){
this.amountCharged = amount;
}
public double getAmountCharged() {
return amountCharged;
}
};
// Préparation de l'objet à tester + injection du mock
DefaultPaymentCardManager paymentCardManager =
new DefaultPaymentCardManager(bankConnectorMock);
// Exécution (qui indirectement fera appel au mock)
paymentCardManager.pay("4701322211111234", 50.0);
// Oracle: la "banque" a retiré le bon montant de la carte
assertEquals(50.0, bankConnectorMock.getAmountCharged());
}
Cette approche permet d’alléger le code dans les cas les plus simples. Malheureusement, dès que l’interface du mock possède un grand nombre d’opérations, alors la déclaration de le classe anonyme devient alors tout de même assez lourde. En effet, toutes les opérations − même celles non nécessaires pour le scénario de test considéré − doivent y être implémentées.
3.3.2. Frameworks de mocks
Heureusement, il existe un grand nombre de frameworks pour faciliter la création de mocks au sein de scripts de test. Un framework de mocks fournit un ensemble de services pour facilement créer des mocks de différentes sortes.
Habituellement, un framework de mocks typique fournira les services suivants (un ensemble d’opérations similaires existe pour la créations d’espions) :
-
Une méthode pour créer un mock à partir d’un type passé en paramètre (interface, une classe abstraite ou une classe normale). Le mock obtenu est un objet valide et bien typé par le type donné en paramètre. En revanche, une fois créé, le mock n’a pas encore de comportement, et tout appel de méthode effectué sur lui sera susceptible de donner une erreur.
-
Un ensemble de méthodes pour configurer le comportement du mock, autrement dit lui indiquer quelles opérations vont être appelées sur lui, et quels résultats il est alors censé produire.
-
Un ensemble de méthodes pour vérifier que le comportement du mock était bien celui attendu. Par exemple, vérifier que toutes les méthodes attendues ont été appelées et/ou vérifier qu’elles l’ont été dans l’ordre.
Ainsi, à l’aide de ces méthodes, on procède toujours de la manière suivante au sein d’un scénario de test nécessitant des mocks :
-
Création des mocks nécessaires.
-
Description du comportement attendu par chacun des mocks.
-
Utilisation des mocks :
-
Si besoin, on fournit d’abord le mock à l’objet à tester, via son constructeur ou via un setter.
-
On appelle la méthode à tester, si besoin avec le mock en paramètre.
-
-
Si besoin, vérification que les interactions avec les mocks sont bien celles qui étaient attendues.
Dans le monde Java, on trouve de nombreux frameworks de mocks comme Mockito, JMockit ou encore EasyMock. Dans ce cours, on se concentrera sur Mockito.
| Ces frameworks de mocks sont indépendants des frameworks de test (comme JUnit), même si souvent ils nécessitent des connecteurs particulier pour bien s’y intègrer. |
3.4. Précaution dans l’usage de doublures
Avant de poursuivre, et après avoir souligné l’efficacité et la versatilité des doublures pour résoudre des problèmes de test, il important de garder à l’esprit deux principes de précaution concernant l’usage de doublures :
-
Lorsque c’est possible, il est toujours préférable de tester avec une dépendance réelle plutôt que de tester avec une doublure, car cela permet de rester aussi proche que possible du vrai comportement du système final. Le choix d’utiliser une doublure dans un contexte donné doit donc être réfléchi, et doit rester autant que possible un "dernier recours" pour faire face à des situations de test difficiles.
-
Si on fait le choix (réfléchi) d’utiliser des doublures de test, alors il est très important de prendre soin de ces doublures pour qu’elles simulent un comportement fidèle à la dépendance qu’elle remplace, et pour que cette fidélité soit préservé au fil des évolution du logiciel et de sa spécification. Cela accroit donc forcément le cout de maintenance de la suite de test développée.
4. Mockito : un framework de mocks
4.1. Présentation
Le framework de mocks Mockito, aujourd’hui dans sa version 3, permet de facilement définir des mocks au sein de scénarios de test écrits en Java.
Même si ce framework fournit un grand nombre de fonctionnalités, nous allons nous concentrer sur ses opérations élémentaires pour créer, configurer et vérifier des mocks. Pour voir toutes les fonctionnalités, se référer à la documentation officielle.
4.2. Ajouter une dépendance à Mockito
Comme pour JUnit, On supposera dans ce cours que la gestion de dépendance du projet est faite à l’aide de Maven, mais on notera encore une fois que cette étape sera similaire pour un projet Gradle ou encore un pur projet Eclipse JDT.
On ajoutera simplement dans le pom.xml la dépendance suivante :
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
On peut dès lors importer les classes Mockito au sein d’une classe de test JUnit à l’aide de la ligne :
import static org.mockito.Mockito.*;
On supposera que cet import a bien été fait pour la suite du chapitre.
4.3. Créer un mock
Pour créer un mock on utilise la méthode statique org.mockito.Mockito.mock(java.lang.Class classToMock), par exemple :
MonInterface monMock = mock(MonInterface.class);
À ce stade il est déjà possible d’appeler des méthodes sur le mock créé, car Mockito fournit des implémentations par défaut pour toutes les opérations de l’interface considérée.
Ainsi, par défaut une méthode produira toujours 0 avec int comme type de retour, false avec boolean, une collection vide avec un itérable, ou null avec une classe.
Par exemple, si MonInterface possède une opération int foo(String) :
System.out.println(monMock.foo("nothing")); // affiche "0"
4.4. Configurer un renvoi de valeur par un mock
Pour configurer un renvoi de valeur par un mock, on utilise la méthode when pour indiquer les appels de méthode attendus, puis on utilise la méthode thenReturn pour indiquer quelle valeur doit être rendue par le mock en cas d’appels.
Par exemple pour toujours rendre 42 quand la méthode foo sera appelée avec le paramètre "hello" sur le mock :
when(monMock.foo("hello")).thenReturn(42);
Exemple d’appel :
System.out.println(monMock.foo("hello")); // affiche "42"
La méthode when est assez singulière car elle prend en paramètre l’appel de méthode que l’on souhaite recevoir.
Mockito fait usage de véritable "magie noire" Java pour permettre cette syntaxe.
Tout repose sur le fait que le mock est considéré comme "en construction" durant l’appel à when, et donc tous les appels qui sont réalisés sont interceptés et enregistrés pour configurer le mock.
|
4.5. Configurer un envoi d’exception par un mock
Pour configurer un envoi d’exception par un mock, on utilise la méthode thenThrow.
Par exemple pour toujours produire une exception quand foo sera appelée sur le mock avec le paramètre "error" :
when(monMock.foo("error")).thenThrow(new RuntimeException());
Exemple d’appel :
monMock.foo("error"); // une RuntimeException a lieu
Attention, si la méthode appelée sur le mock est de type void (donc sans valeur de retour), la syntaxe pour configurer un envoi d’exception est assez différente :
doThrow(new RuntimeException()).when(monMock).maMethodeVoid();
Exemple d’appel :
monMock.maMethodeVoid(); // une RuntimeException a lieu
4.6. Définir des arguments attendus sous forme de conditions
On a vu jusqu’ici comment configurer un mock pour qu’il réponde à un appel de méthode bien précis, avec des arguments bien précis. Par exemple, lorsqu’on écrit ceci :
when(monMock.foo("hello")).thenReturn(42);
alors mon objet monMock ne répondra 42 uniquement si on appelle sur lui la méthode foo avec très exactement l’argument "hello".
Cependant, il peut être préférable de configurer un mock pour qu’il accepte tout argument qui satisfait une condition plus large, pour un appel de méthode donné. Cela peut être accompli par des opérations Mockito appelées des arguments matchers, dont voici quelques exemples :
-
any()accepte n’importe quelle valeur (y comprisnull), -
anyBoolean()accepte n’importe quel booléen, -
anyInt()accepte n’importe quel entier, -
anyString()accepte n’importe quelle chaîne de caractères, -
anyCollection()accepte n’importe quelle collection de valeurs (listes, etc.), -
eq(value)accepte uniquement une valeur égale àvalue.
| Pour voir tous les arguments matchers disponibles, se référer à la page correspondante de la documentation de Mockito. |
Par exemple pour rendre 3600 quel que soit la chaîne de caractère donnée en argument à foo, on peut configurer le mock ainsi :
when(monMock.foo(anyString())).thenReturn(3600);
Exemples d’appels :
System.out.println(monMock.foo("random")); // affiche "3600"
System.out.println(monMock.foo("random again")); // affiche aussi "3600"
Attention, il est cependant nécessaire de respecter la contrainte suivante : si la méthode du mock requiert plusieurs paramètres, Mockito interdit de mélanger des expressions normales et des arguments matchers. Dit autrement, la configuration d’un appel de méthode sur un mock doit exclusivement contenir :
-
soit tous les arguments de l’appel sont des expressions Java classiques,
-
soit tous les arguments de l’appel sont des arguments matchers.
Par conséquent, il est interdit en Mockito d’écrire la configuration de mock suivante :
when(monMock.methodWithMultipleParameters("first value", anyInt() ))
.thenReturn(-100); // produit une erreur à l'exécution
car ici "a first value" est une expression Java classique, tandis que anyInt() est un argument matcher.
À la place il est obligatoire ici d’utiliser l'argument matcher eq() ainsi :
when(monMock.methodWithMultipleParameters(eq("first value"), anyInt() ))
.thenReturn(-100);
Cette fois-ci, eq("a first value") est un argument matcher, et anyInt() est également un argument matcher, la contrainte est donc respectée.
Exemples d’appels :
System.out.println(monMock.methodWithMultipleParameters("first value", 5))); // affiche "-100"
System.out.println(monMock.methodWithMultipleParameters("first value", 12)); // affiche aussi "-100"
4.7. Interroger des mocks
Mockito fournit une méthode d’assertion appelée verify qui permet d'interroger un mock afin de vérifier quels appels ont été réalisés sur le mock.
La méthode verify prend en paramètre le mock à interroger, puis on appelle successivement toutes les méthodes qui devraient avoir été appelées, avec les paramètres attendus.
Par exemple pour vérifier que foo a bien été appelée avec le paramètre "hello" sur le mock :
verify(monMock).foo("hello");
On peut également utiliser dans verify différentes méthodes (times(int), atLeastOnce(), atLeast(int), atMost(int), never()) pour indiquer le nombre d’appels attendus pour méthode.
Par exemple pour vérifier que foo aura été appelé au moins deux fois :
verify(monMock, atLeast(2)).foo("hello");
Puis, si on veut vérifier dans quel ordre les appels de méthode ont eu lieu sur un mock, on peut utiliser la classe InOrder.
Par exemple, pour vérifier que foo est bien appelé deux fois avec deux paramètres différents, d’abord "hello" puis "goodbye" :
InOrder inOrder = inOrder(monMock);
inOrder.verify(monMock).foo("hello");
inOrder.verify(monMock).foo("goodbye");
Enfin, si on veut vérifier que qu’un mock n’a jamais été utilisé (aucun appel de méthode) on peut utiliser verifyZeroInteractions, et si on veut vérifier qu’un mock n’est plus utilisé après un certain point on peut utiliser verifyNoMoreIntercations.
Par exemple pour vérifier que monMock n’a jamais été utilisé :
verifyZeroInteractions(monMock); // si on a utilisé le mock par le passé, un échec est levé
Autre exemple pour vérifier que monMock n’est plus utilisé après son premier appel:
monMock.foo("hello"); // on utilise le mock sans problème
verifyNoMoreIntercations(monMock); // si mock est utilisé par la suite, un échec sera levé
DefaultPaymentCardManagerOn implémente à nouveau le même scénario de test pour DefaultPaymentCardManager, cette fois-ci avec Mockito.
@Test
public void testSuccessfulPayment_v3() {
// Préparation des données d'entrée
String correctCardID = "4701322211111234";
double amount = 50.0;
// Préparation du mock
BankConnector bankConnectorMock = mock(BankConnector.class);
when(bankConnectorMock.isValid(correctCardID)).thenReturn(true);
when(bankConnectorMock.hasEnoughMoney(correctCardID, amount)).thenReturn(true);
when(bankConnectorMock.getMaxPossiblePayment(correctCardID)).thenReturn(1000.0);
// Préparation de l'objet à tester + injection du mock
DefaultPaymentCardManager paymentCardManager =
new DefaultPaymentCardManager(bankConnectorMock);
// Exécution (qui indirectement fera appel au mock)
paymentCardManager.pay(correctCardID, amount);
// Oracle: l'opération charge a bien été correctement appelée
verify(bankConnectorMock).charge(correctCardID, amount)
}
On peut observer un changement par rapport aux versions antérieures : cette fois-ci notre oracle s’appuie sur le style "test d’interaction" évoqué plus tôt, c’est à dire que nous vérifions non pas l’état atteint par le mock, mais nous vérifions si la méthode charge a bien été appelée sur le mock avec les bons arguments.
Même si ce style rend le script de test très compact et l’effort de développement du mock très réduit (pas besoin d’implémenter un stockage d’état), un inconvénient majeur est que notre test est désormais dépendant de l’implémentation de la méthode testée pay.
En effet, le fait que l’opération charge est appelée est d’une certaine manière un choix d’implémentation réalisé dans la classe DefaultPaymentCardManager ; or le but d’un test n’est pas de s’assurer qu’un tel choix a été fait, mais juste de vérifier que le choix (quel qu’il soit) satisfait correctement la spécification.
|
DefaultPaymentCardManagerOn s’intéresse au second scénario de test suivant, cette fois-ci avec un cas d’erreur :
Soit une carte invalide avec pour numéro
12341234.Si on appelle
payavec cette carte pour un montant de 10€, alors une exceptionPaymentExceptionest obtenue.
Un ensemble de données d’entrée correspondantes peut être :
-
cardID:"12341234" -
amount:10.0 -
bankConnector:-
isValid:false -
hasEnoughMoney:false -
getMaxPossiblePayment:0
-
On implémente ce scénario avec un script de test qui s’appuie là encore sur Mockito :
@Test
public void testChargeError() {
// Préparation des données d'entrée
String invalidCardID = "12341234";
double amount = 10.0;
// Préparation du mock
BankConnector bankConnectorMock = mock(BankConnector.class);
when(bankConnectorMock.isValid(invalidCardID)).thenReturn(false);
when(bankConnectorMock.hasEnoughMoney(invalidCardID, amount)).thenReturn(false);
when(bankConnectorMock.getMaxPossiblePayment(invalidCardID)).thenReturn(0.0);
// Préparation de l'objet à tester + injection du mock
DefaultPaymentCardManager paymentCardManager =
new DefaultPaymentCardManager(bankConnectorMock);
// Oracle: une exception est envoyée
assertThrows(PaymentException.class, () -> {
// Exécution (qui indirectement fera appel au mock)
paymentCardManager.pay(invalidCardID, amount);
});
}
On peut voir qu’ici nous n’utilisons pas verify car la seule donnée de sortie qui nous intéresse est l’envoi d’une exception par l’opération pay. Ainsi seul un assertThrows est nécessaire, sans test d’interaction sur le mock.
DefaultPaymentCardManagerOn s’intéresse au troisième scénario de test suivant, qui illustre une autre sorte de cas d’erreur :
Soit une carte valide avec pour numéro
4701322211111234, associée à un compte avec 5000€ en réserve, et dont le paiement maximal autorisé est de 1000€. Le prochain paiement réalisé avec cette carte échoue (BankConnectorError) au moment de prélever l’argent sur le compte.Si on appelle
payavec cette carte pour un montant de 50€, alors une exceptionPaymentExceptionest émise.
Cette fois-ci, on souhaite vérifier que si la dépendance rencontre un cas d’erreur et envoie une exception BankConnectorError lors de l’exécution de charge, alors cette erreur sera correctement traitée par la méthode pay qui réagira comme sa spécification le prévoit, c’est à dire en envoyant une PaymentException.
Un ensemble de données d’entrée correspondantes peut être :
-
cardID:"4701322211111234" -
amount:50.0 -
bankConnector:-
isValid:true -
hasEnoughMoney:true -
getMaxPossiblePayment:5000.0 -
charge: envoi d’uneBankConnectorError
-
On implémente le script de test suivant avec l’aide de Mockito :
@Test
public void testChargeError() {
// Préparation des données d'entrée
String correctCardID = "4701322211111234";
double amount = 50.0;
// Préparation du mock
BankConnector bankConnectorMock = mock(BankConnector.class);
when(bankConnectorMock.isValid(correctCardID)).thenReturn(true);
when(bankConnectorMock.hasEnoughMoney(correctCardID, amount)).thenReturn(true);
when(bankConnectorMock.getMaxPossiblePayment(correctCardID)).thenReturn(1000.0);
doThrow(new BankConnectorError()).when(bankConnectorMock).charge(correctCardID, amount);
// Préparation de l'objet à tester + injection du mock
DefaultPaymentCardManager paymentCardManager =
new DefaultPaymentCardManager(bankConnectorMock);
// Oracle: une exception est envoyée
assertThrows(PaymentException.class, () -> {
// Exécution (qui indirectement fera appel au mock)
paymentCardManager.pay(invalidCardID, amount);
});
}
On peut voir que la préparation de bankConnectorMock a nécessité l’usage de l’opération doThrow qui permet de demander à un mock d’envoyer une exception lorsqu’une méthode de type void est appelée (ici charge).
Ainsi, du point de vue du code testé, une exception BankConnectorError va surgir de la la dépendance, et on vérifie avec assertThrows que pay réagit correctement avec PaymentException.
4.8. Limites de Mockito
On notera qu’il n’est pas possible de faire de doublures ou des configurations de :
-
Classes
final(pas très grave puisqu’on voudra normalement surtout créer des doublures à partir d'interfaces), -
Méthodes
finaloustatic(ce qui n’arrive que lorsqu’on souhaite créer une doublure à partir d’une classe, donc même commentaire que le précédent), -
Méthodes
equals()ethashCode(), utilisées dans la machinerie interne de Mockito.
5. Références
-
Paul Ammann, and Jeff Offutt. Introduction to software testing, Chapitre 12. Cambridge University Press, 2008.
-
Winters, Titus, Tom Manshreck, and Hyrum Wright. Software engineering at Google: Lessons learned from programming over time, Chapitre 18. O’Reilly Media, 2020. https://abseil.io/resources/swe-book
-
Martin Fowler, Mocks Aren’t Stubs, 2007, https://martinfowler.com/bliki/TestDouble.html