blogDev

Tests unitaires: informer son équipe de leurs intérêts

Ce post – encore un bien long – est le contenu d’un support de présentation sur les tests unitaires. Je l’avais écris à l’époque où j’avais en charge le développement des clients Actionscript des jeux d’IsCool Entertainment, ce qui explique que le langage utilisé n’est pas forcément conventionnel pour le sujet 🙂 (enfin, ce n’est pas du JAVA, ni du C#, ni du C++).

1/ Introduction: qu’appelle t’on “test” en génie logiciel?

Cf wikipedia

Les tests sur les développements informatiques sont un vaste sujet, qui retombe parfois sur le QA, ou sur les chefs de projet fonctionnels ou techniques, voir sur les développeurs, sans pour autant être clairement défini. On « teste » souvent à la main, sans savoir exactement ce qu’on cherche, on lance l’application, on la fait tourner une fois. Si ça passe, on considère que c’est validé. Oust…

Pourtant, il existe des normes pour tous les différents tests, et, en s’y penchant un peu, on se rend compte que ce n’est pas un sujet aussi magique qu’il n’y parait. Au contraire, c’est un domaine sur lesquels l’expérience de gros éditeurs ou SSII, tel que Microsoft, IBM et autres, a apporté un cadre professionnel et clair. Aussi, quid des tests ?

1.1/ Les niveaux de tests

Il existe quatre niveaux de tests:

  • tests unitaires, ne s’intéressant qu’aux unités les plus petites du code, à savoir les fonctions.
  • tests d’intégration, permettant de vérifier la mise en place technique au sein de la plateforme.
  • tests systèmes. Ces tests ne portent que sur les fonctionnalités développées
  • tests d’acceptation, dernier du cycle, qui correspond à ce que l’on peut appeler recette.

De ces différents niveaux ressortent les “SUT” (system under test).

1.2/ Les types de tests

De manière transverse, on trouve les types de tests.

  • tests de performance
  • tests de non-régression
  • tests fonctionnels
  • test de robustesse
  • test de non vulnérabilité
  • test de charge

Une confusion – abus de langage – entraîne souvent à confondre le niveau de tests et le type de tests. Cependant, rien n’empêche un test de performance de valider la non régression sur un élément du système logicielle. Rien n’empêche les tests unitaires de valider parfois la robustesse du code, les fonctionnalités…

Évidemment, il y a quelques non-sens qui pourraient découler d’une telle liberté: un test unitaire ne pourra pas valider la charge par exemple, sachant que ce type de tests nécessite bien plus qu’une simple unité de code pour être valable.

A partir de maintenant, on ne s’intéressera qu’au niveau TEST UNITAIRE

 

2/ Premier exemple de test unitaire

2.1/ Comment définir un test?

Pour ce premier exemple, je reprends celui fourni par wikipedia – en le modifiant que très légèrement. source:http://en.wikipedia.org/wiki/Unit_testing

Admettons que l’on souhaite tester une classe effectuant des additions, pour un programme de calculatrice (l’exemple est volontairement trivial, le niveau de complexité du code n’empêchant d’intégrer des tests unitaires, contrairement aux idées reçues). Cette classe est décrite par une interface. En voici le code:

interface Adder { 
    int add( int a, int b); 
} 

class AdderImpl implements Adder { 
    int add(int a, int b) { 
        return a + b; 
    } 
}

 2.1.1/ Définition du SUT

Pour un test unitaire, le SUT est une méthode. Pour une même méthode, on pourra avoir plusieurs tests, notamment si celle-ci dispose de différents comportements (renvoie un résultat ou lève une exception selon les valeurs en entrée, …).

Ici, notre “sut” est « add » (contrairement à ce qu’il y a souvent marqué dans les tests unitaires des frameworks flash, le SUT n’est pas un objet… c’est juste un léger abus de langage)

2.1.2/ Détail du corps d’un test

Un test unitaire comprend, quasiment toujours, 4 étapes:

a/ L’initialisation (setup)

On instancie l’objet contenant le SUT, et on met en place les valeurs d’entrée pour le test.

Adder adder = new AdderImpl();
b/ L’exécution

L’exécution, sur un test unitaire, est simple: il s’agit d’appeler la méthode testée, et de conserver son retour.

int result = adder.add(1, 1);
c/ La vérification

Dans un test simple, la vérification est composée de différentes assertions (est-ce que le résultat est bien égal à? est-ce que le retour n’est pas nul? …).

Afin de conserver une bonne lisibilité dans le feedback donné par les tests, et de pouvoir trouver facilement, si un test échoue, où se trouve l’erreur, il est fortement conseillé de limiter le nombre d’assertions par tests unitaires. Un maximum de 3/4 assertions par test peut être considéré comme une bonne pratique.

assert(result == 2);
d/ Le nettoyage (tear down)

Chaque test unitaire doit être exécuté dans son propre contexte, et ne doit pas partager celui d’un autre test. Ce qui signifie que, si un test porte / utilise un singleton, par exemple, il faut s’assurer que le test le laisse invariant (qu’il soit exactement dans le même état avant le test, qu’après).

En l’occurence, il n’y a pas de tear down pour ce test.

2.1.3/ Corps complet de cet exemple de test

Pour aller plus vite, on placera les exécutions dans les assertions, lorsqu’il est possible de le faire

public void testSum() { 
    Adder adder = new AdderImpl(); 

    // can it add positive numbers? 
    assert(adder.add(2, 2) == 4); 
    // is zero neutral? 
    assert(adder.add(0, 0) == 0); 
    // can it add negative numbers? 
    assert(adder.add(-1, -2) == -3); 
    // can it add a positive and a negative? 
    assert(adder.add(-1, 1) == 0); 
}

 

2.2/ Exécution du test et sortie.

2.2.1/ Appellations.

Les méthodes des tests unitaires sont appelées TestCase.

Ces méthodes sont placées dans des classes, qui s’appellent TestSuite.

2.2.2/ Le test runner, mécanisme de l’enchainement des tests.

Comme dit plus tôt, chaque test est exécuté dans son propre contexte. Pour se faire, les frameworks de tests unitaires disposent de “Runner”.

On fournit au runner – manuellement ou automatiquement, grâce aux frameworks modernes – la liste des TestSuites.

Pour chaque TestCase, le runner :

  •  instancie la TestSuite,
  • exécute le TestCase,
  • oublie l’instance de la TestSuite.

2.2.3/ La sortie.

La sortie d’un test unitaire se doit d’être claire et lisible. Mais cela dépend de vos outils. Couplés avec les IDE, un certain nombre de frameworks affichent clairement les tests ayant échoué, et permettent d’accéder immédiatement au code de celui-ci.

Couplés avec les serveurs d’intégrations, les frameworks génèrent si possible des rapports XML, qui sont parsés pour ensuite être affichés dans les rapports de build.

 

3/ Le contrat des tests unitaires.

3.1/ L’automatisation

Quelques soit le framework de tests / IDE / outils divers utilisés, il faut absolument que l’exécution des tests soit automatisée, ou du moins puisse se résumer à un clic pour le développeur. Les frameworks de tests unitaires et les outils de compilation (que ce soit pour le Java, le.NET ou l’AS3) ont aujourd’hui la capacité de générer le code des runners, et l’exécution des tests unitaires écrits se limite à choisir le package des tests que l’on souhaite lancés et cliquer sur RUN.

3.2/ La rapidité d’exécution

Des tests unitaires qui demandent dix minutes pour pouvoir valider l’état du développement sont de mauvais tests unitaires. Les tests unitaires ne devraient pas prendre plus de quelques secondes à chaque fois que le développeur souhaite les lancer, sinon, il ne les utilisera plus. Dans le cas d’une application complexe, ayant des 10° de milliers de tests, il devient indispensable de pouvoir tester localement à certains packages, et non sur l’ensemble de l’application à chaque fois.

3.3/ Le retour clair

Le sujet a été évoqué en 2.2.3. Si vos outils de tests unitaires renvoient des résultats impossibles à traiter (c’était le cas au début, lorsque tous les tests, ok et ko, étant listés dans d’énormes sortie test), changez-en !

3.4/ Des moyens de suivi: la couverture

Il faut pouvoir mesurer la « qualité » des tests. Cela reste entre guillemets, parce qu’on ne va pas non plus tester nos tests. Cependant, on doit pouvoir savoir ce qui est testé et ce qui ne l’est pas. C’est ici qu’intervient la couverture du code [de production] par les tests.

Je poserai un bémol sur la notion de couverture : la plupart des outils de couverture – et en tout cas, tout ce que je connais – se base sur une instrumentation du code de production, puis pour chaque ligne, compote le nombre de fois où la ligne a été appelée lors de l’exécution des tests unitaires. Cela peut nuire à la lisibilité de la couverture. En effet :

  • si A dépend de B,
  • que le test de A utilise l’implémentation concrète de B

La mesure de la couverture considèrera que B est couvert, étant donné que, lorsque les tests ont tournés, le code de B a été invoqué. Ce qui peut biaiser le pourcentage de couverture.

 

4/ Oui, mais écrire des tests, ça me prend du temps et cela ne me concerne pas: je n’écris pas de bugs “moi”

(oui, on me l’a déjà sorti…)

Avant d’aller plus loin, abordons les plus et les moins des tests unitaires, en commençant par ces derniers. On ne va pas les traiter exhaustivement, mais s’intéresser aux principaux.

4.1/ Les “moins” des tests unitaires

4.1.1/ Le découragement

Ecrire des tests est décourageant. Démotivant. C’est le vrai problème des tests unitaires. Dans un rythme de programmation “classique” (comprendre par classique: détant d’une période bien avant la période moderne): le développeur programme, sans se soucier de savoir si cela fonctionne vraiment dans tous les cas. Il compile, il voit immédiatement que “ça fonctionne” (dans le bon cas). Ayant ainsi le sentiment d’avoir fini sa tâche, il la livre. Pour les tests des cas dits défavorables, il y aura le QA, ou les utilisateurs, mais cela ne le concerne plus: il a fini sa tâche, et l’a effectué rapidement. Qui plus est, développer de cette manière ne force que très peu la réflexion: le développement peut être – et par expérience est – intuitif. “On code comme on pense”. Cela va vite, c’est réactif. On est vraiment satisfait.

Alors écrire des tests? Là où on pouvait se permettre d’écrire son code de manière totalement intuitive, il va falloir penser:

  • à écrire au moins un test pour chaque méthode (mais le plus souvent 3 ou 4) -> perte de temps!
  • à penser au cas favorable, mais aussi à tous ceux qui ne le sont pas – et qui sont souvent plus difficile à imaginer -> perte de temps!
  • au fait qu’on peut potentiellement écrire des bugs (mais pourtant, ça n’arrive jamais!)

Oui, les tests unitaires sont, dans une première approche, décourageants. Superficiellement, cela donne envie de fuir. Fort heureusement, si ces méthodes existent, et que les frameworks de tests se développent clairement, c’est certainement parce que le gain mérite l’effort initial de s’y mettre.

4.1.2/ Le temps pris à maintenir des sources de tests

Les tests unitaires correspondent à du code source.

Ce code est touché par tout refactoring sur le code de production C’est un code qui, du coup, doit être maintenu à chaque refactoring qui l’impose.

Lorsqu’on commence à poser des tests sur un code déjà existant, on se retrouve souvent confronter à du couplage fort, imposant. Chaque modification du code de production engendre systématique ou presque une modification du code des tests. Cependant, au fur et à mesure que la base de code testée augmentera, le couplage ira vers un état plus faible – parce que, justement, l’écriture de tests induit cette évolution. Aussi, la difficulté à maintenir les tests unitaires est importante au démarrage, bien qu’elle finisse par se lisser à terme – sans pour autant disparaître complètement.

 

4.2/ Les “plus” des tests unitaires

4.2.1/ Ils diminuent le temps de debug

Le temps qu’un développeur peut passer à débugger est inévaluable. Il faut pouvoir reproduire le bug, le traquer, le corriger. Potentiellement, cela générera des effets de bord qui ne seront pas repérés tant qu’un utilisateur ne tombera pas dessus. Bref, le “debug” sans avoir des moyens de tester la non régression est toujours un risque, en terme de planning et de fonctionnement de l’application.

Les tests unitaires ne suppriment pas le temps de debug, mais ils permettent de le réduire de manière intéressante:

  • si les méthodes sont testées, lancer les tests unitaires devraient pouvoir montrer clairement qu’une d’entre elles ne fonctionnent pas correctement, et on n’aura plus à traquer le bugs,
  • si la méthode n’est pas testée, il va falloir effectivement passer un peu de temps en debug, mais en écrivant immédiatement ensuite le ou les tests correspondant à la vérification du bug, on s’assure une non-régression par des modifications du code futures,
  • si la couverture par les tests est suffisante, même si on n’obtient pas un risque 0 de non-régression, on peut livrer un fix plus sereinement, sans trop craindre une régression par effet de bord.

La suppression du temps de debug est un mythe, clairement, cependant sa diminution est réelle.

4.2.2/ Ils améliorent et vérifient la non-régression

Cela ne concerne évidemment que les méthodes qui sont testées. Mais, plus la couverture augmente, moins les régressions passeront inaperçues. Même si on sait tous que “les régressions n’arrivent jamais”, ou “presque jamais”… Ou pas.

4.2.3/ Ils assurent la robustesse de l’application

Les tests doivent tester les cas favorables, mais également les cas défavorables. Cela permet de vérifier localement à une fonction que les exceptions sont bien levées, ou qu’elles sont bien traitées. On “sait” que l’application est robuste, et on limite les crashs qui sont difficiles à reproduire dans un environnement de développement, mais qui, potentiellement, peuvent arriver après une livraison, ou autre. Typiquement: on ne devrait jamais avoir des erreurs 500.

4.2.4/ Ils imposent un design à couplage faible, ou plus faible

Parce qu’une méthode est compliquée à tester si elle nécessite un nombre important de dépendances, écrire les tests – avant ou après – à un effet souvent bénéfique sur le couplage. On a le choix:

  • ne pas écrire le test
  • se forcer à réduire le couplage

Avec un peu de courage, le code bénéficie d’une meilleure lisibilité.

4.2.5/ Ils fournissent une documentation

Lorsqu’on travaille à plusieurs sur une même base de code – voir, lorsqu’on travaille sur un code qu’on n’a pas touché depuis des mois, la documentation des méthode est importante. Des exemples d’utilisation également. Et les tests unitaires sont autant d’exemples d’utilisation des méthodes. Quelque part, écrire des tests unitaires prend le même temps qu’écrire une documentation complète.

4.2.6/ Ils renforcent la confiance du développeur sur son code.

Cela peut paraître idiot, mais plus la couverture est importante, moins le développeur a besoin de se rappeler comment fonctionne telle ou telle partie, parce qu’il lui fait confiance. Cela découle des avantages précédents:

  • le code est robuste,
  • le code n’engendre pas de non-régression

Et, étant donné que le code fonctionne comme il le doit, plus besoin de se rappeler la manière dont il est écrit lorsqu’il faudra débugger l’application. Si les tests sont bien écrits, le code est sain.

Evidemment, les tests unitaires ne gèrent pas tous les cas de figure, ni tous les cas défavorables. Leur nombre peut grandir au fur et à mesure de l’implémentation et / ou des phases de debug. Mais il est plus facile de se rappeler ce qu’on a testé, que la manière dont s’imbrique un ensemble de composants qui produisent une erreur.

Quoiqu’il en soit, la confiance d’un développeur sur son code, c’est également la confiance de l’équipe sur ce code, et quelque chose d’important d’un point de vue de l’équipe projet.

4.3/ Bilan

Pour finir, je conclurai sur:

  • non, les tests unitaires ne font pas gagner de temps, ou du moins, pas forcément énormément. On diminue certes le temps de debug, mais vu qu’on a un double code à maintenir – production et tests – cela à une tendance à s’équilibrer. C’est d’autant plus vrai pour les premiers tests qui sont écrits: mais quelle prise de tête de penser à ce qu’on doit vraiment tester, et comment?
  • les tests unitaires sont un outil de test… IE: cela fiabilise le comportement de ce qui est testé, ici, les méthodes. C’est le contrat de base, et ce n’est déjà pas si mal. Si on part du principe que d’un point de vue du temps, c’est “gratuit”, autant s’y mettre non?

 

5/ Ok, j’accepterais bien de m’y mettre, sauf que mon code est I-N-T-E-S-T-A-B-L-E. J’ai des dépendances dans tous les sens / mon code est trop complexe pour que cela ne prenne pas trop de temps, …

La réponse facile d’une personne qui a l’habitude d’écrire des tests est: “Et alors?” Plus tôt, j’ai effectivement admis que le couplage fort pouvait augmenter la difficulté d’écrire des tests. Mais, plus tôt encore, j’ai également admis que “le niveau de complexité du code n’empêchant d’intégrer des tests unitaires (contrairement aux idées reçues)”.

Il faut bien garder cette idée en tête: on ne doit tester que la méthode. Il arrive souvent, si ce n’est tout le temps, qu’une méthode dépende d’autres méthodes. Du coup, lorsque l’on teste la méthode A, qui appelle la méthode B, quelle est le résultat du test? A-t’on testé la méthode A ou la méthode B? Et pire encore, la méthode B, pour pouvoir s’exécuter, à besoin d’une méthode C et d’une méthode D, qui ont elles-mêmes leurs propres dépendances.

Le principe de tests unitaires est brisé, puisqu’on ne sait plus quel unité on aura testé. Donc, de toute manière, il faut trouver un moyen de ne pas avoir à faire appel à l’implémentation concrète de B dans le TestCase.

“Heu, comment?”. Par des “Fakes”. On en vient à la notion de stubs et de mocks. Ces deux notions permettent d’éviter d’avoir à instancier des implémentations concrètes des dépendances de la méthode testée. Ce, grace à la magie de la réflexion.

5.1/ Les stubs

On créera un stub lorsque la méthode A aura besoin du retour de la méthode B.

Voici un exemple en AS3. Désolé, je n’en ai pas pris en Java ou en .NET, mais la syntaxe est suffisamment proche… La création d’un stub peut varier selon le framework de test utilisé, mais le principe reste le même:

public class SendMoveLetterRequestCommandTestBis {
    [Rule]
    public var mocks : MockolateRule = new MockolateRule();
    [Mock]
    public var boardModel : IBoardModel;
    [Mock]
    public var deckModel : IDeckModel;
    [Mock]
    public var letterModel : ILetterModel;
    [Mock]
    public var currentTurnModel : CurrentTurnModel;
    [Mock]
    public var eventDispatcher : IEventDispatcher;
    [Mock]
    public var gameplayService : GameplayService;
    [Test]
    public function test_canMoveFromBoardToBoard () : void {
    
    // Setup data
    var sut : SendMoveLetterRequestCommand = new SendMoveLetterRequestCommand();
    var positionX : int = 2;
    var positionY : int = 3;

    // Setup stub
    stub(boardModel).method("getLetterAtPosition").args(2, 3).returns(null);
    stub(currentTurnModel).method("isPlayable").args(letterModel).returns(true);
    stub(currentTurnModel).getter("isActive").returns(true);
    sut.boardModel = boardModel;
    sut.deckModel = deckModel;
    sut.gameplayService = gameplayService;
    sut.currentTurnModel = currentTurnModel;
    sut.eventDispatcher = eventDispatcher;

    // Execute && assertation
    assertTrue(sut.isValideMove(letterModel, positionX, positionY));
    }
}

Maintenant, détaillons le code:

public class SendMoveLetterRequestCommandTestBis {

Rien de particulier, c’est le nom de la TestSuite

    [Rule]
    public var mocks : MockolateRule = new MockolateRule();

Cette ligne est propre à la gestion du framework Flexunit. Les autres frameworks n’utiliseront sûrement pas ce principe.

    [Mock]
    public var boardModel : IBoardModel;
    [Mock]
    public var deckModel : IDeckModel;
    [Mock]
    public var letterModel : ILetterModel;
    [Mock]
    public var currentTurnModel : CurrentTurnModel;
    [Mock]
    public var eventDispatcher : IEventDispatcher;
    [Mock]
    public var gameplayService : GameplayService;

Voilà la définition des variables qui seront stubbées.

En flash, on utilisera Mockolate pour le support des mocks et des stubs. Mockolate utilise de la réflexion pour générer à la volée des objets ayant la même implémentation que les variables publiques précédées de la métadonnée [Mock].

Il y a écrit “Mock” et non “Stub”, sûrement pour une raison de simplification de la part de Mockolate, mais nous utiliserons bien ces différents champs en tant que stub, dans notre exemple. Cela signifie que boardModel sera un objet contenant l’ensemble des méthodes définies dans l’interface IBoardModel. Ces “fausses” méthodes renverront les valeurs par défaut pour le type de retour (null en cas d’objet, 0 pour un int / number, …).

Stubber les méthodes permettra de définir d’autres valeurs retournées par l’invocation des méthodes.

Si maintenant on s’intéresse au corps de notre test:

    // Setup data
    var sut : SendMoveLetterRequestCommand = new SendMoveLetterRequestCommand();
    var positionX : int = 2;
    var positionY : int = 3;

Rien à en dire de particulier, on met en place l’objet qui contient la méthode que l’on souhaite tester. (Comme dit précédemment, “sut” est utilisé ici avec un “léger” abus de langage. Le véritable sut est la méthode “isValideMove”).

    // Setup stub
    stub(boardModel).method("getLetterAtPosition").args(2, 3).returns(null);
    stub(currentTurnModel).method("isPlayable").args(letterModel).returns(true);
    stub(currentTurnModel).getter("isActive").returns(true);

Ici, on “stub” les méthodes, à savoir, on définit les valeurs de retour. Les bibliothèques de stubs / mocks permettent la plupart de temps une grande liberté. Ici, par exemple, on définit que :

  • la méthode getLetterAtPosition de boardModel renverra null si elle est appelée avec les arguments 2 et 3. (oui, cette ligne est superflue… mais bon),
  • la méthode isPlayable de currentTurnModel renverra “true” si elle est appelée avec l’argument letterModel,
  • la propriété isActive de currentTurnModel renverra “true”.

Partie suivante:

    sut.boardModel = boardModel;
    sut.deckModel = deckModel;
    sut.gameplayService = gameplayService;
    sut.currentTurnModel = currentTurnModel;
    sut.eventDispatcher = eventDispatcher;

Là, cela dépend de l’implémentation de la classe. La manière dont est codée le client de Wordox – et surtout la bibliothèque Robotlegs – force à avoir un bon nombre de variables publiques (ou de propriétés publiques) pour faire appel à l’injection automatique de dépendance.

Ici, on fournit juste les différentes dépendances dont aura besoin la méthode isValideMove pour pouvoir fonctionner.

On ne teste pas “tout” ce que fait la méthode dans ce test, juste une partie de son comportement. C’est pourquoi tout n’est pas forcément stubber.

Et finalement:

    // Execute && assertation
    assertTrue(sut.isValideMove(letterModel, positionX, positionY));

C’est une assertion standard.

Cette notion de stub est un gain précieux. Effectivement, si on devait se baser sur les implémentations concrètes deIBoardModel et autres, qui ont elles-mêmes leurs propres dépendances, on se retrouverait facilement à devoir instancier le monde entier, juste pour pouvoir tester notre méthode. Ici, peu importe que les dépendances de la méthode “isValideMove” aient leurs propres dépendances: le framework Mockolate génère un objet qui nous en coupe complètement. (je me répète, c’est volontaire).

5.2/ Les mocks

Ok, les stubs permettent d’éviter de se prendre trop la tête lorsqu’on doit tester une méthode ayant des dépendances qui, récursivement, n’en finissent plus. Quid des mocks?

Les mocks peuvent être vus comme une extension des stubs. Non seulement ils fournissent une “fausse” implémentation pour une interface ou une classe, mais en plus, ils permettent au développeur d’effectuer des vérifications. Les plus classiques, sont “est-ce que cette méthode a bien été appelée lorsque j’ai lancé appelé mon sut?”.

Plus succinctement que pour la partie stub, voici un exemple de TestCase pour la partie mock:

    [Test]
    public function test_updateState() : void {
        var sut : AskForPlayerCommand = new AskForPlayerCommand();
        mock(stateMachine).setter("state")
            .arg(WordoxStates.WORDOX_ASK_TO_PLAY)
            .once();

        setMocks(sut);
        sut.execute();
    }

Ici, on cherche à vérifier que la méthode execute() de la commandAskForPlayerCommand modifie bien l’état de Wordox. Pour cela, on vérifie que le setter “state” de stateMachine (qui est une dépendance de notre sut) est bien appelé avec l’argument WordoxStates.WORDOX_ASK_TO_PLAY, une et une seule fois.

A la fin de l’exécution du test, le framework de mock effectuera cette validation.

5.3/ La naissance des “mockistes”

Assez rapidement, suite à l’arrivée du concept des mocks dans les tests unitaires, est née une “école de pensée” autour des mocks. Certains testeurs n’écrivent presque plus de tests classiques, par le biais d’assertions classiques, mais passent essentiellement par des mocks. Il y a des avantages et des désavantages
dans les deux cas.

Typiquement, se baser uniquement sur des mocks signifie connaître l’implémentation de ce qu’on teste. Tester avec des assertions ne nécessite de connaître uniquement l’interface exposée de ce que l’on teste. Le seul avis que j’émettrai par rapport à cela, est qu’il faut choisir la manière d’écrire des tests avec laquelle on est le plus à l’aise.

 

6/ OK! Mais ça me fatigue d’écrire à chaque fois mes setup et tear down!

Oui, c’est pénible de devoir à chaque fois écrire des setup et teardown, surtout quand le code est systématiquement le même. Hey! Comme vous ne serez pas les premiers à penser cela, les frameworks de tests comprennent – ou doivent comprendre pour pouvoir être exploitables – un certain nombre de métadonnées. Cela comprend le “before” et “after” qui seront exécutés respectivement à chaque instanciation de TestSuite et à chaque fin de TestCase.

 

7/ Allez, je me lance. Mais je suis perdu devant mon code: trop de chose à tester…

On ne doit pas tout tester non plus. Les tests unitaires sont un outil, et comme tout outil, mal utilisé il peut se révéler bien pénible. La manière dont on teste ainsi que le choix de ce qui est testé vient avec l’expérience des tests.

Mais on peut déjà sortir quelques pratiques qui seront assez communes: On ne teste pas les vues. Pourquoi? Parce que potentiellement elles changent tout le temps. Un pixel à gauche, une font un peu plus grande, etc… et le coût que représente la mise à jour systématique des tests n’en vaut pas le gain. Qui plus est, si le code est bien découpé et si les vues comprennent le moins de logique possible – ce n’est, malheureusement, pas le cas par exemple du client de la belote – les vues sont les éléments de code qui sont les plus faciles à tester à la main, lors des recettes.

  • On ne teste pas les getters / setters. A part si ceux-ci embarquent de la logique
  • On peut commencer par tester les modèles.
  • Puis les contrôleurs, ou les commandes, selon le pattern utilisé.
  • Puis les interactions avec les services.
  • Puis les médiateurs, ou vue-modèles selon le pattern.

 

8/ Ouverture sur les méthodologies…

…basées sur les tests unitaires. Il y a du bon, il y a du moins bon, mais ce n’est jamais perdu. Le sujet de cette présentation n’est pas l’XP programming ni le TDD – d’ailleurs, on aura réussi à parler des tests unitaires, de leurs avantages et inconvénients, ainsi que de leur mise en place, sans évoquer ces méthodes.

Par ailleurs, afin d’avancer de manière graduelle et non déprimante, il me semble plus intéressant de prendre l’habitude de poser quelques tests à chaque refactoring, ou lorsque le temps se présente, plutôt que de se lancer dans du full TDD / XP Programming. Mais… qu’est-ce que le TDD, par exemple?

On confond souvent les deux: écrire des tests unitaires et faire du TDD. Les deux doivent avoir la même vocation: faire en sorte que la couverture du code de production augmente, au fil de l’eau.

Écrire des tests unitaires, cela peut se faire sur du « temps libre », lorsqu’on en a l’envie / le courage.

Travailler en TDD, cela signifie penser systématiquement à ce qu’on doit implémenter, avant même de l’implémenter. Mais plus qu’une doctrine, cela doit rester un choix du développeur. Cela fait plus de 5 ans que j’écris des tests unitaires – que j’en motive l’écriture, et que j’admire les “préceptes de développement” de Martin Fowler (oui, je suis aussi un fanboy). Cependant, le premier projet sur lequel je travaille en TDD est wordox. Alors…

8.1/ Pourquoi avoir du mal à apprécier le TDD?

Le TDD nécessite une réflexion en amont, et recule d’autant le temps où on pourra présenter / livrer quelque chose. Cela peut paraître décevant pour un chef de projet, mais également pour l’ego du développeur – la majeure partie des développeurs que je connais cherchent plus à être des lièvres, et à livrer très rapidement.

Oui, le TDD est de prime abord décevant, surtout lorsqu’on n’a pas l’habitude d’écrire des tests unitaires: on perd beaucoup de temps pour un gain risible. Aussi je ne conseillerai cette méthode qu’aux personnes qui ont pris l’habitude d’écrire des tests, afin, justement, de limiter ce côté négatif. Cela ne sert à rien de se lancer dans le TDD dès le démarrage.

8.2/ Qu’apporte le TDD?

Ok. Mais du coup, le TDD, en dehors d’être décevant, ça sert à quelque chose? Oui. Une fois acquise l’aisance dans l’écriture des tests – et le renversement de la pensée (je ne pense pas à comment je dois faire, mais à qu’est-ce que je dois renvoyer), le tdd apporte, entre autre par le biais des IDE, une vitesse de programmation accrue. Parce qu’au final, il est plus simple de penser à ce dont on a besoin qu’à la manière dont on le fera.

8.3/ Le flot du TDD

Qui plus est, cela permet d’avancer par étapes claires, et non de se lancer dans une implémentation qui bien rapidement deviendra confuse. En voici le détail:

  • le point de départ est, à priori, le besoin d’implémenter une fonctionnalité.
  • on définit les interfaces dont on aura besoin pour cette fonctionnalité. Inutile de poser immédiatement des classes concrètes. Tout développement peut se baser sur des interfaces qui seront par la suite plus simple à manipuler (ce sujet est hors scope, donc je n’y ferai pas plus référence).
  • on la crée la TestSuite associée, ainsi que l’ensemble des TestCase qui correspondent aux différentes méthodes de l’interface. J’ai bien précisé qu’on peut commencer par créer l’interface avec ses premières méthodes, parce que c’est plus simple, quoiqu’il en soit, d’avoir une première base.
  • au fur et à mesure des de l’écriture des TestCases, on se rendra compte qu’il nous manque des méthodes, des dépendances. Pas de soucis: on écrit directement ce qu’il nous manque dans les TestCases, comme si on les avait déjà. Cette approche permet de définir les spécifications des sut en fonction de la manière dont on les utilise et non par un travail d’imagination pouvant partir trop loin par rapport au besoin réel.
  • normalement, après cette étape, l’IDE devrait afficher pas mal de rouge, étant donné que les TestCase appellent des méthodes et des propriétés qui ne sont pas définies dans l’interface. Là, on remerciera les IDE modernes: il suffit juste de générer automatiquement ce qu’il nous manque. On peut remarquer, déjà, qu’on n’a pas eu non plus à réfléchir aux signatures de ce qu’il nous manquait: ce qu’il nous manque est défini par les tests.
  • il ne reste plus qu’à poser les implémentations.

8.4/ Parce que l’optimisation précoce est comme un mariage trop jeune: un pas vers l’échec…

Sujet qui est également hors scope…. ceci dit, on considérera un principe simple: la première chose importante est que le code fonctionne. Et ensuite, on optimise. Pour cela, les tests unitaires en général, mais bien plus encore le TDD, permettent d’avancer progressivement et sans risque.

Une fois que les tests sont écrits, on peut vérifier à n’importe quel moment que le code correspond bien à ce que l’on souhaite. Refactorer en vue d’une optimisation est ensuite facilité, tout simplement parce qu’une optimisation devrait presque toujours laisser invariante la sortie d’une méthode. Et on pourra s’en assurer, systématiquement, en relançant les tests après chaque modification des implémentations. Vive la non régression!

8.5/ Le TDD et les mocks

Le principe du TDD est de se baser uniquement sur les interfaces. Le principe des mocks est de valider des appels effectués par une implémentation. Cela peut parfois rentrer en conflit. Aussi, faire de TDD diminue parfois le besoin de faire appel aux mocks.

9/ Références

http://www.simple-talk.com/dotnet/.net-framework/unit-testing-myths-and-practices/ (article du 05/01/2012) : Un article très intéressant, dénonçant les deux grands mythes principaux des tests unitaires. Contrairement aux apparences, ce n’est pas un article cherchant à dévaloriser les tests unitaires, mais au contraire à recadrer leur but et la limite de leur utilité. Il s’agit par ailleurs d’un billet classé dans la rubrique .NET de ce blog.

http://martinfowler.com/articles/mocksArentStubs.html (article du 02/02/2007) : Un article parlant des mocks, et le choix entre tests classiques par assertions, ou par mockage.

Test Driven Development: By Example. http://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530 : Excellent livre de Kent Beck sur le TDD. Simple à aborder, plein d’exemples concrets.

Refactoring: Improving the Design of Existing Code. http://www.amazon.com/Refactoring-Improving-Design-Existing-Code/dp/0201485672/ref=pd_sim_b_3 : Livre déjà acheté par IsCool. Des méthodes de refactorings, avec une introduction assez longue explication comment modifier du code en évitant de créer des bugs fonctionnels.

Working Effectively with Legacy Code. http://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052/ref=pd_sim_b_6 . Très bon livre sur “Oh! Du code déjà existant (que je n’ai pas écris, ou alors j’étais bourré ce jour-là) et qui est horrible… Qu’est-ce que j’en fais?”

Extreme Programming Explained: Embrace Change. http://www.amazon.com/Extreme-Programming-Explained-Embrace-Change/dp/0201616416/ref=sr_1_14?s=books&ie=UTF8&qid=1326368411&sr=1-14 Livre quasi “originel” de Kent Beck, sur la méthode qu’il a inventé en 1996, en tant que chef de projet. Caricaturalement, XP + lean = SCRUM.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *