Karim Ammor
Architecte logiciel chez Enablon, leader mondial dans l'édition de logiciels orientés développement durable, environnement et gouvernance d'entreprise.
Anciennement tech lead chez Meetic
Accompagner ses développements de tests automatisés est de plus en plus courant.
C'est en effet une bonne pratique de programmation, partie intégrante du cycle de développement d'une application.
Cet article a pour but de repositionner les bases de cette pratique, approfondir les tests unitaires et vous présenter les autres types de tests automatisés.
Si vous développez déjà en suivant les principes de TDD et/ou BDD, allez directement plus loin. Dans le cas contraire, bonne lecture !
Il existe plusieurs types de tests automatisés.
On en représente à minima trois :
L'idée est qu'un test système (ou test fonctionnel) coûte cher à développer et à maintenir.
Il a un vrai intérêt puisqu'il permet de valider que le code produit répond au besoin utilisateur, mais il sera également assez long à exécuter puisqu'il fait tourner l'application dans son intégralité, dépendances (base de données, service d'envoi de mail, api externes, etc) incluses.
Le test d'intégration permet de valider que toutes les méthodes/classes d'une application interagissent bien entre elles, sans ces dépendances.
On simulera l'insertion en base via un mock par exemple.
Enfin, le test unitaire a pour but de tester de façon très ciblée la plus petite portion de code possible : un use case d'une méthode par exemple.
Ici toutes les dépendances seront remplacées par des mocks.
Chaque test a un intérêt, et un coût, différent. Le but n'est donc pas de choisir l'un au détriment des autres mais de les utiliser de manière complémentaire pour couvrir au mieux notre application.
C'est la combinaison des différents types de test qui nous permettra de nous assurer qu'un nouveau développement :
Nous verrons par la suite qu'il existe d'autres types de test, qui répondent à d'autres problématiques.
Focalisons-nous pour le moment sur la base de notre pyramide : le test unitaire.
On peut définir trois critères importants pour un test unitaire :
Certaines classes sont dédiées à la coordination des traitements (par exemple les contrôleurs Symfony). Ces classes ont beaucoup de dépendances mais ne portent aucune logique métier et sont très simples.
Il y a peu de chances qu'une régression apparaisse à ce niveau, et il y a par contre de grandes chances pour que le test échoue sans qu'il n'y ait de régression.
Les tests unitaires de ces classes vont être longs à écrire, potentiellement compliqués à cause des dépendances, et rarement échouer pour une bonne raison. Couvrir ces classes avec des tests unitaires n'a donc que peu d'intérêt.
D'autres portent une réelle logique métier (par exemple un validateur d'e-mail).
Le test unitaire aura une réelle valeur lorsqu'il est appliqué à ces classes – qui sont normalement suffisamment isolées pour être testées avec pas, ou très peu, de mocks.
Il n'est pourtant pas toujours simple de définir si une classe peut, ou doit, être testée unitairement.
En règle générale : si une classe porte de la logique métier et n'est pas aisément testable, c'est souvent le signe que cette classe porte trop de responsabilités et sera donc rapidement difficile à maintenir/faire évoluer. Prenez alors du recul et voyez s'il n'est pas possible de la re-factorer
Si ce n'est pas le cas, peut-être que des tests d'intégration sont plus pertinents ? Le test sera moins ciblé mais permettra malgré tout de couvrir le cas fonctionnel défini.
Il est également parfois plus coûteux d'écrire certains tests que de ne pas le faire : appliquez la règle des 80/20 et n'écrivez pas de test s'il ne couvre pas les trois critères cités ci-dessus !
Partir du cas fonctionnel est indispensable, surtout lorsqu'on écrit son test après son code, pour éviter de tester l'implémentation au lieu de la fonctionnalité : le but est de valider que notre code répond à un besoin, un cas fonctionnel spécifique.
De plus, repartir du cas fonctionnel nous fait prendre du recul et mieux réfléchir à tous nos cas de test – dont certains qu'on n'aura peut-être pas vu en premier lieu.
Prenons l'exemple d'une route permettant l'ajout de commentaires :
class CommentController {
public function sendComment(string $payload) {
$comment = $this->serializer->unserialize($payload);
$this->sendCommentHandler->handle($comment);
}
}
class Comment {
public function __construct(string $body) {
if ($body === '') {
throw new EmptyBodyException();
}
$bodyLength = strlen($body);
if (strlen($body) > self::MAX_BODY_LENGTH) {
throw new TooLongBodyException($bodyLengt, self::MAX_BODY_LENGTH);
}
$this->body = $body;
}
}
Le contrôleur ne fait que de la coordination : le tester unitairement nous assurera que les appels sont faits dans le bon ordre, mais pour ça on serait obligés de définir un mock pour le serializer
, un autre pour le handler
. Il n'y a aucune logique métier, peu de chance de voir le test échouer en cas de régression, et de fortes chances de le voir échouer sans qu'il n'y ait de bug – si je rajoute une dépendance par exemple.
L'objet Comment
, par contre, porte une logique métier – le corps de mon commentaire ne peut pas être vide et ne doit pas faire plus de X caractères. Il s'agit d'une règle qui a été définie avec le P.O et est certainement inscrite dans les user acceptances. On va donc la tester.
Pour cela, on peut écrire ces méthodes de tests très simples :
class CommentTest {
public function providerCommentBodyOutOfRange()
public function testCommentShouldThrowAnExceptionWhenBodyLengthIsOutOfRange($body, $expectedException)
public function testCommentShouldBeCreatedWhenBodyLengthIsWithinRange()
}
J'ai considéré ici que « body is empty » et « body is too long » sont deux variations d'un seul et même cas d'usage fonctionnel : « body length is out of range ». L'effet de bord est que ça permet de mutualiser du code.
Faites des méthodes spécifiques à chaque cas de test, quitte à regrouper le code commun (pour ne pas avoir de duplication de code) dans une méthode privée.
L'intérêt est d'avoir des tests très clairs, qui indiquent de manière simple (avec un nom de méthode très explicite) les responsabilités de la méthode testée.
Prenez autant soin de vos tests que du reste de votre code (voir même plus), vous gagnerez en lisibilité comme en maintenance !
La méthode la plus simple et la plus commune est de regarder le code coverage.
Il s'agit d'un indicateur qui mesure le nombre de lignes de code exécutées lorsqu'on lance nos tests unitaires. On sait alors quel est le pourcentage de code exécuté et on isole rapidement les lignes de code qui ne le sont pas.
Cette méthode, quoique utile pour avoir une première idée et détecter les zones non couvertes, ne permet pas de vérifier la qualité de nos tests.
En effet, elle se base sur le nombre de lignes exécutées, et ne vérifie pas les assertions faites ni ce qui est réellement testé.
Imaginons une méthode d'addition comme suit.
public function add(int x, int y): int {
return x + y;
}
Le code coverage sera à 100% avec la méthode de test suivante :
public function testAdd() {
add(2, 3);
}
Ma méthode de test ne teste pourtant pas grand-chose, et n'échouera jamais, contrairement à celle-ci :
public function testAdd() {
$result = add(2, 3);
$this->assertEqual(5, $result);
}
Ces exemples sont très simples pour faciliter la compréhension, la réalité est souvent bien plus complexe.
C'est pourquoi il est important de s'assurer de la qualité de nos tests. Vont-ils bien échouer en cas de régression ?
Tester une seule et unique assertion par test permet d'obtenir des tests simples et bien ciblés, il sera donc plus facile de détecter une erreur dans nos tests. Ce n'est toutefois pas toujours simple, et très difficilement mesurable de manière objective.
C'est là qu'interviennent les tests de mutation.
Le principe : faire muter le code couvert par des tests unitaires, puis jouer les tests et compter le nombre de mutants qui n'ont pas été détectés.
Quelques exemples de mutation :
Dans notre cas, la première méthode de test (qui ne teste rien) n'échouera pas si on transforme l'addition en soustraction. La deuxième, par contre, échouera (et permettra de détecter la mutation).
La librairie Humbug permet de le faire en PHP.
Attention toutefois : il s'agit de tests plus longs à s'exécuter. Il est donc conseillé de ne pas les faire tourner à chaque changement de code mais les utiliser plutôt comme un indicateur de qualité plus global (à faire tourner une fois par jour sur le master par exemple).
De même, cette méthode reste limitée aux lignes couvertes par vos codes.
Une utilisation intelligente du code coverage combinée à des tests de mutation vous donnera une mesure assez pertinente de la qualité de vos tests.
Nous avons vu un peu plus en détail les tests unitaires, qui vous permettent de valider que chacune des méthodes que vous développez rempli bien la responsabilité qui lui a été attribuée.
Les tests d'intégration vous permettent de vous assurer que toutes vos méthodes s'intègrent bien et qu'il n'y a pas d'incompatibilité entre elles.
Les tests système vous permettent de vérifier que votre application s'intègre bien avec les autres systèmes (base de données, API externe, etc…)
Ces trois méthodes de test sont souvent complétées par d'autres, plus poussées :
La plus commune : les tests d'UI (pour User Interface), permettent de valider un scénario utilisateur, en simulant les actions d'un utilisateur sur l'interface de l'application.
Selon le cas, vous aurez potentiellement besoin de mettre aussi en place des tests de performance. Ceux-ci vous permettront de valider que les nouveaux développements n'ont pas eu d'impact négatif sur le temps de réponse de vos APIs / pages.
Vous pourrez avoir aussi besoin d'effectuer des tests de montée en charge : le but est de simuler l'activité d'un grand nombre d'utilisateur sur votre application pour détecter les problèmes potentiels (accès concurrents, goulots d'étranglement, fuites mémoire, …) et les optimisations nécessaires pour supporter la charge prévue sans risquer de ralentissement/d'interruption de service.
La seconde possibilité n'est toutefois valable que pour l'existant : il vous faudra tout même utiliser la première possibilité pour les fonctionnalités qui ne sont pas encore déployées en production.
Enfin, vous pouvez vous inspirer de la chaos army de Netflix pour aller plus loin dans vos tests, notamment pour vous assurer de la résilience de votre production.
Ce n'est pas toujours simple de se lancer dans les tests automatisés : il faut prendre le temps de se former, comprendre la philosophie derrière chaque type de test pour choisir le test adéquat en fonction de la situation, avoir un code suffisamment propre et découplé, mettre en place les outils nécessaires (en local ou via un serveur d'intégration continue), ...
Il faut parfois convaincre nos amis du produit (ou nos clients) que non, ce n'est pas du temps perdu, même si au démarrage ça augmente le coût des nouveaux développements.
Toutefois, tester proprement une application a plusieurs effets positifs :
Sauf si vous êtes payés pour faire de la TMA ou que votre site ne restera pas plus d'un mois en ligne (et encore), les tests automatisés sont vos amis. J'espère donc que cet article vous aura aidé à y voir plus clair.
Vous avez des remarques, des questions ou d'autres outils à conseiller ? N'hésitez pas à commenter cet article !