Le TDD (Test Driven Development) est un process de développement logiciel qui consiste à faire évoluer un programme petit à petit à travers des mini-cycles : le développeur commence par implémenter un test automatique échoué représentant un cas d’utilisation particulier d’un nouveau code qu’il souhaite ajouter, et modifie ensuite le programme pour passer ce test. Puis, il implémente un deuxième test échoué d’un autre cas particulier de la même partie du code, et modifie le programme pour réussir les deux tests, et ainsi de suite jusqu’à l’obtention d’un programme qui passe plusieurs tests couvrant tous les cas d’utilisation du nouveau code. Ces tests sont ensuite conservés et lancés après toute modification du programme, afin de garantir la non-régression de toutes les fonctions de son code.
Application du TDD avec Pytest
Imaginons qu’on souhaite implémenter un programme en python qui nous permet de valider une adresse email et en suivant le TDD avec l’aide du framework pytest. On n’utilisera pas les expressions régulières pour plus de lisibilité.
Dans notre cas, pour qu’une adresse mail soit valide il faut qu’elle ait la forme suivante : xxx@yyy.zzz Avec:
- xxx est une chaine de caractère composée par des lettres et des chiffres et de taille minimale 1.
- yyy et zzz sont de même que xxx mais de taille minimale 2.
Commençons par installer pytest avec la commande :
$ pip install pytest
Après cela, créons un répertoire qui contiendra notre programme :
$mkdir email_validation && cd email_validation
Ensuite, on crée un fichier pour le code et un autre pour les tests :
$ touch __init__.py && touch email_validation.py && touch test_email_validation.py
Commençons par définir une fonction de validation vide dans le fichier email_validation.py
def validate_email(email): pass
Pour suivre les principes du TDD, on doit d’abord commencer par implémenter un test de la fonction validate_email, un premier cas de test peut être que la chaine “test@test.test” est bien une adresse valide :
Dans le fichier test_email_validation.py on peut implémenter le test comme suit :
from email_validation.email_validation import validate_email def test_validate_email(): assert validate_email("test@test.test")
Exécutons le test:
$pytest ============================= test session starts ============================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: email_validation collected 1 item test_email_validation.py F [100%] ================================== FAILURES =================================== ______________________ test_validate_email_simple_string ______________________ def test_validate_email_simple_string(): > assert validate_email("test@test.test") E AssertionError: assert None E + where None = validate_email('test@test.test') test_email_validation.py:4: AssertionError
NB: pytest lance les fonctions préfixées par “test_” dans les fichiers préfixés par “test_”. Pour en savoir plus sur ces conventions aller sur ce lien
Comme décrit ci-dessus, on doit modifier le code le moins possible pour faire passer ce test.
Par exemple:
def validate_email(email): return True
Maintenant si on lance pytest on obtient :
$pytest ============================= test session starts ============================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: email_validation collected 1 item test_email_validation.py . [100%] ============================== 1 passed in 0.03s ==============================
Et voilà ! On a terminé notre premier mini-cycle.
Recommençons la même démarche pour un deuxième tes. Ici, par exemple on rajoute le test qui vérifie que la chaine “abcd” n’est pas une adresse valide :
from email_validation.email_validation import validate_email def test_validate_email(): assert validate_email("test@test.test") def test_validate_email_simple_string(): assert not validate_email("abcd")
Et on lance pytest:
$ pytest ============================= test session starts ============================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: email_validation collected 2 items test_email_validation.py .F [100%] =================================FAILURES================================== ______________________ test_validate_email_simple_string ______________________ def test_validate_email_simple_string(): > assert not validate_email("abcd") E AssertionError: assert not True E + where True = validate_email('abcd') test_email_validation.py:7: AssertionError ========================= 1 failed, 1 passed in 0.07s =========================
Pytest indique qu’un test passe et un test échoue, on appelle ce stade de cycle l’étape rouge.
La prochaine étape et de modifier le code pour faire passer les deux tests. Par exemple vérifier que la chaine passer en paramètre contient un caractère ‘@’ :
def validate_email(address): if '@' not in address: return False return True
Maintenant les deux tests passent :
$ pytest ============================= test session starts ============================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: email_validation collected 2 items test_email_validation.py .. [100%] ============================== 2 passed in 0.04s ==============================
Troisième mini-cycle:
Test : test@@test.test n’est pas une adresse valide
Ensuite l’étape rouge et puis le nouveau code :
def validate_email(address): if '@' not in address: return False parts = address.split('@') if len(parts) != 2: return False return True
4ème mini-cycle :
Test : test@test n’est pas valide
Nouveau code :
def validate_email(address): if '@' not in address: return False parts = address.split('@') if len(parts) != 2: return False local = parts[0] domain_name_parts = parts[1].split(".") if len(domain_name_parts) != 2: return False return True
On répète ensuite les mêmes étapes avec des tests qui couvrent tous les cas d’utilisation de notre programme:
test@test..test N’est pas valide
t@t.t N’est pas valide
t!@t?.t$ N’est pas valide
Enfin notre fichier de tests aura la forme suivante :
from email_validation.email_validation import validate_email def test_validate_email(): assert validate_email("test@test.test") def test_validate_email_simple_string(): assert not validate_email("abcd") def test_validate_email_two_Ats(): assert not validate_email("test@@test.test") def test_validate_email_no_dot(): assert not validate_email("test@test") def test_validate_email_two_dots(): assert not validate_email("test@test..test") def test_validate_email_few_letters(): assert not validate_email("t@t.t") def test_validate_email_special_chars(): assert not validate_email("t!@t?.t$")
Et le fichier du code :
def validate_email(address): if '@' not in address: return False parts = address.split('@') if len(parts) != 2: return False local = parts[0] domain_name_parts = parts[1].split(".") if len(domain_name_parts) != 2: return False hostname = domain_name_parts[0] domain = domain_name_parts[0] if len(local)<1 or len(hostname)<2 or len(domain)<2: return False if not (local.isalnum() and hostname.isalnum() and domain.isalnum()): return False return True
Quelques fonctionnalités avancées de pytest
1 – Paramétrer un test
Pytest permet de passer plusieurs entrées pour un scenario de test, cela peut se faire comme suit:
@pytest.mark.parametrize("a,b,s",[(1,1,2),(3,2,5),(0,3,3),(-1,1,0)]) def test_add(a, b, s): assert a+b==s
En lançant le test, on voit bien que 4 scenarios ont été executés :
========================= test session starts ================================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: tests collected 4 items test_add.py .... [100%] ========================= 4 passed in 0.03s====================================
2 – Grouper des tests
On peut grouper les tests sous plusieurs groupes en marquant chaque test avec le nom de son groupe et lancer un groupe de test en particulier.
Exemple :
import pytest @pytest.mark.numbers def test_add(): assert 1 + 3 == 4 @pytest.mark.numbers def test_mult(): assert 8*2 == 16 @pytest.mark.strings def test_startswith(): assert "abcd".startswith("a") @pytest.mark.strings def test_endsswith(): assert "abcd".endswith("d")
Dans cet exemple on a distingué deux groupes de tests : les test des nombres numbers et les tests des chaines de caractères strings.
Pour lancer les tests des nombres uniquement la commande et la suivante :
$ pytest test_ example.py -m "numbers" ========================= test session starts ============================== platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: tests collected 4 items / 2 deselected / 2 selected test_example.py .. [100%] =================== 2 passed, 2 deselected in 0.02s ========================
3 – Les fixtures
Les fixtures sont des fonctions s’exécutant avant chaque test auquel elles sont appliquées, elles permettent de factoriser du code se répétant plusieurs fois dans les tests.
Exemple:
import pytest @pytest.fixture def threshold(): threshold = 100 return threshold def test_greater(threshold): assert 150 > threshold def test_less(threshold): assert 50 < threshold
Quand les tests sont exécutés maintenant la variable threshold sera assignée avant chacun des deux tests :
========================= test session starts ======================= platform win32 -- Python 3.6.5, pytest-5.2.0, py-1.8.0, pluggy-0.13.0 rootdir: tests collected 2 items test_example.py .. [100%] =========================== 2 passed in 0.06s =======================
Conclusion
Pour conclure, le TDD est une approche de développement qui consiste à un avancement un lent mais sûr tout au long du projet, cela permet d’optimiser la dette technique et d’éviter les surprises de la production. Plusieurs frameworks aide à suivre cette méthodologie tel que la libraire de tests standard unittest ou encore le framework pytest dont on a détaillé certaines fonctionnalités dans cet article, Il existe encore plus de fonctionnalités de pytest que celles citées ci-dessus notament pouvoir sauter des tests (skip), executer les tests en parallèle, arrêter l’exécution des tests après un nombre d’echecs donné …
Retrouvez l’ensemble de nos articles Python dans la catégorie dédiée. Et si vous souhaitez apprendre de nouvelles méthodologies de développement, retrouvez nos articles Agilité & Craft.