Introduction au Test Driven Development et implémentation en Python avec pytest.
Introduction
Avec ma première expérience de développeur sur un long projet, j’ai pu expérimenter de nombreuses mésaventures, qui m’on fait réfléchir sur ma méthode de travail. Comment avoir moins de bug ? Comment ne plus avoir peur de casser des fonctionnalités en refactorisant ? Comment être certain à tout moment que mon code est l’exact reflet des user stories données par le business ?
Toutes ces questions m’ont poussées à explorer les bonnes pratiques de codes et l’esprit software craftmanship : Design Pattern, Architecture hexagonale, DDD (Domain Driven Design), BDD (Behaviour driven design) et évidemment TDD (Test driven Development). Je ne suis qu’au début de mon aventure dans ce nouveau monde mais en tant que développeur le point qui m’apparaît le plus important c’est le TDD. Évidemment pour avoir une application de qualité, fonctionnel, maintenable et scalable le TDD ne suffit pas.
La mort ou TESTER !
Avant de comprendre TDD il faut comprendre pourquoi on implémente des tests :
- Les tests sont garants du bon comportement fonctionnel de l’application.
- Avec un fonctionnement garanti on peut refactoriser sans risque.
- Ils sont une documentation vivante de l’application, tous les tests liés à une fonctionnalité sont une collection d’exemples avec un focus sur les cas limites.
Tous ses avantages apparaissent seulement si les tests sont bien implémentés. Comment doit-on les intégrer ?
- Après avoir codé la fonctionnalité ? Puisque les tests ne nous guident pas dans l’écriture du code, mais seulement à le vérifier, ils deviennent une corvée. De plus, il y a de grandes chances qu’on découvre beaucoup de bugs en même temps, tellement qu’on ne saura pas sur lesquels se concentrer, ajoutant de la pénibilité.
- Avant de coder ? Là, les tests vont pouvoir nous guider. Malheureusement le risque est de passer beaucoup de temps à ne voir que le rouge symbolisant l’échec des tests rendant le travail frustrant.
On voit que la seconde approche est bien meilleur, mais ils nous faudrait quelque chose de plus incrémentale, coder et tester petit à petit. C’est exactement l’approche de TDD !
Red, Green, Refactor le nouveau mantra
En TDD, il y a trois étapes qu’on répète encore et encore :
- Red : écrire un test qui va échouer et le lancer.
- Green : écrire le minimum de code qui va faire marcher le test.
- Refacto : réécrire le code pour en améliorer la clarté et potentiellement les performances.
Il faut essayer de couvrir avec le test seulement une petite partie de la fonctionnalité à chaque itération. Il faut éviter la gourmandise et y aller petit à petit pour bien couvrir tous les cas, même les plus rares.
Il est important que le nouveau test échoue, un test qui fonctionne, c’est un cas déjà couvert par les autres tests. On essaye d’être minimale avec le code qu’on écrit pour la fonctionnalité et avec le nombre de tests.
Évidemment s’il n’y a pas de refacto évidente, pas besoin de trop se creuser la tête, cette étape peut être passé contrairement aux deux autres.
TDD ce n’est pas seulement une méthode pour implémenter des tests, c’est surtout une autre manière de coder. Plus besoin d’écrire des print(“Salut je suis”, var1) à tout-va, plus peur de ne plus couvrir un cas anciennement couvert, plus de frustration, on avance petit à petit en étant récompensé par un passage au vert à chaque étape !
C’est sans doute un peu trop théorique et un peu vague pour la plupart d’entre vous. Si c’est le cas, je vous conseille de regarder la vidéo ci-dessous, un peu longue, mais un très bon exemple de Michael Azherad (même si ce n’est pas du python ça reste compréhensible):
Implémentation en Python avec pytest
Tout d’abord ajouter pytest et pytest-mock à votre environnement
pip install pytest
pip install pytest-mock
Nous allons créer la fonction like_fruit qui permet de dire si une certaine personne aime un certain fruit. Elle prendra en entrer un dictionnaire avec pour clé des noms associé à des ensembles (set) de fruits, un nom et un fruit. Elle renverra True si la personne aime le fruit, False sinon.
Créer example.py c’est ici que nous allons écrire like_fruit
def like_fruit(nom: str, fruit: str, nom_fruit: dict) -> bool:
return None
Créer un dossier tests où vous stockerez tous vos tests, créer un fichier test_example.py dans ce dossier. Les noms de fichers son normés avec pytest donc il faut bien créer un dossier tests et un nom de ficher commençant par test_.
On créer un petit test simple.
from example import like_fruit
class TestLikeFruit:
def test_alice_like_ananas(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
assert like_fruit('Alice', 'ananas', nom_fruit) is True
Comme pour les noms de fichiers les noms des classes et fonction de test son normés. les classes commencent par Test, les fonctions commencent par test_
Il n’est pas obligatoire de créer une classe, mais elle permettent d’avoir des codes de tests bien organisé.
Lancer la commande dans le terminal :
python -m pytest
Comme prévu le test échoue, on écris le code minimal pour faire marcher le test.
def like_fruit(nom: str, fruit: str, nom_fruit: dict) -> bool:
return True
On relance la commande de test, et le test réussi.
Puis on écrit un nouveau test voué à échouer, on change la fonction, on retest.
from example import like_fruit
class TestLikeFruit:
def test_alice_like_ananas(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
assert like_fruit('Alice', 'ananas', nom_fruit) is True
def test_alice_dont_like_poire(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
assert like_fruit('Alice', 'poire', nom_fruit) is False
def like_fruit(nom: str, fruit: str, nom_fruit: dict) -> bool:
return fruit in nom_fruit['Alice']
etc.
Réutiliser un objet de façon sécurisé dans plusieurs fonctions de tests grâces aux fixtures
On utilise le même nom_fruit dans chaque test, on aimerait pouvoir le définir une seul fois et pouvoir l’utiliser partout. On pourrait faire une variable globale ou un attribut de la classe TestLikeFruit mais on ne sera pas assuré que l’objet sera conservé dans son état initial entre chaque méthodes de tests.
Pour répondre à ce besoin il existe les fixtures, des fonctions décorées, permettant de passer leurs résultats en paramètre des fonctions de test. Ainsi, l’objet est toujours régénéré !
from example import like_fruit
import pytest
class TestLikeFruit:
@pytest.fixture()
def nom_fruit(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
return nom_fruit
def test_alice_like_ananas(self, nom_fruit):
assert like_fruit('Alice', 'ananas', nom_fruit) is True
def test_alice_dont_like_poire(self, nom_fruit):
assert like_fruit('Alice', 'poire', nom_fruit) is False
Remplacer le retour d’une fonction (par exemple, un appel a une base de données) grâces aux mock
L’objet nom_fruit pourrait ne pas être passé à la fonction like_fruit, mais être le résultat d’un appel à une base de donnée à l’intérieur de la fonction.
def get_nom_fruit():
#Appel à une base de donnée
return None
def like_fruit(nom: str, fruit: str) -> bool:
nom_fruit = get_nom_fruit()
return fruit in nom_fruit['Alice']
Pour tester la fonctionnalité, on n’a pas envie de faire cet appel, parce qu’il est lourd et inutile pour tester la logique. On va donc mocker cette fonction afin qu’elle retourne un objet qu’on contrôlera.
from example import like_fruit
import pytest
class TestLikeFruit:
@pytest.fixture()
def nom_fruit(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
return nom_fruit
def test_alice_like_ananas(self, nom_fruit, mocker):
mocker.patch(
# Chemin ou la fonction est appelé
'example.get_nom_fruit',
return_value=nom_fruit
)
assert like_fruit('Alice', 'ananas') is True
def test_alice_dont_like_poire(self, nom_fruit, mocker):
mocker.patch(
'example.get_nom_fruit',
return_value=nom_fruit
)
assert like_fruit('Alice', 'poire', ) is False
ATTENTION: le chemin pour mocker n’est pas le chemin où la fonction est déclarée, mais le chemin où elle est appelée pour le test !!!
Tester un message d’erreur
Il est aussi possible de capturer un message d’erreur pour le tester. Par exemple si le nom qu’on cherche n’est pas connue on aimerait lever une exception.
from example import like_fruit
import pytest
class TestLikeFruit:
@pytest.fixture()
def nom_fruit(self):
nom_fruit = {
"Alice": {"ananas", "pomme"},
"Bob": {"poire", "fraise"},
}
return nom_fruit
def test_alice_like_ananas(self, nom_fruit, mocker):
mocker.patch(
# Chemin ou la fonction est appelé
'example.get_nom_fruit',
return_value=nom_fruit
)
assert like_fruit('Alice', 'ananas') is True
def test_alice_dont_like_poire(self, nom_fruit, mocker):
mocker.patch(
'example.get_nom_fruit',
return_value=nom_fruit
)
assert like_fruit('Alice', 'poire', ) is False
def test_nom_inconnue(self, nom_fruit, mocker):
mocker.patch(
'example.get_nom_fruit',
return_value=nom_fruit
)
with pytest.raises(Exception) as execinfo:
like_fruit('Cicéron', 'ananas')
assert str(execinfo.value) == "nom inconnue"def get_nom_fruit():
#Appel à une base de donnée
return None
def like_fruit(nom: str, fruit: str) -> bool:
nom_fruit = get_nom_fruit()
if nom_fruit.get(nom) is None:
raise Exception("nom inconnue")
return fruit in nom_fruit['Alice']