Twitter iPhone pliant OnePlus 11 PS5 Disney+ Orange Livebox Windows 11

Ecrire de bon tests unitaires

8 réponses
Avatar
Zouplaz
Bonjour, je suis en train de me faire violence et apprendre à utiliser un
outil de tests unitaires, Cactus en l'occurence.

Je compte mettre en oeuvre autant de tests qu'il y a de formulaires dans
mon application. Ca ne couvre pas tous les cas mais ça représente la
majeure partie des fonctionnalités, et qui plus est les plus sensibles
(l'ajout ou la modification de données).

Grâce à Cactus, je simule un post de formulaire et je vérifie ensuite si
les données présentes dans la base sont cohérentes.

Mais, ce faisant je commence à me poser quelques questions :

1) Est-il normal que j'ai à modifier un DAO pour permettre l'exécution
d'un test.

Par exemple, j'ai du créer cette méthode :
public Collection findWithCriteria(String criteria) throws
HibernateException

Alors que dans l'application je n'en n'ai (pour l'instant au moins) pas
besoin. D'accord c'est un cas bateau et avoir une méthode capable de
retourner une collection à partir d'un critère SQL fait parti du BA BA
des DAO mais quand même... Sur le principe est acceptable ?


2) Comment faire pour que les tests eux-mêmes ne soit faillibles ?

Exemple avec un des tests :

public void testAddChildCategory() throws Exception
{
boolean found = false;

this.request.setRemoteUser("chris");
servletRun();
setupDAO();

Category parentCategory = categoryDAO.getCategory(new Long(1));
assertNotNull(parentCategory);

// Doit être trouvée dans les enfants de la catégorie parente
Collection child = parentCategory.getChildCategories();
Iterator iter = child.iterator();
while(iter.hasNext() && !found)
found = ((Category)iter.next()).getTitle().equalsIgnoreCase
("TESTCASE_CHILDCAT");
assertTrue(found);

// Doit avoir pour parent la catégorie ID 1
Collection cats = categoryDAO.findWithCriteria
("cat.title='TESTCASE_CHILDCAT'");
assertEquals(1,cats.size());
Category cat = (Category)cats.iterator().next();
assertTrue(cat.getParentCategory().getId().intValue() == 1);

// Ménage
categoryDAO.makeTransient(cat);

endDAO();
}

Dans le code ci-dessus, il y a plusieurs choses qui me dérangent :
- si le code échoue pour une raison Y (mise au point du test), il laisse
la base de donnée dans un état incohérent

- j'utilise (sans avoir d'autre alternative pour l'instant) une recherche
par chaine de caractères pour retrouver l'enregistrement crée. Une erreur
de typo est vite arrivée, générant une erreur de test alors que c'est le
test lui même qui est fautif, pas ce sur quoi il porte. Plus généralement
je fais référence à des données statiques (la catégorie parente dont l'ID
est 1 : FAUX si la base est vide, le titre de la catégorie doit être
'TESTCASE_CHILDCAT', etc

- pour des tests encore plus complexes, le code du test va devenir plus
délicat à mettre au point. Je risque d'avoir à examiner des collections,
des relations, ou que sais-je encore. Quelle technique/principe puis-je
mettre en oeuvre pour éviter d'écrire un test qui :
a) laisserait passer une erreur
b) ne produit pas d'erreurs par lui même

Ceux d'entre vous qui font du test unitaire sur des servlet devraient
pouvoir me donner quelques conseils qui seront les bienvenus...

Merci d'avance !!

8 réponses

Avatar
Laurent Bossavit
Yo,

Bonjour, je suis en train de me faire violence et apprendre à utiliser un
outil de tests unitaires, Cactus en l'occurence.


Bravo !

Je compte mettre en oeuvre autant de tests qu'il y a de formulaires dans
mon application.


Un principe qui peut aider à démarrer, c'est d'écrire d'abord des tests
pour les fonctionnalités dans lesquelles on a trouvé des bugs. En
particulier en intégrant le test dans le processus de correction: on me
signale un bug, j'écris un test qui échoue à cause du bug, je corrige le
bug, le test doit maintenant passer - s'il passe je sais que ma
correction était effective.

1) Est-il normal que j'ai à modifier un DAO pour permettre l'exécution
d'un test.


En règle générale, il ne faut pas s'étonner d'avoir à modifier du code
pour en améliorer la testabilité, si on n'a pas préalablement écrit le
code en ayant spécifiquement cherché à le rendre testable.

Sans en savoir plus, impossible de répondre à ta question sur le sujet
précis des DAO ou autres patterns particuliers.

Par exemple, j'ai du créer cette méthode :
public Collection findWithCriteria(String criteria)


Pourquoi pas... Si ça te gène, créée dans ta classe de test une classe
auxiliaire héritant de ton DAO et implémentant cette méthode:

class CategoryTest extends TestCase {
// bla bla
public void testAddChildCategory() {
// bla bla
class CategoryDAOForTest extends CategoryDAO {
public Collection findWithCriteria(String criteria) {
// etc

Si jamais tu en as besoin dans l'appli, un refactoring: Move Method. :)

Dans le code ci-dessus, il y a plusieurs choses qui me dérangent :
- si le code échoue pour une raison Y (mise au point du test), il laisse
la base de donnée dans un état incohérent


Et c'est sans importance. Le "contrat" d'un test unitaire est le
suivant: chaque test doit instaurer un contexte connu et prévisible, de
telle sorte qu'on puisse prédire (et vérifier par des assert) ce que
devient ce contexte après le déroulement de la fonction qu'on teste. Si
tu testes sur la base de données, le test unitaire doit placer la base
dans un état connu, prévisible, avant de dérouler la fonction testée.

Chaque exécution d'un test est précédée et suivie respectivement d'un
appel aux méthodes setUp() et tearDown() de TestCase. C'est dans ces
méthodes que tu peux en fonction des besoins appeler le code qui va
replacer la base dans un état connu.

Cf:
http://www.codeproject.com/gen/design/autp5.asp (Rollback Pattern)

Une autre solution est d'utiliser une DB comme HypersonicSQL, qui permet
très facilement et rapidement de réinitialiser la base à un état connu.

Plus généralement je fais référence à des données statiques (la catégorie
parente dont l'ID est 1 : FAUX si la base est vide, le titre de la catégorie
doit être 'TESTCASE_CHILDCAT', etc


Oui, et ça ne pose aucun problème dans la mesure où tu initialises la
base à un état connu avant chaque test.

Quelle technique/principe puis-je mettre en oeuvre pour éviter d'écrire
un test qui :
a) laisserait passer une erreur
b) ne produit pas d'erreurs par lui même


La meilleure solution: écrire le test avant le code de la fonction
testée. (Donc, évidemment, au fil du développement, pas à la fin lorsque
l'appli est terminée.)

Dans ton exemple ci-dessus, imagine que le servlet ne fait rien - il est
totalement vide de toute implémentation. Tu écris d'abord le test -
celui que tu as posté me semble bien fichu. Tu exécutes le test:
forcément, il échoue, puisque le servlet est sans effet. Ensuite tu
implémentes la transaction dans le servlet. Si ton test est correct, et
que ton code est correct aussi, le test passe.

Il peut aussi arriver que le test soit incorrect, ou que le code soit
incorrect. Alors le test échoue. Tu ne sais pas faire la différence
entre les deux cas, à moins d'examiner les choses de plus près, mais tu
sais qu'une erreur a été détectée. Il est donc impossible d'écrire un
mauvais test si tu fais les choses dans cet ordre. (Pas impossible, en
fait, mais hautement improbable - il faudrait que l'erreur dans ton test
"annule" précisément l'erreur dans ton code.)

Laurent

Avatar
Zouplaz
Merci pour ta réponse, c'est très instructif !

Un détail encore, tu me parles des méthodes setUp() et tearDown()...
Si j'ai bien compris elles sont exécutées avant et après l'ensemble des
tests. Bien, mais est-ce qu'il n'y a pas un moyen d'avoir un setUpXXX et
tearDownXXX ?

Je m'explique : je viens à peine de comprendre que dans Cactus les méthodes
beginXXX et endXXX sont clientes et que la méthode textXXX est serveur.
Donc peine perdue si je veux faire passer des données des premières vers la
seconde puisque plusieurs instances sont crées par Cactus.

En clair, pourquoi est-il n'y a aucune possibilité d'avoir une méthode
appellée systématiquement à la fin de chaque test pour la partie serveur ?
Comme il n'y a que tearDown(), ça implique d'y mettre le code qui va faire
"le ménage" de l'ensemble des tests et non pas d'un test particulier...

Je suis surpris que les concepteurs de Cactus n'ai pas implémenté cette
fonctionnalité, ou alors je l'ai raté, ou alors ça ne sert à rien et il y a
quelque chose qui m'échappe... ;-)
Avatar
Zouplaz
Je complète mon précédent message :

L'ordre d'exécution est

beginXXX
setUp
testXXX
tearDown
endXXX

Actuellement, dans ma méthode beginXXX je dois passer comme paramètre
l'identifiant d'une catégorie à laquelle va être attaché un message (par
exemple).
Je pensais placer dans setUp() le code nécessaire qui crée cette catégorie
(qui sera détruite dans tearDown)

Or, beginXXX s'exécute AVANT setUp donc ça ne colle pas. Du coup je suis
obligé de placer le code en question (qui crée cette fameuse catégorie)
dans beginXXX, de récupérer l'identifiant (la clé primaire), de le stocker
dans une variable membre statique, identifiant qui sera exploité dans
testXXX (je dois vérifier que le message présent dans la base est bel et
bien attaché à cette catégorie).

D'une part ça me semble bizarre d'initialiser un objet dans beginXXX
puisque je suis du côté "client", et d'autre part d'être obligé de passer
par un membre statique (c'est le seul moyen que j'ai trouvé, vu que deux
instances de la classe de test sont créées par Cactus).

Bon, c'est peut être pas trop clair ce que je raconte ;-)
Avatar
Laurent Bossavit
Bon, c'est peut être pas trop clair ce que je raconte ;-)


Si si, très clair.

Je connais JUnit mieux que je ne connais Cactus, mais je vais essayer
d'esquisser la solution... Ce n'est pas une astuce technique, c'est une
question de bien comprendre la logique du test unitaire.

Le problème est le suivant: setUp ne devrait pas avoir à "communiquer"
avec beginXXX.

Pour bien comprendre, il faut connaître un peu la philosophie de JUnit,
qui est l'ancètre de Cactus. Dans JUnit, il y a pour chaque classe de
test un seul setUp et un seul tearDown. Chaque classe de test doit
contenir des tests qui peuvent être déroulés dans le même contexte de
test (on dit aussi "fixture"). Donc, quand on veut des setUp()
différents, on va tout simplement créer une nouvelle classe de test,
parce que ça signifie qu'on est dans un contexte différent. Ce contexte,
on est censé le connaître.

C'est seulement parce que "l'astuce" de JUnit qui consiste à exécuter,
dans une même classe de test, toutes les méthodes testXXX() (via la
reflection de Java), rend la vie très (trop) simple qu'on a tendance à
faire l'équation "cas de test" = "méthode de test". En fait un cas de
test est une classe de test. Pour un seul cas de test tu peux avoir N
méthodes de test qui testent des comportements divers *dans un contexte
donné* et ce contexte est supposé invariable. JUnit est fait comme ça,
c'est peut-être un mauvais choix, mais c'est un choix qui fait partie de
sa conception et Cactus reprend ce choix.

La doc de Cactus n'est pas dans le vrai quand elle dit que beginXXX est
"l'équivalent côté client" de setUp(). Les méthodes beginXXX() *font
partie du test*, puisqu'elles précisent le comportement du client dans
un cas d'utilisation donné. Tu pourrais tout faire dans testXXX(). Alors
que les méthodes setUp() ne font jamais partie du test, tout ce qu'elles
font c'est établir un contexte pour le test.

Dans ton exemple, "le contexte" c'est la base de données dans un état
bien précis, avec des catégories dont les propriétés sont connues. Donc,
dans setUp() il faudrait recréer systématiquement les *mêmes* données en
base, de sorte que l'identifiant de la catégorie parente reste constant
d'un test à l'autre. Par exemple, utilise un INSERT qui spécifie la clé
primaire. Et du coup, tu vas coder "en dur" l'identifiant en question
dans tes beginXXX.

Cela dit, pragmatiquement, s'il est plus simple de créer tes catégories
à la volée et récupérer l'identifiant après... Cactus est open-source,
donc tu dois pouvoir modifier l'ordre d'appel en modifiant Cactus. Mais
la "portabilité" de cette astuce n'est pas garantie. :)

Laurent

Avatar
Zouplaz
Laurent Bossavit - :


Je connais JUnit mieux que je ne connais Cactus, mais je vais essayer
d'esquisser la solution... Ce n'est pas une astuce technique, c'est une
question de bien comprendre la logique du test unitaire.

Le problème est le suivant: setUp ne devrait pas avoir à "communiquer"
avec beginXXX.



Cf. plus bas

Pour bien comprendre, il faut connaître un peu la philosophie de JUnit,
qui est l'ancètre de Cactus. Dans JUnit, il y a pour chaque classe de
test un seul setUp et un seul tearDown. Chaque classe de test doit
contenir des tests qui peuvent être déroulés dans le même contexte de
test (on dit aussi "fixture"). Donc, quand on veut des setUp()
différents, on va tout simplement créer une nouvelle classe de test,
parce que ça signifie qu'on est dans un contexte différent. Ce
contexte, on est censé le connaître.


Bon, d'accord je comprends mieux : une partie de mes problèmes viennent
du fait que je place des tests dans la même classe alors que je devrais
créer plusieurs classes de test.


C'est seulement parce que "l'astuce" de JUnit qui consiste à exécuter,
dans une même classe de test, toutes les méthodes testXXX() (via la
reflection de Java), rend la vie très (trop) simple qu'on a tendance à
faire l'équation "cas de test" = "méthode de test". En fait un cas de
test est une classe de test. Pour un seul cas de test tu peux avoir N
méthodes de test qui testent des comportements divers *dans un contexte
donné* et ce contexte est supposé invariable. JUnit est fait comme ça,
c'est peut-être un mauvais choix, mais c'est un choix qui fait partie
de sa conception et Cactus reprend ce choix.

La doc de Cactus n'est pas dans le vrai quand elle dit que beginXXX est
"l'équivalent côté client" de setUp(). Les méthodes beginXXX() *font
partie du test*, puisqu'elles précisent le comportement du client dans
un cas d'utilisation donné. Tu pourrais tout faire dans testXXX().


Bon, sur le principe oui mais dans la pratique l'objet http request (du
moins la simulation de l'objet request qui dans Cactus est un WebRequest)
n'est pas disponible lors de l'exécution de testXXX. Mais il est sans
doute possible d'altérer les paramètres par des moyens conventionnels (à
priori tout ça se sont des Map)

Alors que les méthodes setUp() ne font jamais partie du test, tout ce
qu'elles font c'est établir un contexte pour le test.

Dans ton exemple, "le contexte" c'est la base de données dans un état
bien précis, avec des catégories dont les propriétés sont connues.
Donc, dans setUp() il faudrait recréer systématiquement les *mêmes*
données en
base, de sorte que l'identifiant de la catégorie parente reste constant
d'un test à l'autre. Par exemple, utilise un INSERT qui spécifie la clé
primaire. Et du coup, tu vas coder "en dur" l'identifiant en question
dans tes beginXXX.


C'est ce que je me disais mais (sauf erreur de ma part), Cactus appelle
beginXXX avant setUp ! C'est une des choses que j'ai vraiment du mal à
comprendre. Comme tu le dis, je pourrais tenter de modifier Cactus mais
j'ai bien peur d'y passer beaucoup de temps vu mon niveau de pratique
général ;-)


Je reviens sur ce que tu dis plus haut : setUp et beginXXX ne devraient
pas communiquer entre eux. Voici plus précisement le problème auquel je
me heurte justement à ce sujet et pour lequel je ne vois aucune autre
solution que l'usage d'une variable membre statique pour faire transiter
une info entre les deux. Problème qui vient du fait que puisqu'on simule
un POST de formulaire je n'ai ni côté client, ni côté serveur (sous
Cactus j'entends bien) la possibilité de connaître la clé primaire
générée sans la faire transiter par un membre statique.

Le topo : chaque message du blog doit être associé à une catégorie.
Il y a des catégories racines et des catégories de niveau inférieur. J'ai
donc une relation parent/enfants entre les catégories.

Voici le test m'assurant que la modification d'une catégorie (pour
l'instant la seule information modifiable est le titre de la catégorie)
fonctionne correctement :


public void beginModifyCategory(WebRequest wr) throws Exception
{
setupAuthCommonParameters(wr);

beginDAO();

// On commence par créer et sauvegarder une catégorie
Category cat = new Category();
cat.setTitle(ROOTCAT_SIGNATURE);
categoryDAO.makePersistent(cat);
testCategoryID = cat.getId();

// On prépare le form post

wr.addParameter("action","modcat");
wr.addParameter("postback","true");
wr.addParameter("category.id",testCategoryID.toString());
wr.addParameter("category.title",EDITED);

endDAO();
}

public void testModifyCategory() throws Exception
{
this.request.setRemoteUser("chris");
servletRun();
beginDAO();

// Les champs de la catégorie doivent
Category cat = categoryDAO.getCategory(testCategoryID);
assertEquals(cat.getTitle(),EDITED);
categoryDAO.makeTransient(cat); // TODO trouver un moyen de faire
le ménage en cas d'exception : à mettre dans tearDown
endDAO();
}

1) J'ai besoin d'une catégorie à modifier, elle est créée pour l'occasion
2) dans begin j'ai besoin de l'identifiant de cette catégorie pour
effectuer le POST
3) dans test j'ai à nouveau besoin de l'identifiant pour vérifier que les
modifications sur la catégorie sont effectives
4) et aussi dans tearDown pour détruite le jeu de test (la catégorie)

Problèmes :
a) Si je crée la catégorie dans setUp, test y aura accès mais pas begin
(or j'en ai besoin dans begin)
b) de toutes façons setUp s'exécute après begin
c) et si je la crée dans begin, tearDown n'y a pas accès !

J'ai beau faire, je ne sais pas comment je peux me passer de la jonction
faite par le membre statique (ici testCategoryID) avec pour résultat :

# begin écrit testCategoryID
# test lit testCategoryID
# tearDown lit testCategoryID

La seule solution serait qu'il n'y ait plus rien dans begin et tout dans
setup et test (si c'est possible, à voir). Ce qui me semble bizarre
puisque ça va à l'encontre des fonctionnalités client/serveur de
Cactus...

Ouf, quel casse tête. C'est Cornélien ! ;-)

Avatar
Laurent Bossavit
C'est ce que je me disais mais (sauf erreur de ma part), Cactus appelle
beginXXX avant setUp ! C'est une des choses que j'ai vraiment du mal à
comprendre.


C'est vrai que dans ton cas ça semble compliquer inutilement les choses.

Maintenant, si tu y réfléchis, c'est une conséquence logique de la
situation: Cactus simule un client et un serveur. Comment veux-tu que le
client "connaisse" l'identifiant d'un objet qui est stocké en base côté
serveur ? Que setUp() soit appelé avant ou après ne change rien à
l'affaire - tu aurais quand même besoin de faire passer ton identifiant
par un canal "illogique" puisqu'il n'existe pas dans la situation qu'on
est censé tester. (Normalement, ton client a récupéré, via un premier
formulaire, donc après une première requête GET, l'identifiant d'une
catégorie qui est déjà créée.)

Le problème vient de ce que tu créées ta catégorie via "makePersistent"
en laissant Hibernate générer l'identifiant. Ton test installe donc une
situation "aléatoire" - tu ne peux pas prédire à l'avance quel sera
l'identifiant généré par makePersistent. (Est-ce que c'est correct ?) Or
un test unitaire doit installer une situation parfaitement connue.

A la place, peux-tu spécifier l'identifiant au moment de rendre l'objet
persistant ? Le peu que je connais d'Hibernate me laisse penser que
c'est possible, via setId - je peux me tromper. (Sinon, peux-tu utiliser
des INSERT ?) Cela te permettrait de spécifier à l'avance l'identifiant
de la catégorie.

Bref, c'est à peu près comme ça que c'est censé se passer:

public void setUp() throws Exception
{
beginDAO();

// On commence par créer et sauvegarder une catégorie
Category cat = new Category();
cat.setId(99);
cat.setTitle(ROOTCAT_SIGNATURE);
categoryDAO.makePersistent(cat);

endDAO();
}

public void beginModifyCategory(WebRequest wr) throws Exception
{
setupAuthCommonParameters(wr);

// On prépare le form post
wr.addParameter("action","modcat");
wr.addParameter("postback","true");
wr.addParameter("category.id",99);
wr.addParameter("category.title",EDITED);
}

Laurent

Avatar
Zouplaz
Laurent Bossavit - :

C'est ce que je me disais mais (sauf erreur de ma part), Cactus appelle
beginXXX avant setUp ! C'est une des choses que j'ai vraiment du mal à
comprendre.


C'est vrai que dans ton cas ça semble compliquer inutilement les choses.

Maintenant, si tu y réfléchis, c'est une conséquence logique de la
situation: Cactus simule un client et un serveur. Comment veux-tu que le
client "connaisse" l'identifiant d'un objet qui est stocké en base côté
serveur ? Que setUp() soit appelé avant ou après ne change rien à
l'affaire - tu aurais quand même besoin de faire passer ton identifiant
par un canal "illogique" puisqu'il n'existe pas dans la situation qu'on
est censé tester. (Normalement, ton client a récupéré, via un premier
formulaire, donc après une première requête GET, l'identifiant d'une
catégorie qui est déjà créée.)

Le problème vient de ce que tu créées ta catégorie via "makePersistent"
en laissant Hibernate générer l'identifiant. Ton test installe donc une
situation "aléatoire" - tu ne peux pas prédire à l'avance quel sera
l'identifiant généré par makePersistent. (Est-ce que c'est correct ?) Or
un test unitaire doit installer une situation parfaitement connue.

A la place, peux-tu spécifier l'identifiant au moment de rendre l'objet
persistant ? Le peu que je connais d'Hibernate me laisse penser que
c'est possible, via setId - je peux me tromper. (Sinon, peux-tu utiliser
des INSERT ?) Cela te permettrait de spécifier à l'avance l'identifiant
de la catégorie.


Je saisi mieux la nuance entre un environnement aléatoire et un
environnement connu. Reste à voir si je peux forcer un identifiant de cette
manière...


Bref, c'est à peu près comme ça que c'est censé se passer:

public void setUp() throws Exception
{
beginDAO();

// On commence par créer et sauvegarder une catégorie
Category cat = new Category();
cat.setId(99);
cat.setTitle(ROOTCAT_SIGNATURE);
categoryDAO.makePersistent(cat);

endDAO();
}

public void beginModifyCategory(WebRequest wr) throws Exception
{
setupAuthCommonParameters(wr);

// On prépare le form post
wr.addParameter("action","modcat");
wr.addParameter("postback","true");
wr.addParameter("category.id",99);
wr.addParameter("category.title",EDITED);
}



Je vais essayer d'aller dans ce sens... Rhâa faut vraiment s'accrocher pour
mettre en pratique de bonnes méthodes ! Je suppose que c'est au début qu'on
patinne, après on en retire les bénéfices.

Merci pour ton aide !


Avatar
Laurent Bossavit
Je vais essayer d'aller dans ce sens... Rhâa faut vraiment s'accrocher pour
mettre en pratique de bonnes méthodes ! Je suppose que c'est au début qu'on
patinne, après on en retire les bénéfices.


Tu peux aussi te faire aider par un expert... par exemple je dispense
des formations sur le sujet. :)

Laurent
http://bossavit.com/