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:

  1. xxx est une chaine de caractère composée par des lettres et des chiffres et de taille minimale 1.
  2. 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.