Ma démarche de test
V1.0
© Laurent Carbonnaux, 12 Mai 2010
Sommaire
Préambule
Les TU c’est bien, le TDD c’est mieux, TDR c’est très bien
TU sans TDD sans TDR c’est mieux que rien.
Sans test d’acceptance côté interface utilisateur (IHM, ligne de commande, …), c’est déjà bien.
Bon, une fois qu’on a dit ça, on fait un joli plan de test qui sera relu par les ingénieurs qualité et tout ça, et tout ça.
Mais le développeur, lui, il est un peu embêté.
D’abord, je suis à peu prés sûr qu’il ne lira pas le plan de test; Si, si, ça arrive, et en plus il s’en fou !
Le développeur, il sait ce que c’est que d’écrire un test, enfin, il sait, il sait ce qu’il doit coder pour écrire un test. Mais sait-il ce qu’il faut tester ?
Il lui faut de l’aide, une bonne stratégie de test.
NB : Je n’ai pas la prétention de répondre à tout, encore moins d’avoir la science infuse, mais quelques expériences à partager. Je préfère donc parler de démarche plutôt que de stratégie.
Remerciements : Merci à Julien Patou (People In Action) qui a jouer le rôle de Product Owner et David Brocard celui de critique constructive, tous deux membres de l’association SigmaT.
Ma démarche de test
Stratégie de test : Vaste sujet, qui va des tests unitaires à la recette du produit.
C’est souvent comme cela qu’on l’aborde dans les formations ou les séminaires : par petit bout et dans ce sens.
J’ai donc voulu ici faire le chemin inverse et revenir à l’origine : le besoin du client.
Certes, l’approche cycle en V répondait (tiens, pourquoi je mets ça au passé ?) d’une certaine façon à cela, mais ne fournissait pas de réelle réponse, ni de garantie de cohérence en traversant les différentes couches d’intervenants : la branche de gauche qui décompose le besoin du client jusqu’au code (voir mon billet « Exigences, validation et traçabilité »)
Une stratégie de test, C’est quoi :
- Définir clairement ce qui doit ou pas être testé et comment, quand, par qui.
- L’éternelle roue de Deming : Plan-Do-Check-Act que l’on utilise comme une fractale.
- Au delà des métriques. Ça n’a aucun sens de demander une couverture à 70%. Que fait-on des 30% restant, à quoi correspondent-ils, comment choisir ce qui est dans les 30 ou dans les 70 ?
- Autre approche, on teste tous ce qui est de type DAO, classes de service, classes métiers, classes outils. Cette approche est déjà plus efficace dans l’aide au choix.
- C’est aussi trouver le bon niveau de test entre coût de développement et retour sur investissement
- Faire un test est couteux, mais il faut le ramener au nombre de fois où l’on pourra le jouer. Avec une contrainte de temps et de budget comme imposé par les contrats au forfait, il n’est inimaginable de TOUT tester. Certaines exigences seront plus couteuses à tester que d’attendre le bug et de le corriger (là, ça va pas plaire, mais c’est la dur réalité des projets), pour la simple et bonne raison, qu’il sera traité dans un autre budget (garantie, tma) et un autre temps.
- Là encore, il va donc falloir aider l’équipe à choisir.
M’inspirant des travaux de Jean-Michel Inglebert qui apprend les méthodes de tests unitaires (TDD sans le dire) à ces élèves, j’ai voulu par un exemple très simple montrer ce qui peut/doit se faire. (sa prez à l’Agile Tour 2009).
Jean-Michel Inglebert explique les tests unitaires de la façon suivante :
« Les plus simples et pourtant il s’agit …
- d’oublier les approches manuelles
- d’expliciter les limites
- de traiter le qualitatif et le quantitatif
et Ce qu’il faut retenir …
- C’est irréalisable par un humain
- Les limites sont aussi importantes que les cas standards pour l’utilisateur de la librairie
- Le retour sur investissement est important : l’information fournie sur la fonction versus le volume du code de test
«
Au final, les tests unitaires servent à :
- Garantir le besoin au niveau « mécanique » : qualitatif et quantitatif
- Fournir un moyen de contrôle de stabilité : tests de non régression
- Définir les limites d’utilisation : mieux que de la spec détaillée
A mon sens, la seule vraie façon de garantir la maintenance du système, bien au delà de n’importe quelle documentation.
Reprenons
A partir de cela, il nous est :
- impossible de tout tester
- il y aura donc des bugs
Deuxième objectif induit par cette contrainte, ne pas perdre de temps à trouver la source du bug.
Coupler deux stratégies, détaillées plus loin :
- tests en couches
- tests automatiques sur résultat de référence
En cas de bug, cette double stratégie permet de :
- être alerté d’un changement de comportement, ce n’est pas encore un bug. Il faut vérifier alors si c’est l’application qui n’est pas cohérente ou le test (ou le résultat de référence) qui n’a pas évolué.
- de savoir quelle couche l’a découvert. Par exemple, si le bug est découvert sur les tests d’IHM, mais pas par les tests unitaires, normalement le bug est au niveau de l’interaction entre le code de gestion et l’interface utilisateur (MVC)
Tests en couches
- Les tests unitaires s’occupent de vérifier le code.
- Les tests IHM, testent l’interaction utilisateur avec ce code.
- Les tests fonctionnels ou d’acceptance testent la conformité aux besoins (exigences fonctionnelles) du client, au cahier des charges.
- Les tests d’intégration n’interviennent que s’il on a de l’intégration entre sous systèmes. Pas entre composants, c’est du niveau tests unitaires.
L’objectif est que chaque couche de tests s’appuie sur celle du dessous et ne cherche pas à re-tester la même chose, mais compléter la cohérence global. Comme je l’expliquais dans un précédent billet.
Je ne parle pas ici de dichotomie tests/fonctionnels / tests unitaires, résultant d’une décomposition des tests, mais bien de la notion de complémentarité.
Il faut bien prendre en compte l’objectif et les limitations des outils utilisés.
Par exemple, l’interaction homme/machine ne sera pas testé avec du JUnit, et un algorithme mathématique avec du Sélénium. Par contre, garantir que les paramètres saisis par l’utilisateur sont bien transmis à l’algorithme interne par du Sélénium avec un jeu de données simple et tester plus largement l’algorithme avec JUnit en faisant varier les paramètres entre leurs bornes, voir même à l’extérieur pour tester la robustesse.
Les tests d’intégration sont un peu à part : Traversant les couches décrites ci-dessus, mais entre sous systèmes, il leur faudra aussi vérifier
- la mécanique : des tests unitaires pour la couche technique d’échange, objectif: Contrôler le modèle échangé
- l’interface métier : vérifier qu’au-delà du modèle d’échange, les deux sous systèmes l’interprètent de la même manière
- et enfin le fonctionnel, c’est l’aspect utilisateur du système complet, qui ne s’intéresse qu’à la partie métier pure, ce que l’utilisateur va faire avec son système….
Tests automatiques sur résultats de référence
Il est souvent difficile d’obtenir des données réelles de la part de nos clients. Soit elles sont confidentielles, soit pas encore existantes, mais simplement en partie spécifiées.
Le but du jeu est de définir un jeu de données (entrée/sortie) dit de référence et d’automatiser l’exécution du test qui nous alertera si, avec les mêmes entrées, les sorties sont différentes de la référence.
Ces données de références peuvent être validées en parallèle avec les experts concernés.
En attendant, je ne perds pas de temps !
Cette pratique est très utile dans le cas d’appel de composant « boite noire » (type moteurs de calcul) fournis par le client.
Où l’on a du mal à faire la différence entre tester le code d’appel du composant et le composant lui-même. Du moins est-il difficile en cas d’erreur de savoir si elle provient du code appelant ou du composant. La frontière est délicate.
Dans ce cas, j’utilise les tests par référence.
Je joue le test une fois avec des données d’entrée, je récupère les données de sortie et ces jeux de données deviennent la référence. Le code appelant est testé unitairement, et la référence sera contrôlée et validée ensuite.
En exécution automatique, je serai alerté en cas de résultat différent de la référence.
Est-ce le composant qui a changé ? Le code d’appel ? Le code traitant les résultats ? Je ne sais pas directement, il faut investiguer un peu plus, mais au moins je suis alerté du changement de comportement de l’application autour de cette fonction.
Le Jeu
J’ai demandé aux membres de l’association SigmaT de me définir une ou deux User Stories. Je suis resté assez vague sur l’objectif, j’ai juste précisé que c’était pour faire un topo sur les tests.
Je m’y attendais, les premières réponses étaient des questions, ou des propositions techniques !
Premier constat : Nous, informaticiens, ne sommes pas faits pour définir les besoins, mais pour y répondre, trouver des solutions.
J’ai reçu plusieurs propositions de user stories, j’ai choisi la suivante qui correspondait le mieux à mon besoin de simplicité. Je croyais !
Premier essai :
ID : | US1 |
Nom : | Créer un nouvel utilisateur |
Description : | En tant que : * ADMINISTRATEUR (utilisateur ayant des droits d'administration) Je peux : * créer un utilisateur Afin de : * insérer un utilisateur dans le référentiel de données |
Critères d’acceptance : | * Vérifier que seul un utilisateur ayant le droit d'administration à accès à l'écran de création d'un utilisateur * Vérifier que les champs de saisie "login" et "mot de passe" sont obligatoires (le bouton "Créer" n'est actif que si les champs ne sont pas vides) * Vérifier que les champs de saisie "login" et "mot de passe" respectent le format spécifié (le bouton "Créer" n'est actif que si les champs "login" et "mot de passe" contiennent respectivement au moins 4 et 6 caractères) * Vérifier que si un utilisateur avec le login saisi existe déjà en base un message d'avertissement s'affiche : "Un utilisateur avec cet identifiant existe déjà" * Vérifier qu'après l'action de création, un message d'information s'affiche : "Utilisateur créé avec succès" * Vérifier qu'après l'action de création, l'écran de création d'un utilisateur est vierge * Vérifier qu'après l'action de création, un enregistrement est présent dans la table USER avec les attributs saisis et la date du jour dans le champ CREATION_DATE |
A partir de là, oups ! Premier problème, je fais ça en quoi ?
Problème de technicien, pas le sujet du jour, au plus simple. D’autant que je cherche à préciser une stratégie, pas forcément les pratiques elles-mêmes.
Est-ce qu’un TDD en swing est si différent qu’en web ou .Net, dans la technique oui, les outils oui, mais le principe non.
Deuxièmement, je commence par quoi !
Tout simplement, mais c’est très bête, lire le sujet et préparer les moyens de tests.
Analyse du besoin fonctionnel pour définir les besoins de tests :
- pouvoir identifier l’utilisateur de l’appli. Il manque une user story précisant qu’il faut se signer sur l’appli pour tester le premier critère, donc on teste mais on bouchonne. Mais en même temps la user story ne précise pas comment l’utilisateur fait pour accéder à l’écran de création, un bouton, un lien, un url ??? Dans ce cas, il faut demander précision au product owner, normalement lors du workshop ou pendant le sprint, dans ce cas, on ajoute une exigence à la story.
- Réponse :
Compléments | - un bouton (ou lien) "Nouvel utilisateur" inactif (ou invisible, encore mieux) pour ceux qui n'ont pas les droits. On n'informe pas la personne qu'elle n'a pas les droits, ça peux la vexer :-) . |
- Il faut un écran de base d’où sera exécuté l’action et un écran de création d’un utilisateur.
- Une base de données avec une table USER avec champs login, motdepasse et creation_date. (Ce n’est pas un besoin fonctionnel, mais le client le demande, alors !)
Analyse du besoin fonctionnel pour définir les scénarios de tests : Normalement en TDR
- test si utilisateur est admin alors écran création utilisateur dispo (bouton « Nouvel utilisateur» actif)
- test si utilisateur pas admin alors bouton « nouvel utilisateur » invisible
1er partie, décrire les scénarios de tests d’acceptance.
Pour cela j’ai utilisé RobotFramework. Pour deux raisons :
- C’est un outil de test dit « keyword driven ». Hormis une phase d’apprentissage de l’outil, il ne nécessite aucune compétence de développement.
- Il permet de tester l’interaction avec le sous système. Les IHMs : de type web couplé avec Sélenium, ou de type java swing. Les process simples, ou telnet, ssh…
- Et de 3, parce que j’en ai eu le besoin, on peut facilement ajouter des keywords de base, cela nécessite un peu de développement.
Deux scénarios : Lancer l’appli en deux modes. 1 mode admin, 1 mode non admin, 1er critère oblige.
Scénario en mode non admin.
N’ayant pas encore de « modèle » pour gérer les droits de l’utilisateur, ni de gestion de la connexion utilisateur, j’ai simplement utilisé une méthode « isUSerAdmin() » qui retourne un booléen. Cette méthode servant de bouchon pourra être facilement remplacé lorsqu’on s’attaquera à la bonne user story. En attendant, je lance l’appli en mode avec paramètre pour simuler le mode admin, sans paramètre en mode non admin.
Scénario :
- Lancer l’application en mode non admin
- Sélection de la fenêtre principale
- Vérifier que le bouton « Nouvel Utilisateur » n’est pas visible (c’est ce keyword que j’ai du ajouter)
Ça donne dans RobotFramework :
Test Mode Non Admin
Test Case | Action | Arguments | ||
[Setup] | Lancer appli en mode non Admin | |||
Button Should Not Be Visible | ${boutonNewUser} | |||
Keyword | Action | Arguments | ||
Start Application | org.lcx.myapp.MySampleApp | |||
Select Window | ${AppName} | |||
Scénario en mode admin.
Scénario :
- Lancer Appli En Mode Admin
- Lancer l’application en mode admin
- Sélection la fenêtre principale
- Ouvrir Ecran Creation Utilisateur
- Vérifier que le bouton « Nouvel Utilisateur » est actif
- Cliquer sur le bouton « Nouvel Utilisateur »
- Vérifier que l’écran est ouvert
- Les Champs Sont Obligatoires
- Vérifier que le bouton créer est inactif
- Saisir 4 caractères dans le login et 6 dans le password
- Vérifier que le bouton créer est actif
- Vider les champs login et password
- Vérifier que le bouton créer est inactif
- Les Champs Sont Au Bon Format
- Vérifier que le bouton créer est inactif
- Saisir 3 caractères dans le login et 5 dans le password
- Vérifier que le bouton créer est toujours inactif
- Saisir 4 caractères dans le login et 6 dans le password
- Vérifier que le bouton créer est actif
- Ce test n’est pas suffisant, il faudra un test unitaire pour compléter le contrôle de la règle de gestion
- Créer Un Utilisateur
- Saisir 4 caractères dans le login et 6 dans le password
- Cliquer sur le bouton créer
- Vérifier l’affichage du message de création
- Fermer le message
- Vérifier que les champs login et password sont vides.
- Vérifier que les données existent en base dans la table User. (je n’ai volontairement pas testé la date)
- Créer Un Utilisateur en double
- Re-saisir le même login et password
- Cliquer sur le bouton créer
- Vérifier le message d’erreur
- Fermer le message
- Supprimer les données de la table User
Test Fonctionel Mode Admin
Setting | Value | |||
Suite Teardown | Delete table user |
Test Case | Action | Arguments | ||
[Setup] | Lancer appli en mode Admin | |||
Button Should Be Enabled | ${boutonNewUser} | |||
Ouvrir ecran creation utilisateur | ||||
Les champs sont obligatoires | ||||
Les champs sont au bon format | ||||
Creer un utilisateur | ||||
Creer Un Utilisateur en double | ||||
Keyword | Action | Arguments | ||
Start Application | org.lcx.myapp.MySampleApp | -admin | ||
Select Window | ${AppName} | |||
Push Button | ${boutonNewUser} | |||
Select Context | ${newUserPanel} | |||
${buttonTest} = | Get Button Text | ${boutonCreer} | ||
Should Be Equal | Create | ${buttonTest} | ||
Clear Text Field | ${login} | |||
Clear Text Field | ${password} | |||
Button Should Be Disabled | ${boutonCreer} | |||
Insert Into Text Field | ${login} | toto | ||
Insert Into Text Field | ${password} | tatata | ||
Button Should Be Enabled | ${boutonCreer} | |||
Clear Text Field | ${login} | |||
Clear Text Field | ${password} | |||
Button Should Be Disabled | ${boutonCreer} | |||
Clear Text Field | ${login} | |||
Clear Text Field | ${password} | |||
Insert Into Text Field | ${login} | 123 | ||
Button Should Be Disabled | ${boutonCreer} | |||
Insert Into Text Field | ${password} | 12345 | ||
Button Should Be Disabled | ${boutonCreer} | |||
Insert Into Text Field | ${login} | 1234 | ||
Button Should Be Disabled | ${boutonCreer} | |||
Insert Into Text Field | ${password} | 123456 | ||
Button Should Be Enabled | ${boutonCreer} | |||
Select Window | ${AppName} | |||
Insert Into Text Field | ${login} | John | ||
Insert Into Text Field | ${password} | 123456 | ||
Push Button | ${boutonCreer} | |||
Check Message | Utilisateur | Utilisateur cree avec succes | ||
Select Window | ${AppName} | |||
${newLoginValue} = | Get Text Field Value | ${login} | ||
Should Be Empty | ${newLoginValue} | |||
${newPasswordValue} = | Get Text Field Value | ${password} | ||
Should Be Empty | ${newPasswordValue} | |||
Connect To Database | com.mysql.jdbc.Driver | jdbc:mysql://localhost/mysampledb | mysampleuser | |
... | mysampleuser | |||
Check Content For Row Identified By Where Clause | login,password | John|123456 | user | |
... | login="John" | |||
Disconnect From Database | ||||
[Arguments] | ${title} | ${message} | ||
Select Dialog | ${title} | |||
${messageText} | Get Label Content | OptionPane.label | ||
Should Be Equal | ${message} | ${messageText} | ||
Close All Dialogs | ||||
Select Window | ${AppName} | |||
Insert Into Text Field | ${login} | Maurice | ||
Insert Into Text Field | ${password} | 123456 | ||
Push Button | ${boutonCreer} | |||
Check Message | Utilisateur | Utilisateur cree avec succes | ||
Select Window | ${AppName} | |||
Insert Into Text Field | ${login} | Maurice | ||
Insert Into Text Field | ${password} | 654321 | ||
Push Button | ${boutonCreer} | |||
Check Message | Avertissement | Un utilisateur avec cet identifiant existe deja | ||
Connect To Database | com.mysql.jdbc.Driver | jdbc:mysql://localhost/mysampledb | mysampleuser | |
... | mysampleuser | |||
Delete All Rows From Table | user | |||
Disconnect From Database | ||||
Tests développeurs
Comme vu dans la description des scénarios, il y a besoin pour compléter ces tests de niveau fonctionnel, d’ajouter des tests unitaires qui vont garantir la couverture de l’exigence.
Les tests unitaires étant du « code », il est plus facile d’obtenir des algorithmes de contrôle plus « couvrant ».
Test du contrôle de format des champs :
public void testFieldsFormat()throws Exception {
NewUserPanel panel = new NewUserPanel();
String text = "0123456789";
// only login
for (int i = 0; i < 10; i++) {
assertFalse(panel.controlFields(text.substring(0,i),""));
}
// only password
for (int i = 0; i < 10; i++) {
assertFalse(panel.controlFields("",text.substring(0,i)));
}
// both for password
for (int i = 0; i < 10; i++) {
if(i<6) {
assertFalse(panel.controlFields("1234",text.substring(0,i)));
} else {
assertTrue(panel.controlFields("1234",text.substring(0,i)));
}
}
// both for login
for (int i = 0; i < 10; i++) {
if(i<4) { assertFalse(panel.controlFields(text.substring(0,i), "123456"));
} else {
assertTrue(panel.controlFields(text.substring(0,i), "123456"));
}
}
}
Il faudrait ici compléter ce test par un test de robustesse.
C'est-à-dire un test non dérivé du besoin, mais répondant à une contrainte technique.
J’ai choisi des champs en base de données de type texte à 45 caractères max. Il faudrait donc écrire un test garantissant que ces limites sont correctement gérées.
Encore une fois, il me semble évident d’abord de demander l’avis du Product Owner sur ce point qui reste fonctionnel et compléter la user story par cette nouvelle exigence.
public void testCreateUser() throws Exception {
Class.forName("com.mysql.jdbc.Driver");
Connection con = DriverManager.getConnection
( "jdbc:mysql://localhost/mysampledb", "mysampleuser","mysampleuser");
String login = "cestuntest";
String password = "cestlepassword";
User user = new User(login, password);
String message = user.save(con);
assertEquals("Utilisateur cree avec succes", message);
Statement s = con.createStatement();
ResultSet rs = s.executeQuery("select login, password, creation_date from user where login=\""+login+"\"");
rs.next();
assertEquals(login, rs.getString(1));
assertEquals(password, rs.getString(2));
assertNotNull(rs.getString(3));
// date non testée
user.delete(con);
ResultSet rs2 = s.executeQuery("select login, password, creation_date from user where login=\""+login+"\"");
assertFalse(rs2.next());
}
public void testCreateDoubleUser() throws Exception {
Class.forName("com.mysql.jdbc.Driver");
Connection con = DriverManager.getConnection
( "jdbc:mysql://localhost/mysampledb", "mysampleuser","mysampleuser");
String login = "cestuntest";
String password = "cestlepassword";
User user = new User(login, password);
user.save(con);
User user2 = new User(login, password);
try {
user2.save(con);
} catch (Exception e) {
assertEquals("Un utilisateur avec cet identifiant existe deja", e.getMessage());
} finally {
user.delete(con);
}
}
Pour des raisons pratiques et de temps, je n’ai ni géré les accents, ni les dates ! Mon Product Owner ne va pas être content.
Conclusion
La combinaison Tests fonctionnels/tests programmeurs (TDR et TDD) s’est avéré très efficace.
- D’un, je respecte le contrat défini par les critères d’acceptance,
- Deux, je communique avec le client sur la précision de son besoin,
- Trois, je garantie un jeu de test de non régression automatisé.
Cette approche m’a permis aussi de facilement définir quelle partie du code devait être testé unitairement. C’est une réponse au besoin exprimé par les tests fonctionnels qui m’a conduit à faire un test unitaire complémentaire garantissant une forme de robustesse et de cohérence. C’est le couplage de ces deux niveaux de test qui couvrent le besoin à 100%.
Je me suis aussi rendu compte que l’écriture des tests m’a obligé à codé d’une autre façon. Notamment, les méthodes de contrôles sont par besoin obligatoirement factorisées, mais aussi indépendantes du code appelant. Cela, me semble-t-il, améliore la maintenabilité du code.
Par exemple, le contrôle de format des champs m’a contraint à fournir une méthode prenant deux paramètres en entrée et renvoyant un booléen. Cette méthode à ensuite été connectée aux événements des deux champs login et password de l’IHM. Les tests IHMs assurant la bonne interaction entre la saisie utilisateur et le code de contrôle.
Annexe Tests unitaires
En attendant, petit exercice :
Reprenons le sujet de Jean-Michel Inglebert.
Peu importe la pratique, test first ou test « trop tard » !, l’objectif est de vérifier que la portion de code à tester rend le service demandé, le cas nominal, et répond aux appels extérieurs, cas dégradés et robustesse.
A ce niveau de test, ce sont les règles de gestion que l’on teste. La mécanique du système.
Prenons l’exemple du calcul de racine d’une équation à 2 inconnues.
ax² + bx + c = 0 avec a != 0
C’est une règle de gestion comme une autre.
On peut partir du test suivant simple, décrivant les trois cas majeurs :
- a = 1, b = 2, c = 3 è pas de résultat
- a = 1, b = 4, c = 4 è racine double X1=X2
- a = 1, b = 5, c = 3 è deux racines différentes X1 != X2
C’est pas mal, mais pas suffisant, comment généraliser :
Jean-Michel Inglebert propose par exemple de faire varier les paramètres:
- a = -1, b de 10 à 100000, c = 1
Il y erreur de calcul, le résultat n’est pas strictement égale à 0, dû aux erreurs de calcul sur virgule flottante. Il faut donc limiter le résultat. Dans ce cas, le résultat est ok à 10E-6 prés de zéro.
Le test couvre mieux la spec et précise la limitation de la méthode à 10E-6 pour b jusqu’à 10000
Et ne pas oublier de tester « avec a !=0 ». Mais quel comportement doit avoir l’appli dans ce cas ? Une exception, un code retour, un message à l’utilisateur… A demander à votre Product Owner.
0 commentaires:
Enregistrer un commentaire