PHP

Introduction aux tests unitaires et au TDD en PHP (sans framework)

Comprendre les tests unitaires : Les 2 principes de base, sans framework ou concept complexe, illustrés avec un exemple pratique.

A quoi servent les tests unitaires ?

Les tests unitaires, tu en as sûrement déjà entendu parler, mais tu ne comprends pas bien l’intérêt. Et puis ça a l’air compliqué : Framework de test, PHPUnit, Pest, Fixtures, Mock, etc. Un tas de nouvelles notions alors que tu n’as pas encore compris à quoi ça servait en premier lieu…

« Mais… mon code… bien sûr que je l’ai testé ! » On en doute pas ! Tu as créé ta fonction, tu l’as appelée avec des arguments, tu as vérifié qu’elle retournait le résultat attendu. Tu as créé une page web avec un formulaire, saisi le formulaire, vérifié en base de données que tout était bien enregistré. Etc.

Mais en fait, tu l’as testé 1 fois, à un moment précis de ton process de développement. Lorsque tu ajoutes de nouvelles fonctionnalités, tu as quelques idées des impacts possibles sur le code existant. Donc tu retournes tester le code « à côté » de ces nouvelles fonctionnalités, pour vérifier que tout fonctionne comme avant. Déjà là, tu vas retester quelque chose que tu avais déjà testé avant… Puis ton site, ton application va grandir, et sans t’en rendre compte, tu vas passer à côté de certains impacts. Et c’est normal. Quand on commence à avoir 20, 30, 50 fonctionnalités, voir beaucoup plus, il devient impossible de déduire instinctivement TOUS les impacts que va avoir le développement d’une nouvelle fonctionnalité, ou la mise à jour d’une fonctionnalité existante.

C’est là que les tests unitaires sont très puissants. Tu écris le teste de chaque morceau de ton code. Comme ça, dès que tu fais évoluer quelque chose, tu relances automatiquement l’ensemble des tests produits, en quelques instants. Tu détectes donc tout de suite ces fameux « impacts » ou « effets de bord » sur une autre fonctionnalité, qui est plus ou moins proche de ce que tu es en train de développer. Et tu évites donc de très nombreux bugs et mésaventures pour les utilisateurs de ton site ou de ton application.

Cet article va présenter quelques concepts, sans frameworks, pour montrer ce qu’est un test unitaire, et te donner l’envie d’en écrire quelques-uns dès maintenant.

Écrire un jeu de tests avant d’écrire une fonction

On parle souvent de TDD = Test Driven Developpement. Ça veut dire que ce sont les tests qui dirigent l’écriture du code. Sans rentrer dans les détails techniques et précis, cela signifie surtout d’écrire un jeu de test avant de commencer à coder.

Illustrons ça avec un peu de code. Pour cela, je vais m’appuyer sur ce challenge de code, autour d’Harry Potter qui cherche à produire des potions. Selon une recette et des ingrédients à disposition, Harry peut ou pas produire certaines potions.

La potion de foudre nécessite ces ingrédients :

  • 2 nuages ténébreux
  • 1 poussière de fée

Quels tests je vais pouvoir écrire ?

  • Si j’ai ces 2 ingrédients, dans les quantités requises, je peux produire 1 potion.
  • Si j’ai ces 2 ingrédients, mais le premier dans une quantité insuffisante, je ne peux pas produire de potion.
  • Si j’ai ces 2 ingrédients, dans les quantités X fois requises, je peux produire X potions.
  • S’il me manque l’ingrédient 1, je ne peux pas produire de potion.
  • S’il me manque l’ingrédient 2, je ne peux pas produire de potion.
  • Si j’ai des ingrédients autres, je ne peux pas produire de potion.
  • Si je n’ai aucun ingrédient, je ne peux pas produire de potion.
  • Si j’ai beaucoup d’ingrédients 1, mais peu d’ingrédients 2, l’ingrédient 2 est limitant sur le nombre de potions.
  • Si j’ai beaucoup d’ingrédients 2, mais peu d’ingrédients 1, l’ingrédient 1 est limitant sur le nombre de potions.

Bon, pour un premier exemple, on démarre un peu fort avec 9 tests possibles pour notre fonction. Mais au moins j’ai pensé à tous les cas possibles (enfin je crois…). Et si ma fonction répond à tous ces cas de figure, je pense qu’elle sera pas mal ! Voici les tests, mis en forme dans un tableau PHP :

$tests = [
	[
		'nom' => '2 ingrédients, dans les quantités requises',
		'ingredients' => ['nuages_tenebreux' => 2, 'poussiere_fee' => 1],
		'potions' => 1
	],
	[
		'nom' => '2 ingrédients, mais pas assez du premier',
		'ingredients' => ['nuages_tenebreux' => 1, 'poussiere_fee' => 1],
		'potions' => 0
	],
	[
		'nom' => '2 ingrédients, dans les quantités X fois requises',
		'ingredients' => ['nuages_tenebreux' => 8, 'poussiere_fee' => 4],
		'potions' => 4
	],
	[
		'nom' => 'manque l\'ingrédient 1',
		'ingredients' => ['poussiere_fee' => 1],
		'potions' => 0
	],
	[
		'nom' => 'manque l\'ingrédient 2',
		'ingredients' => ['nuages_tenebreux' => 2],
		'potions' => 0
	],
	[
		'nom' => 'ingrédients autres',
		'ingredients' => ['queue_lezard' => 5, 'eau_jouvence' => 2],
		'potions' => 0
	],
	[
		'nom' => 'Aucun ingrédient',
		'ingredients' => [],
		'potions' => 0
	],
	[
		'nom' => 'Beaucoup ingrédient 1',
		'ingredients' => ['nuages_tenebreux' => 250, 'poussiere_fee' => 1],
		'potions' => 1
	],
	[
		'nom' => 'Beaucoup ingrédient 2',
		'ingredients' => ['nuages_tenebreux' => 2, 'poussiere_fee' => 120],
		'potions' => 1
	],
];

C’est un tableau de tableaux. 3 éléments :

  • Le nom du test, pour savoir ce qu’on est en train de tester
  • Les ingrédients utilisés
  • Le nombre de potions attendues

Voici maintenant une première version de ma fonction :

function nbPotionsFoudreRealisables(array $ingredients): int
{
	return 0;
}

L’idée ici est vraiment d’avoir une première version, « basique », qui me permet de lancer mes tests :

foreach ($tests as $test) {
	if ($test['potions'] === nbPotionsFoudreRealisables($test['ingredients'])) {
		echo '<h6 style="color: green;">Test : '. $test['nom'] .' OK</h6>';
	} else {
		echo '<h6 style="color: red;">Test : '. $test['nom'] .' KO</h6>';
	}
}

Un simple foreach, je parcours $tests, et je vérifie la correspondance entre l’attendu et le résultat de la fonction. Si l’égalité des valeurs est vérifiée, j’affiche en vert que c’est OK. Si ce n’est pas OK (=KO) j’affiche en rouge. Et c’est tout ! Pas de framework dédié ou autre. Voici l’affichage :

Résultats de mes premiers tests

C’est formidable, j’ai déjà 5 tests qui sont verts :p Maintenant l’objectif c’est de passer tout en vert !

Je code jusqu’à ce que tous mes tests unitaires soient verts !

Une première version possible pour la fonction :

function nbPotionsFoudreRealisables(array $ingredients): int
{
	$recette = [
		'nuages_tenebreux' => 2,
		'poussiere_fee' => 1
	];

	$nbPotionsParIngredient = [];

	foreach ($ingredients as $ingredient => $quantite) {

		if (isset($recette[$ingredient])) {
			$maxPourCetIngredient = floor($quantite / $recette[$ingredient]);
			$nbPotionsParIngredient[] = $maxPourCetIngredient;
		}
	}

	if (count($nbPotionsParIngredient) < count($recette)) {
		return 0;
	}

	return min($nbPotionsParIngredient);
}

Quelques explications :

  • La recette fait partie de ma fonction.
  • Je vais chercher à savoir, pour chaque ingrédient, combien je peux faire de potion(s).
  • Je parcours donc tous mes ingrédients.
  • Si je trouve l’ingrédient dans ma recette, je calcule combien de potion(s) je peux réaliser pour l’ingrédient courant.
  • Si j’ai 2 ingrédients dans ma recette, je dois avoir trouvé 2 valeurs, sinon il me manque au moins 1 ingrédient et donc je retourne 0.
  • Je retourne le minimum des potions réalisables pour un ingrédient car c’est la plus petite valeur qui détermine le nombre de potions réalisables.

Et voilà :

Que du vert

Ça y est ! Ma fonction est correcte.

Les tests unitaires verrouillent le bon fonctionnent de mon code !

Voici le premier usage des tests unitaires, entériner qu’une portion de code est correcte. J’ai codé le fait qu’elle était correcte. Dès que je vais lancer mes tests, cette vérification va donc s’effectuer. Attention tout de même, la qualité de tes jeux de tests va définir l’assurance que tu auras sur le bon fonctionnement d’un code. On ouvre ici la porte à un lot de questionnements :

  • Est-ce que j’ai pensé à tous les cas possibles ?
  • Est-ce que toutes les portions de mon code sont testées ?
  • Est-ce qu’il faudra que je mette à jour mes tests au fur et à mesure que je mets à jour mon code ?
  • Comment tester des choses plus compliquées comme une authentification ou autre ?
  • Etc.

L’objectif de cet article n’est pas de répondre à ces questions. Gardez en tête qu’elles existent. Plus vite tu prends l’habitude de faire cette gymnastique d’écrire des jeux de tests, plus vite tu maîtrises les différents concepts associés.

Les tests unitaires permettent de faire évoluer son code en toute sécurité

On parle ici de « refactoring ». Une pratique qui consiste à réécrire ou faire évoluer son code. Pour tout un tas de raisons :

  • Le système évolue et le code doit être réorganisé
  • Une mise à jour du langage me permet de nouvelles fonctionnalités
  • J’ai progressé depuis que j’ai écrit ce bout de code, j’ai envie de le mettre à jour
  • La librairie utilisée n’est plus maintenue, je dois la remplacer
  • Il faut optimiser les performances du code
  • Etc.

« Ce code marche, on n’y touche surtout pas ! » C’est une phrase qu’on peut entendre lorsque se présente l’opportunité de faire évoluer du code. S’il n’y a pas de tests unitaires, on travaille sans filet. Et comme on ne maîtrise pas les impacts possibles de l’évolution du code, on n’a pas envie de le faire évoluer. Pourquoi prendre le risque de casser quelque chose qui marche ?! C’est là une autre facette importante des tests unitaires. Tu connais l’état stable de ton système. « Un bout de code à faire évoluer ? Pas de problème ! » Tu vas travailler le code, des tests vont « devenir rouges », tu vas coder jusqu’à ce qu’ils « redeviennent tous verts ». Une fois qu’ils sont de nouveau tous verts, le travail est terminé, le système est de nouveau dans un état stable et fonctionnel. Encore une fois, que tu aies 10 ou 1000 fonctionnalités, tu vas tester TOUT d’un coup.

Si je reprends mon exemple précédent. Je parcourais tous les ingrédients présents pour voir s’ils me permettaient de faire la recette. Si j’ai 1000 ingrédients mais aucun de ma recette, je vais tous les parcourir quand même. Je peux sans doute parcourir la recette plutôt que les ingrédients… Recommençons :

function nbPotionsFoudreRealisables(array $ingredients): int
{
	$recette = [
		'nuages_tenebreux' => 2,
		'poussiere_fee' => 1
	];

	$nbPotionsParIngredient = [];

	foreach ($recette as $ingredient => $quantiteNecessaire) {

		if (!isset($ingredients[$ingredient])) {
			return 0;
		}

		if ($ingredients[$ingredient] < $quantiteNecessaire) {
			return 0;
		}

		$nbPotionsParIngredient[] = floor($ingredients[$ingredient] / $quantiteNecessaire);
	}

	return min($nbPotionsParIngredient);
}

Quelques explications :

  • Je parcours maintenant $recette au lieu de $ingredient
  • Dès qu’il manque un ingrédient de ma recette, je retourne 0
  • Dès qu’un ingrédient est en quantité insuffisante pour faire 1 potion, je retourne 0
  • Si je peux faire au moins 1 potion, je stocke le nombre de potions réalisables pour un ingrédient
  • Je retourne le minimum, comme précédemment

Et je relance mes tests :

Toujours ok

Mes tests jouent le rôle de filet de sécurité, tant qu’ils sont verts, je sais que ma fonction continue à produire le résultat attendu. Je peux donc continuer à chercher des optimisations dans ma fonction :

function nbPotionsFoudreRealisables(array $ingredients): int
{
	if (empty($ingredients)) {
		return 0;
	}

	$recette = [
		'nuages_tenebreux' => 2,
		'poussiere_fee' => 1
	];

	$nbPotions = max($ingredients);

	foreach ($recette as $ingredient => $quantiteNecessaire) {

		if (!isset($ingredients[$ingredient]) || $ingredients[$ingredient] < $quantiteNecessaire) {
			return 0;
		}

		$nbPotions = min(
			$nbPotions,
			floor($ingredients[$ingredient] / $quantiteNecessaire)
		);
	}

	return $nbPotions;
}

Quelques explications :

  • Si je n’ai pas d’ingrédients, je retourne directement 0
  • Je définis un nombre de potions comme le maximum d’ingrédients disponibles (postulat de départ)
  • Je teste mes 2 conditions en même temps, grâce à un || (OU)
  • Je calcule le minimum au fur et à mesure, à chaque itération
  • Je retourne donc le nombre de potions à la fin, qui est le bon

Est-ce que ce code est + performant que le précédent ? Aucune idée 🙂 Mais ce n’est pas la question ici. Le but est bien de montrer que les tests unitaires te donnent un confort de travail très important.

Conclusion

Pas besoin de frameworks de tests, de fixtures, ou autre pour écrire tes premiers tests unitaires. Je ne dis pas que ce ne sont pas des outils importants (ils le sont !), je dis qu’il y a vraiment 0 excuse pour ne pas commencer dès maintenant à tester son code. Un petit tableau, un foreach, et c’est parti !

Les tests unitaires permettent de « verrouiller » le bon fonctionnement de son code. Dès que le code évolue à un endroit donné, tu peux t’assurer que tu ne crées pas d’impact à un autre endroit de ton code, bien souvent difficile, voire impossible à identifier sans tests. Sans ces tests, ce seront tes utilisateurs, ou tes outils de monitoring qui te préviendront du problème…

Les tests unitaires apportent un confort de travail et une sécurité quand tu dois faire du refactoring de code, c’est-à-dire faire évoluer une portion de code existante.

Ressources supplémentaires :

  • Pour aller plus loin avec PHP, regarde du côté de PHPUnit, le framework de test le plus répandu côté PHP. Mais il y en a d’autres, dont des très récents comme Pest PHP qui offre une syntaxe plus agréable.
  • Un excellent épisode de podcast, en français, qui parle de différents types de test et de leur intérêt respectif : If This Then Dev
  • Une série de vidéos, par le « père » du TDD, en anglais, sur la logique des tests : Test desiderata

Notre série d’articles sur les tests unitaires

En nous appuyant sur les challenges, voici des ressources pour progresser sur la pratique des tests unitaires

Et la formation que nous mettons en avant :


Qui a codé ce superbe contenu ?

Keep learning

Autres contenus à découvrir


Ta newsletter chaque mois

Corrigés, challenges, actualités, veille technique... aucun spam.