Coaching Agile

Agile, Scrum et autres pensées
Laurent Carbonnaux

Strategie de test

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 na 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.


  • Cest 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 …
  • doublier les approches manuelles
  • dexpliciter les limites
  • de traiter le qualitatif et le quantitatif
    et Ce qu’il faut retenir …
  • Cest irréalisable par un humain
  • Les limites sont aussi importantes que les cas standards pour lutilisateur de la librairie
  • Le retour sur investissement est important : linformation 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 dutilisation : 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é dun 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 la 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.


image


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é
  • linterface 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, cest laspect utilisateur du système complet, qui ne sintéresse quà la partie métier pure, ce que lutilisateur 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 lutilisateur de lappli. 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 doù 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 :
  • Cest 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 lapplication en mode non admin
  • Sélection de la fenêtre principale
  • Vérifier que le bouton « Nouvel Utilisateur » nest pas visible (cest ce keyword que jai du ajouter)

Ça donne dans RobotFramework :

Test Mode Non Admin

Test Case
Action
Arguments
Tester Écran Création Mode Non Admin
[Setup]
Lancer appli en mode non Admin



Button Should Not Be Visible
${boutonNewUser}







 
Keyword
Action
Arguments
Lancer Appli En Mode Non Admin
Start Application
org.lcx.myapp.MySampleApp



Select Window
${AppName}







  

Scénario en mode admin.

Scénario :
  • Lancer Appli En Mode Admin
    • Lancer lapplication 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 nest 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 laffichage 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 derreur
    • 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
Créer Un Nouvel Utilisateur
[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
Lancer Appli En Mode Admin
Start Application
org.lcx.myapp.MySampleApp
-admin


Select Window
${AppName}







Ouvrir Ecran Creation Utilisateur
Push Button
${boutonNewUser}



Select Context
${newUserPanel}



${buttonTest} =
Get Button Text
${boutonCreer}


Should Be Equal
Create
${buttonTest}






Les Champs Sont Obligatoires
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}







Les Champs Sont Au Bon Format
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}







Creer Un Utilisateur
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








Check Message
[Arguments]
${title}
${message}


Select Dialog
${title}



${messageText}
Get Label Content
OptionPane.label


Should Be Equal
${message}
${messageText}


Close All Dialogs








Creer Un Utilisateur en double
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






Delete table user
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"));
}
}
}

Test de robustesse :
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.

Test de persistance nouvel utilisateur :
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());
}

Test création en double:
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.
  • Dun, je respecte le contrat défini par les critères dacceptance,
  • 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: