Les tests unitaires

Qu’est-ce qu’un test ?

Un test est une fonction particulière, écrite dans un fichier particulier, dans un dossier dédié aux tests (généralement nommé “Tests” et placé à la racine d’un projet en cours de développement). Il s’agit ici de définir une suite de règles qui doivent s’appliquer à une fonction de notre choix présente dans un projet et s’assurer de son bon fonctionnement, que ce soit maintenant ou dans deux ans. Le principe consiste à comparer les résultats escomptés avec les résultats obtenus.
Cela peut sembler être insignifiant expliqué ainsi, mais les tests peuvent accomplir des miracles comme empêcher des projets de partir à la dérive et finir par couler. Vous verrez dans la suite pourquoi.

Il existe plusieurs types de tests :

  • Les tests unitaires
  • Les tests fonctionnels

Les tests unitaires permettent de tester une fonction particulière et de s’assurer de son bon déroulement. Le principe consiste à choisir une fonction critique, la tester sous tous les angles et comparer les résultats avec ceux attendus. L’objectif ici étant d’assurer la pérennité du projet grâce au fait que peu importe les changements apportés à travers les évolutions futures, tant que ce test reste au vert, la méthode associée continue de fonctionner normalement et sans régression.

Exemple : Si j’ai développé une fonction qui prend en entrée un ID et me retourne un utilisateur en sortie, mon test unitaire va tester que je reçois bien un utilisateur si je passe l’ID 1, que je reçois NULL si je passe l’ID NULL (et donc que le système ne crash pas), etc…

Les tests fonctionnels, en opposition aux tests unitaires, s’assurent du bon déroulement d’un processus. Il s’agit ici de contrôler une suite d’étapes cruciales et s’assurer qu’il n’y ait pas d’erreur.
Exemple : Si j’ai développé une page qui permet d’afficher un profil d’utilisateur en fonction de l’ID donné dans l’URL, je vais alors tester cette url avec l’ID 1 et m’assurer qu’il s’agisse bien du profil de l’utilisateur 1 qui s’affiche à l’écran. De la même manière que je vais tester cette URL avec un ID vide ou un ID inexistant et m’assurer que je reçois bien une erreur 404.

Le pour et le contre

Lorsque l’on développe un site ou une application, il est vivement recommandé d’écrire des tests pour assurer le bon fonctionnement d’une méthode ou d’une action.
Malheureusement, trop nombreux sont les développeurs qui ignorent cette best practice. Les excuses sont nombreuses et souvent justes :

  • C’est (très) long
  • C’est laborieux
  • Il faut les réécrire dès lors que l’on change le comportement d’une fonction
  • La flemme (excuse la plus entendue à ce jour)

Mais malgré cela, les tests unitaires peuvent aussi se montrer si utiles qu’ils peuvent sauver des projets, et de plusieurs manières :

  • Encadrer l’évolution d’un projet
  • Protéger des régressions à venir
  • Définir explicitement les utilisations possibles du projet
  • Prémunir des cas d’utilisation invalides (ces utilisations pas prévues qui font crasher le site en prod)

Quand s’y mettre ?

Les bonnes pratiques recommandent de toujours développer un test par fonction développée et de toujours écrire le test avant la fonction (test qui échouera donc jusqu’à ce que la fonction soit terminée). Néanmoins, je dois avouer moi-même qu’il m’arrive souvent de changer totalement d’idée en cours de route, ce qui ne serait pas très pratique si j’ai déjà écris les tests à l’avance. C’est pourquoi, bien que rédiger les tests avant soit une très bonne chose, je préfère les écrire après, une fois que le système fonctionne parfaitement.

Il est en général recommandé de commencer à rédiger des tests dès la début du projet. Pour chaque nouvelle fonction, on ajoute un nouveau test. Néanmoins, la rédaction de tests à un cout et pas des moindres. Il faut en moyenne compter entre 10% et 20% du temps qui était alloué pour créer une tâche, pour rédiger les tests qui vont avec, ce qui rallonge la durée de développement du projet.

L’exemple par la pratique

A présent, imaginons que nous développions un site e-commerce. Il serait avisé de tester les différentes fonctions liées à l’ajout d’un produit au panier. Ainsi nous aurions les tests unitaires suivants :

  • testGetProduct
  • testGetCart
  • testAddProductToCart

Pour tous les exemples suivants, j’utiliserai les fonctions mise à disposition dans le framework Symfony. Il existe de nombreuses solutions proposant des outils pour faire des tests unitaires et presque chacune d’entre elles ont des fonctions et des syntaxes similaires. Mon choix dans cet article se portera sur Symfony et son implémentation de PHPUnit (librairie de test en PHP).

Pour la fonction de test testGetProduct, qui devra tester la manière dont on reçoit un produit, voici un exemple de code utilisable :

public function testGetProduct() {
    $this->assertInstanceOf(
        'Product',
        $this->catalogService->getProduct(1)
    );
    $this->assertNull(
        $this->catalogService->getProduct(null)
    );
    $this->assertNull(
        $this->catalogService->getProduct(9999)
    );
    $this->assertNull(
        $this->catalogService->getProduct('banana')
    );
 }

Dans cet exemple, nous testons tous les cas d’utilisation imaginable de notre fonction getProduct() :

  • D’abord la manière valide, à savoir avec un ID numérique et existant, qui DOIT retourner un objet résultat de type “produit”, et pas un tableau ou autre chose dans le même genre d’idée
  • Ensuite le cas où il n’y aurait pas d’ID fourni, ce qui serait fort probable d’arriver si l’ID est manquant dans l’URL et qu’il n’y a pas (ce n’est pas bien) de test au préalable, mais on test quand-même juste pour garantir le comportement et écrire noir sur blanc que pas d’ID égal pas de résultat (et non un objet vide ou pire, un crash)
  • Puis vient le cas d’un ID inexistant, comme par exemple si un petit malin s’amuse à changer l’ID dans l’URL
  • Enfin, on ne sait jamais, si par hasard notre utilisateur a une petite faim et aimerais mettre un texte à la place de l’ID, juste nous assurer que ça ne fasse pas crasher tout le site…

Concernant la fonction testGetCart, elle s’assurera que la fonction getCart() retourne bien un panier. Cela parait évident et donc inutile à tester, mais si dans 3 mois quelqu’un décide de remplacer l’objet “panier” par un tableau, cela aura un impact sur toutes les fonctions du site qui touchent de près ou de loin au panier. Alors il vaut mieux écrire noir sur blanc que le site entier s’attend à ce que getCart() retourne un objet de type “panier” et que si l’idée lui prend de vouloir changer le type de retour c’est à ses risques et périls.

public function testGetCart() {
    $this->assertInstanceOf(
        'Cart',
        $this->catalogService->getCart()
    );
}

Dans cet exemple, nous effectuons simplement un seul test sur notre fonction getCart(), à savoir :

  • Nous confirmons qu’il s’agit bel et bien d’un objet de type “panier”.

Enfin, la fonction testAddProductToCart testera l’ajout d’un produit à un panier et vérifiera le bon comportement de ce système, que ce soir avec des données valides ou invalides.

public function testAddProductToCart() {
    $cart = $this->catalogService->getCart();
    $product = $this->catalogService->getProduct(1);

    $this->assertTrue(
        $cart->addProduct($product)
    );
    $this->assertFalse(
        $cart->addProduct(null)
    );
    $this->assertFalse(
        $cart->addProduct(1)
    );
    $this->assertFalse(
        $cart->addProduct('banana')
    );
}

Dans cet exemple, nous contrôlons tous les cas d’utilisation imaginables pour l’ajout d’un produit dans un panier :

  • D’abord on s’assure du bon fonctionnement de la fonction, tel qu’elle est censée être utilisée, c’est à dire en lui donnant un produit et espérant qu’elle retourne TRUE.
  • Ensuite on s’assure que la fonction retourne bien FALSE si on ne lui donne pas de produit
  • Puis on s’attend aussi à recevoir FALSE si on donne l’ID du produit plutôt que le produit lui-même
  • Enfin, on ne sait pas trop comment cela pourrait arriver mais il vaut mieux s’assurer que la fonction addProduct se comporte correctement avec une chaine de caractères au lieu du produit

Maintenant que nous avons testé les méthodes, il faut tester le processus d’ajout d’un produit à un panier via l’utilisation d’un test fonctionnel.

Ce type de test est plus vague dans sa réalisation car il dépend à la fois du langage utilisé, de l’outil utilisé pour les tests et de la manière d’exécuter le processus. Ici nous allons encore partir sur le système de test de Symfony avec leur crawler qui va appeler différentes URL pour simuler le chemin à emprunter pour ajouter un produit à son panier.

public function testAddProductToCart() {
    $client = static::createClient();
    $client->followRedirects( true );

    $productPage = $client->request( 'GET', '/products/1' );
    $addToCartLink = $productPage
                         ->filter( 'a#add-to-cart' )
                         ->link();

    $cartPage = $client->click( $addToCartLink );

    $banana = $cartPage->filter( 'span:contains("Banana")' );

    $this->assertEquals( 1, $banana );
}

Ce test est un peu plus complexe que les précédents, mais nous allons le décortiquer ensemble :

  • D’abord, nous créons un “client” qui se chargera de parcourir des URLs du site et qui retournera le code HTML ce des pages
  • On en profite au passage pour lui dire qu’il doit suivre les redirections, s’il y en a
  • Maintenant on passe aux choses sérieuses en indiquant au client d’aller visiter la page d’un produit
  • Ensuite on recherche dans l’HTML le lien qui permet d’ajouter le produit au panier
  • On clique sur le lien (qui va ajouter le produit au panier avant de rediriger sur la liste des produits du panier)
  • On récupère toutes les balises <span> contenant le texte “Banana”
  • Et enfin on s’assure qu’il y ait bien une et une seule occurrence de ce <span> sur toute la page

Au final, si pour une raison quelconque, une des fonctions utilisées dans le processus d’ajout d’un produit dans le panier devait soudainement changer (erreur ou changement volontaire), que ce soit dans une semaine ou dans 2 ans, alors le ou les développeurs seront prévenus que cette partie du site est cassée, et pourront apporter les corrections nécessaires au site bien avant que celui-ci se retrouve en prod.

Les fixtures

Dans toutes mes explications précédentes, j’ai toujours testé que le produit avec l’ID 1 était un produit “Banana”. Mais ne serait-ce pas dangereux avec des données présentes dans la base de données et destiné à changer avec le temps ?
Réponse : Oui, même très dangereux. C’est pourquoi il ne faut jamais faire de test avec des données de prod.

Mais alors où sont les données ? C’est là qu’interviennent les fixtures.

Les fixtures sont des données de base, définies à l’avance et installables automatiquement avant chaque exécution des tests. Le but est d’avoir une base de données propre pour les tests, ne contenant que de données fixes, connues et définies à l’avance. Les différentes technologies proposent presque toutes des solutions similaires sous des noms différents, mais le principe reste le même : insérer des données de base dans la base de données.

Les mocks

Cela prendrait un article entier et dédié pour décrire les mocks, mais je vais résumer le principe en quelques mots.

Il arrive de temps à autre que des fonctions soient impossibles à tester, à cause de certaines méthodes ou de l’impossibilité d’exécuter le test de manière contrôlée. Dans ces cas, il est important d’utiliser des mocks pour simuler ces objets.

Imaginons qu’à un moment de l’exécution normale de l’application, il y ait un appel vers un autre site via une requête curl et que nous utilisions ce résultat dans la suite du processus.
Il serait inconcevable d’utiliser de telles données variables dans le cadre d’un test qui est censé être encadré. Nous allons dans ce cas créer un mock de notre objet indésirable qui sera identique à une exception près : au lieu de faire une requête curl il retournera directement un objet que l’on aura défini. Ainsi le côté variable et aléatoire est éliminé.

Il est inutile de tester un mock en lui-même, puisque nous définissons nous-mêmes les données qu’il retourne. Ce serait comme tester que 1 est bien égal à 1.
Par contre, si une méthode testée repose sur un intermédiaire à résultat variable (en général quelque chose qui retourne un résultat récupéré depuis l’extérieur), il est primordial de “mocker” cet intermédiaire.

Conclusion

Comme vous avez pu le voir, les tests unitaires sont à la fois un sujet très complet (je me suis limité aux principes de base), c’est long, mais c’est aussi très important pour assurer et encadrer un projet. Ils permettent de surveiller les évolutions à venir et servent de garde-fou pour se protéger des régressions et utilisations détournées et risquées de nos applications et sites internet.

Alors la prochaine fois que vous réaliserez un projet, n’oubliez pas de protéger vos arrières et prenez un peu de temps pour rédiger quelques tests, ça vous sauvera peut-être la mise ?

 

Michael Barbey

Développeur WAX Interactive Suisse

0 commentaires

votre commentaire

Se joindre à la discussion ?
Vous êtes libre de contribuer !

Laisser un commentaire

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

Inscription newsletter

Ne manquez plus nos derniers articles !