Chapitre 13 : Spécifications et tests

Écrire un programme, c'est bien mais c'est encore mieux s'il est parfaitement juste et que l'on peut être sûr qu'il fonctionnera en toute circonstance. C'est ce que l'on appelle prouver un programme mais cela nécessite la maîtrise d'un langage mathématique fondé sur la logique classique, l'arithmétique et la théorie des ensembles que nous n'aborderons pas dans ce cours.

Une première étape vers ce processus de preuve d'un programme est d'apprendre à définir précisément ce dont le programme a besoin et ce qu'il doit répondre. C'est ce que l'on appelle prototyper une fonction ou bien écrire ses spécifications.

Prototyper une fonction

Documenter une fonction

Voici une fonction écrite en Python, convertissant un nombre décimal en binaire sur un nombre de bits donné en paramètre.


def dec_vers_bin(n, bits):
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    return binaire

Pour utiliser cette fonction, il est nécessaire de savoir ce qu'elle fait et sous quelles conditions. Nous pouvons ajouter dans le corps de la fonction, des commentaires sur le bon usage de cette fonction. C'est ce que l'on appelle documenter une fonction. C'est cette documentation qui est renvoyée lorsque la fonction est donnée en argument de la fonction native help().


def dec_vers_bin(n, bits):
    """Convertit le nombre n en binaire sur un nombre de bits."""
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    return binaire

Tester ce code en appelant la fonction avec différents arguments.
Utiliser la fonction help() sur cette fonction et vérifier son comportement.
Explorer la documentation d'autres fonctions natives de python.

Préconditions et postconditions

Décrire les préconditions et les postconditions d'une fonction correspond à écrire ses spécifications.

Une précondition est une condition qui doit être vérifiée au début de l'exécution de la fonction afin que celle-ci fonctionne correctement.

Une postcondition est une condition qui doit être vérifiée à la fin de l'exécution d'une fonction, sur son résultat, afin de s'assurer qu'il corresponde aux attentes et n'introduise pas d'erreur dans la suite du programme.

Dans notre exemple, on peut définir trois préconditions :

Python, comme d'autres langages, propose un mécanisme d'assertion dont la syntaxe est :

assert condition, "message d'erreur"

L'instruction assert teste si une condition est satisfaite. Si c'est le cas, elle ne fait rien. Sinon elle arrête immédiatement l'exécution du programme et affiche le message d'erreur (qui est facultatif).

Voyons comment l'utiliser pour vérifier les préconditions pour la fonction précédente :


def dec_vers_bin(n, bits):
    """Convertit le nombre n en binaire sur un nombre de bits."""
    # Préconditions
    assert type(n)==int and n>=0, "n doit être un entier naturel"
    assert bits!=0 and n<2**(bits), "le nombre de bits est insuffisant"
    
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    return binaire
Dans le script précédent deux des préconditions ont été introduites grâce à deux assertions. Déterminer lesquelles et compléter le code pour prendre en compte la troisième précondition.

assert type(bits)==int and bits>=0, "bits doit être un entier naturel"
    

Le mécanisme d'assertion permet de résoudre des erreurs. En effet, en arrêtant l'exécution avant que l'erreur se produise et en la documentant, il permet de se rendre immédiatement compte d'un problème dans le programme et de le corriger.

De même on peut insérer en fin de fonction, juste avant le return, des assertions pour vérifier des postconditions. Dans notre exemple nous allons en écrire deux :

Compléter le code précédent pour ajouter les deux assertions permettant de vérifier ces deux postconditions.

def dec_vers_bin(n, bits):
    """Convertit le nombre n en binaire sur un nombre de bits."""
    # Préconditions
    assert type(n)==int and n>=0, "n doit être un entier naturel"
    assert type(bits)==int and bits>=0, "n doit être un entier naturel"
    assert bits!=0 and n<2**(bits), "le nombre de bits est insuffisant"
    
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    
    # Postconditions
    assert all(c in "01" for c in binaire), "N'est pas un mot binaire."
    assert len(binaire)==bits, "La longueur du nombre binaire est incorrecte."
    return binaire

Développement logiciel

Ce mode de programmation qui utilise les assertions pour vérifier les préconditions et les postconditions, est appelé programmation défensive. C'est utilisé pour du code dont on a le contrôle, en cours de développement par exemple.

Lorsqu'on écrit un module, on peut programmer défensivement les fonctions qui sont destinées à être seulement appelées au sein de ce dernier. Pour des fonctions destinées à être utilisées par d'autres personnes, en dehors du module, on va plutôt faire une gestion active des erreurs, notamment avec des structures conditionnelle if else et en prévoyant des valeurs de retour spéciales qu'il faudra documenter.

Il est alors préférable, lorsqu'on définit et spécifie une nouvelle fonction, de minimiser le nombre de préconditions si on veut la rendre robuste, c'est-à-dire résistante à de mauvaises utilisations sans qu'elle ne génère d'erreur. Elle doit alors être capable de gérer les différents mésusages. C'est particulièrement nécessaire lorsque cette fonction est vouée à être utilisée directement par l'utilisateur. Cependant, pour un usage interne à un programme, c'est moins critique car vous maîtrisez tous les appels et le code de la fonction n'en sera que plus simple, débarassée des assertions.

Il faut comprendre que le mécanisme d'assertion est une aide au développeur, et ne doit en aucun cas faire partie du code final d'un programme. En supprimant toutes les instructions assert, le programme doit continuer à fonctionner normalement.

Réécrire la fonction dec_vers_bin comme si elle devait être utilisée directement par l'utilisateur.
Pour cela :

def dec_vers_bin(n, bits):
    """Convertit le nombre n en binaire sur un nombre de bits.
       Si n ou bits ne sont pas des entiers naturels ou que bits n'est pas assez grand pour réaliser la conversion,
       la fonction retourne -1."""
    if (type(n)!=int or n<0 or type(bits)!=int or bits<0 or bits==0 or n>=2**(bits)):
        return -1
    
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    
    # Postconditions
    assert all(c in "01" for c in binaire), "N'est pas un mot binaire."
    assert len(binaire)==bits, "La longueur du nombre binaire est incorrecte."
    return binaire
    

Développement piloté par des tests

Définitions

Le développement piloté par des tests (TDD) est une méthode d'écriture de programme qui met en avant le fait d'écrire d'abord des jeux de tests pour chaque fonction du programme (dans ses spécifications) puis d'écrire le code qui permettra au programme de passer ces tests avec succès.

Cette manière de procéder permet de penser plus profondément les spécifications d'un programme, en amont de l'écriture du code. Cela :

La qualité et le nombre de tests sont importants pour atteindre la qualité souhaitée du code mais un jeu de tests ne pourra jamais être exhaustif et prouver la correction d'un programme (garantir qu'il n'y aura pas d'erreur).

Un jeu de tests doit s'attacher à vérifier tous les cas particuliers et les cas limites d'usage pour être réellement utile. Il est inutile de tester toutes les possibilités.

Avec les assertions

Voici un jeu de tests pour la fonction dec_vers_bin. L'idée la plus simple pour mettre en place un jeu de tests est d'utiliser des assertions pour vérifier que les retours de la fonction correspondent à ce qui est attendus.


Implémenter à l'aide d'assertions ce jeu de tests pour la fonction dec_vers_bin. Il faut vérifier l'égalité de l'appel de la fonction avec le jeu d'argument et la réponse attendue.

assert dec_vers_bin(0, 4) == "0000"

assert dec_vers_bin(0, 4) == "0000"
assert dec_vers_bin(3, 4) == "0011"
assert dec_vers_bin(127, 7) == "1111111"
assert dec_vers_bin(128, 8) == "10000000"
assert dec_vers_bin(315, 10) == "0100111011"
    

Ajouter des tests permettant de vérifier les cas où la fonction retourne la valeur -1.

    assert dec_vers_bin(127, 4) == -1
    

L'inconvénient d'insérer des assertions directement après une fonction pour la tester est que celles-ci seront exécutées tout le temps, à chaque exécution du script, ce qui peut entraîner une perte de temps. Lorsque le script est importé en tant que module par un autre script, on préfère éviter ces tests. Cela est possible en utilisant une structure conditionnelle :


if __name__ == "__main__" :
    assert dec_vers_bin(0, 4) == "0000"
    assert dec_vers_bin(3, 4) == "0011"

Cette structure permet de vérifier que l'attribut __name__ du script lui-même est bien égal à "__main__" c'est-à-dire qu'il est exécuté directement, en tant que script principal et non pas en tant que module importé par un autre script. Ainsi les tests sont toujours utilisables en exécutant directement le script mais ne perturbe pas l'exécution d'un projet plus large utilisant ce script comme module.


Insérer tous les tests précédents dans un bloc : if __name__ == "__main__" :.
Ajouter un test renvoyant une erreur et vérifier qu'il renvoie effectivement une erreur.
Créer un autre script python, appelant le module contenant la fonction et observer le comportement.

Avec le module doctest

Le module doctest permet d'inclure le jeu de tests à l'intérieur du corps de la fonction directement dans sa documentation. Voici le code correspondant :


def dec_vers_bin(n, bits):
    """
    Convertit le nombre n en binaire sur un nombre de bits.
    Jeu de tests :
    >>> dec_vers_bin(0, 4)
    '0000'                                          
    >>> dec_vers_bin(3, 4)
    '0011'
    """
    binaire = ""
    for a in range(bits):
        binaire = str(n%2) + binaire
        n = n//2
    return binaire

if __name__ == "__main__" :
    import doctest
    doctest.testmod(verbose=True)

Remarques :

Copier ce code pour observer son comportement puis ajouter d'autres tests.

Remarque : Le module doctests est assez utile mais atteint vite ses limites lorsque les structures de données à vérifier sont plus complexes, comme avec les dictionnaires où l'ordre des éléments n'est pas garanti.

Exercices d'application

Affichage de l'heure
Écrire la fonction afficher_heure(h, m) qui prend l'heure h et les minutes m en argument et retourne la chaîne de caractère "...H...".

def afficher_heure(h, m):
    return f"{h}H{m}"
        

Documenter cette fonction.

def afficher_heure(h, m):
    """Renvoie l'heure sous le format "{h}H{m}"
       avec h et m respectivement l'heure et les minutes données en argument."""

    return f"{h}H{m}"
        

Écrire les spécifications de cette fonction et les ajouter au code sous forme d'assertions.

def afficher_heure(h, m):
    """Renvoie l'heure sous le format "{h}H{m}"
       avec h et m respectivement l'heure et les minutes données en argument.
       h est un entier compris entre 0 et 23 inclus.
       m est un entier compris entre 0 et 59 inclus."""

    assert isinstance(h, int) and 0<=h<24
    assert isinstance(m, int) and 0<=m<60

    return f"{h}H{m}"
        

Rendre robuste cette fonction en supprimant les assertions.

def afficher_heure(h, m):
    """Renvoie l'heure sous le format "{h}H{m}"
       avec h et m respectivement l'heure et les minutes données en argument.
       h est un entier compris entre 0 et 23 inclus.
       m est un entier compris entre 0 et 59 inclus."""

    if not (isinstance(h,int) and 0<=h<24) or not (isinstance(m,int) and 0<=m<60):
        help(afficher_heure)
        return

    return f"{h}H{m}"
        
TDD
Écrire la fonction qui passe cette batterie de tests :

assert fonct(4) == 2
assert fonct(16) == 4
assert fonct(25) == 5
assert fonct(0) == 0
assert fonct(-1) == None            
        

Implémenter cette batterie de tests à l'aide du module doctest.
Travail d'équipe

Par équipe de deux, chacun écrit les spécifications d'une fonction et un jeu de test puis transmet cette documentation à l'autre pour qu'il implémente la fonction en python qui satisfasse la batterie de tests.
  1. Une fonction qui du nom et du prénom d'une personne renvoie une mise en forme particulière : le nom tout en majuscule suivi du prénom avec la première lettre en majuscule.
  2. Une fonction qui d'une liste de mot, renvoie une mise en forme particulière : tous les mots séparés par une virgule sauf le dernier séparé par la conjonction "et".
  3. Une fonction qui permet d'accorder en nombre un mot selon le nombre qui le précède.
  4. Une fonction qui permet de proposer le genre à partir d'un prénom.
  5. Une fonction qui permet de séparer le nom du prénom d'une personne à partir d'un chaîne de caractère.
  6. Une fonction qui supprime et remplace tous les caractère non-ASCII d'un chaîne de caractère.

En anglais :

Bibliographie :