python tests

Identification

Infoforall

11 - doctests : documenter et tester


Dans la première activité sur les fonctions, nous avons vu une première façon de les utiliser : enregistrer du code à utiliser et les activer lorsqu'on veut que ce code s'active.

Nous avons ensuite vu la différence entre les vraies fonctions (avec return) et les procédures (sans return), même si Python ne fait pas la différence.

Le rajout des paramètres permet encore de rajouter de la flexibilité à l'ensemble.

Nous allons aujourd'hui étendre cette utilisation : nous allons documenter correctement les fonctions pour que les personnes puissent l'utiliser sans avoir à comprendre le code de la fonction en lui-même et nous allons utiliser cette documentation pour réaliser des tests automatiques.

Logiciel nécessaire pour l'activité : Python 3

Résumé : Version HTML ou fond blanc ou ou PDF (couleur ou gris)

1 - Documenter une demande

Documenter une fonction à créer est un bon moyen de se mettre d'accord sur le travail à réaliser.

Contenu de la documentation

On peut ainsi fournir une fonction en ne donnant que sa déclaration (et ses paramètres) et en spécifiant dans la documentation :

  • Le but global de la fonction
  • Les spécifications sur les paramètres que le codeur pourra prendre pour acquis.
  • Les spécifications de sortie que le codeur devra faire respecter.
  • Les conditions d'utilisation

01° Modifier la fonction calculer_moyenne pour qu'elle renvoie la réponse attendue précisée dans les commentaires.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
def calculer_moyenne(note1, coef1, note2, coef2) : '''Renvoie la moyenne coefficientée des deux notes :: param note1(int/float) :: nombre dans [0 ; 20] :: param note2(int/float) :: nombre dans [0 ; 20] :: param coef1(int) :: supérieur à 0 :: param coef2(int) :: supérieur à 0 :: return (float) :: moyenne coefficientée, nombre dans [0; 20] ''' moyenne = 0 return moyenne moy = calculer_moyenne(20, 2, 10, 4) print(moy)

...CORRECTION...

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
def calculer_moyenne(note1, coef1, note2, coef2) : '''Renvoie la moyenne coefficientée des deux notes :: param note1(int/float) :: nombre dans [0 ; 20] :: param note2(int/float) :: nombre dans [0 ; 20] :: param coef1(int) :: supérieur à 0 :: param coef2(int) :: supérieur à 0 :: return (float) :: moyenne coefficientée, nombre dans [0; 20] ''' moyenne = (note1*coef1+ note2*coef2) / (coef1 + coef2) return moyenne moy = calculer_moyenne(20, 2, 10, 4) print(moy)

Dans la correction proposée, on peut se passer de la création de la variable locale avant l'envoi avec return : on peut directement fournir l'expression après le return :

return (note1*coef1 + note2*coef2) / (coef1 + coef2)

On peut aussi utiliser une fonction dans une fonction :

02° Analyser, modifier et adapter la fonction fournir_note qui demande à l'utilisateur une note via un input. Veuillez à respecter les spécifications. Cette fonction utilise la fonction note_valide, en ligne 28, pour vérifier si l'entrée utilisateur est correcte.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
# - - - - - # Déclaration des fonctions - - # - - - - - def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :: param note (int/float) :: la note à tester :: return (bool):: True si note dans [ 0 ; 20 ] ''' if note > 20 : return False elif note < 0 : return False else : return True def fournir_note(intitule) : '''Renvoie une note valide après demande via un input en boucle :: param intitule(str) :: intitulé qui apparaît dans la question :: return (float) :: la note valide :: CU :: (conditions d'utilisation) utilise la fonction note_valide pour vérifier si proposition est valide ''' proposition = -1 while (note_valide(proposition) == ???) : proposition = input(f"Veuillez fournir {intitule} (entre 0 et 20) ::: ") ??? return proposition # - - - - - # Programme principal # - - - - - note1 = fournir_note("la note du DS (coef 4)") note2 = fournir_note("la note de l'interro (coef 1)")

Voici une possibilité de correction si vous avez un problème :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
# - - - - - # Déclaration des fonctions - - # - - - - - def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :: param note (int/float) :: la note à tester :: return (bool):: True si note dans [ 0 ; 20 ] ''' if note > 20 : return False elif note < 0 : return False else : return True def fournir_note(intitule) : '''Renvoie une note valide après demande via un input en boucle :: param intitule(str) :: intitulé qui apparaît dans la question :: return (float) :: la note valide :: CU :: utilise la fonction note_valide pour vérifier si proposition est valide ''' proposition = -1 while (note_valide(proposition) == False) : proposition = input(f"Veuillez fournir {intitule} (entre 0 et 20) ::: ") proposition = float(proposition) return proposition # - - - - - # Programme principal # - - - - - note1 = fournir_note("la note du DS (coef 4)") note2 = fournir_note("la note de l'interro (coef 1)")

On peut encore aller plus loin : avec trois fonctions, on peut ainsi parvenir à réaliser un programme qui demande 2 notes valides et qui en fait la moyenne :

03° Tester le programme pour vérifier qu'on parvient bien à assembler les différentes fonctions pour réaliser un programme plus complexe en trois lignes : 49-50-51.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
# - - - - - # Déclaration des fonctions - - # - - - - - def calculer_moyenne(note1, coef1, note2, coef2) : '''Renvoie la moyenne coefficientée des deux notes :: param note1(int/float) :: nombre dans [0 ; 20] :: param note2(int/float) :: nombre dans [0 ; 20] :: param coef1(int) :: supérieur à 0 :: param coef2(int) :: supérieur à 0 :: return (float) :: moyenne coefficientée, nombre dans [0; 20] ''' return (note1*coef1 + note2*coef2) / (coef1 + coef2) def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :: param note (int/float) :: la note à tester :: return (bool):: True si note dans [ 0 ; 20 ] ''' if note > 20 : return False elif note < 0 : return False else : return True def fournir_note(intitule) : '''Renvoie une note valide après demande via un input en boucle :: param intitule(str) :: intitulé qui apparaît dans la question :: return (float) :: la note valide :: CU :: utilise la fonction note_valide pour vérifier si proposition est valide ''' proposition = -1 while (note_valide(proposition) == False) : proposition = input(f"Veuillez fournir {intitule} (entre 0 et 20) ::: ") proposition = float(proposition) return proposition # - - - - - # Programme principal # - - - - - note1 = fournir_note("note du DS (coef 4)") note2 = fournir_note("note de l'interro (coef 1)") moyenne = calculer_moyenne(note1, 4, note2, 1) print(moyenne)

Pas mal non ? Et encore, nous verrons plus tard qu'on peut enregister nos fonctions dans nos propres modules et qu'on peut alors simplifier le programme qui pourrait se résumer à ceci :

1 2 3 4 5 6
import mesfonctions note1 = mesfonctions.fournir_note("note du DS (coef 4)") note2 = mesfonctions.fournir_note("note de l'interro (coef 1)") moyenne = mesfonctions.calculer_moyenne(note1, 4, note2, 1) print(moyenne)

Ou encore mieux (car on n'a pas besoin de préciser à chaque fois le nom du module personnel) :

1 2 3 4 5 6
from mesfonctions import * note1 = fournir_note("note du DS (coef 4)") note2 = fournir_note("note de l'interro (coef 1)") moyenne = calculer_moyenne(note1, 4, note2, 1) print(moyenne)

2 - Doctest

Nous allons maintenant voir comment on peut utiliser la documentation pour préciser un peu plus

  • comment on veut utiliser les fonctions et
  • comment vérifier qu'elle fonctionne correctement

Cette façon de procéder sera au coeur de votre notation désormais.

Premièrement, sachez qu'on peut fournir des exemples d'utilisation de la fonction dans la documentation elle-même. Dans l'exemple ci-dessous, on fournit un exemple d'utilisation de la fonction via le Shell Python, entre les lignes 12 et 19 :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :param note: la note à tester :type note: int or float :return:: True si note dans [ 0 ; 20 ] :rtype: bool :Exemple: >>> note_valide(5) True >>> note_valide(-5) False >>> note_valide(25) False >>> note_valide(20.0) True ''' if note > 20 : return False elif note < 0 : return False else : return True

04° Est-on capable de créer le doctest AVANT que la fonction ne soit réellement fonctionnelle ? Comment montrer au codeur qu'on désire que 20 ou 20.0 fournisse True dans les deux cas ?

...CORRECTION...

Oui, on peut écrire les résultats attendus de notre fonction avant même qu'elle ne soit réellement codée. Il suffit de réflechir à ce qu'elle devrait renvoyer.

Pour que le doctest soit clair vis à vis du 20 integer ou du 20 float, il suffit de rajouter un test dans le doctest :

>>> note_valide(20.0) True >>> note_valide(20) True

Pour l'instant, il ne s'agit que d'une simple documentation. Mais nous allons voir un module de Python qui nous permet de l'utiliser : le module va alors déclencher les tests.

Module doctest

Pour importer le module, il suffit de faire comme pour n'importe quel module :

import doctest

L'utilisation commune de doctest est de ne l'utiliser que si vous activez directement votre fichier Python (en opposition avec un appel via un import). Pour cela, il suffit de rajouter ceci à la fin de votre fichier Python :

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

Ce code va permettre de savoir

  • si votre fichier a bien été activé directement par l'utilisateur (dans ce cas __name__ vaut bien "__main__")
  • ou s'il a été activé via un import

05° Utiliser le code suivant qui ne contient que la fonction (fausse, voir ligne 26) et l'activation du doctest. Observer s'il se passe quelque chose.

Modifier alors la fonction de façon à passer les tests.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
# - # Déclaration des fonctions # - def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :param note: la note à tester :type note: int or float :return:: True si note dans [ 0 ; 20 ] :rtype: bool :Exemple: >>> note_valide(5) True >>> note_valide(-5) False >>> note_valide(25) False >>> note_valide(20.0) True ''' if note > 30 : return False elif note < 0 : return False else : return True # - # Corps du programme # - if __name__ == "__main__": import doctest doctest.testmod()

...CORRECTION...

On observe bien qu'il se passe un problème avec le test sur le 25 :

********************************************************************** File "/home/rv/test5.py", line 20, in __main__.note_valide Failed example: note_valide(25) Expected: False Got: True ********************************************************************** 1 items had failures: 1 of 4 in __main__.note_valide ***Test Failed*** 1 failures.

Lorsqu'on repasse les tests après avoir correctement modifié le IF de la ligne 26, on constate qu'il ne se passe plus rien. C'est l'effet attendu :

Intéret de la mise en place de tests

A l'aide des tests, on peut immédiatement s'apercevoir après avoir fait une correction à un endroit du code que notre correction vient de casser une autre fonctionnalité ailleurs dans le code.

Le principe est donc de modifier quelques lignes de code, de relancer le fichier et de voir si les tests sont toujours corrects. Sinon, on voit clairement apparaître le problème.

Attention : la ligne vide en dessous de dernier test doit bien être une ligne vierge. Sans cela, les tests ne font pas fonctionner. Rajouter donc toujours cette fameuse ligne vide.

Il est bien entendu possible de rendre le test visible, même en cas de réussite :

06° Modifier l'appel à la fonction testmod contenu dans le module doctest. Relancer le fichier.

doctest.testmod(verbose=True)

Cette fois, on constate bien que les tests sont réalisés, même s'ils sont ok.

Trying: note_valide(5) Expecting: True ok Trying: note_valide(-5) Expecting: False ok Trying: note_valide(25) Expecting: False ok Trying: note_valide(20.0) Expecting: True ok 1 items had no tests: __main__ 1 items passed all tests: 4 tests in __main__.note_valide 4 tests in 2 items. 4 passed and 0 failed. Test passed.

On voit qu'il fait le bilan des tests, qu'il donne clairement les fonctions qui n'ont pas de tests ( __main__ ici, nous en parlerons pendant l'activité sur les modules).

D'autres informations sur l'utilisation des doctests sont disponibles dans l'activité sur les tests unitaires. Pour l'instant, cela suffira à vous montrer l'utilité de ces tests.

D'ailleurs, on force rarement verbose à True car dans ce cas, cela bloque le fonctionnement de la fonction à ce format. Nous verrons d'autres façons de faire appel aux doctests, en ayant la possibilité de fournir beaucoup d'options de déroulement.

07° Dans le programme ci-dessous, quelles sont les fonctions qui seront signalées par la fonction testmod comme n'ayant pas de doctests ? Créer quelques tests supplémentaires sur la première à apparaitre dans le code, de façon à limiter le nombre de fonctions non testées.

Attention : les tests fournis doivent impérativement respecter l'exacte codification des réponses attendues. Comme les fonctions sont déjà créées, vous pouvez faire un copier-coller des réponses obtenues dans le Shell.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
# - # Déclaration des fonctions # - def calculer_moyenne(note1, coef1, note2, coef2) : '''Renvoie la moyenne coefficientée des deux notes :: param note1(int/float) :: nombre dans [0 ; 20] :: param note2(int/float) :: nombre dans [0 ; 20] :: param coef1(int) :: supérieur à 0 :: param coef2(int) :: supérieur à 0 :: return (float) :: moyenne coefficientée, nombre dans [0; 20] ''' return (note1*coef1 + note2*coef2) / (coef1 + coef2) def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :: param note (int/float) :: la note à tester :: return (bool):: True si note dans [ 0 ; 20 ] :: exemples :: >>> note_valide(5) True >>> note_valide(-5) False >>> note_valide(25) False >>> note_valide(20.0) True ''' if note > 20 : return False elif note < 0 : return False else : return True def fournir_note(intitule) : '''Renvoie une note valide après demande via un input en boucle :: param intitule(str) :: intitulé qui apparaît dans la question :: return (float) :: la note valide :: CU :: utilise la fonction note_valide pour vérifier si proposition est valide ''' proposition = -1 while (note_valide(proposition) == False) : proposition = input(f"Veuillez fournir {intitule} (entre 0 et 20) ::: ") proposition = float(proposition) return proposition # - # Corps du programme # - if __name__ == "__main__": import doctest doctest.testmod(verbose=True)

La documentation des fonctions est une bonne pratique à acquérir. L'utilisation de tests l'est également. Dans le cadre de Python, l'utilisation de tests via les doctests est parfois bien vue, parfois non. Il existe des façons plus professionnelles de faire des tests unitaires. Mais le doctest à l'avantage de la simplicité.

L'un des probèmes de doctest est notamment le cas des fonctions non déterministes : les fonctions intégrant une part d'aléatoire ou les fonctions intégrant des inputs. Dans ce cas, les tests deviennent beaucoup moins naturels et ils demandent d'écrire les fonctions un peu différement.

Dans le cadre de notre programme, nous avons en plus le problème du while qui oblige à fournir non pas un mais plusieurs faux résultats de inputs lors des tests...

08° Hors programme en NSI. C'est très spécifique : il s'agit de savoir faire des tests sur une fonction qui demande un input. Vous pouvez passer si vous voulez rester sur des choses simples. Sinon, observer la façon dont nous avons ici réalisé des tests avec des simulations de input dans le doctest de la fonction fournir_note. Cela vous permettra de réaliser de nouveaux tests sur vos fonctions comportant des inputs. Les explications viendront dans la prochaine activité sur les fonctions.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
# - # Déclaration des fonctions # - def calculer_moyenne(note1, coef1, note2, coef2) : '''Renvoie la moyenne coefficientée des deux notes :param note1: nombre dans [0 ; 20] :type note1: int or float :param coef1: nombre supérieur à 0 :type coef1: int :param note2: nombre dans [0 ; 20] :type note2: int or float :param coef2: nombre supérieur à 0 :type coef2: int :return: moyenne coefficientée, nombre dans [0; 20] :rtype: float :Exemple: >>> calculer_moyenne(20, 2, 10, 2) 15.0 >>> calculer_moyenne(20, 3, 10, 1) 17.5 ''' return (note1*coef1 + note2*coef2) / (coef1 + coef2) def note_valide(note) : '''Renvoie True si note est dans l'intervalle [ 0 ; 20 ] :param note1: la note à tester :type note: int or float :return: True si note dans [0; 20] :rtype: bool :: exemples :: >>> note_valide(5) True >>> note_valide(-5) False >>> note_valide(25) False >>> note_valide(20.0) True ''' if note > 20 : return False elif note < 0 : return False else : return True def fournir_note(intitule) : '''Renvoie une note valide après demande via un input en boucle :param intitule: intitulé qui apparaît dans la question :type intitule: str :return: une note valide fournie par l'utilisateur :rtype: float :Exemple: >>> import builtins >>> builtins.input = lambda *x, **y: '15' >>> fournir_note('la note de test') 15.0 >>> builtins.input = lambda *x, **y: '15.0' >>> fournir_note('la note de test') 15.0 .. note:: utilise la fonction note_valide pour vérifier si proposition est valide ''' proposition = -1 while (note_valide(proposition) == False) : proposition = input(f"Veuillez fournir {intitule} (entre 0 et 20) ::: ") proposition = float(proposition) return proposition # - # Corps du programme # - if __name__ == "__main__": import doctest doctest.testmod(verbose=True)

Dans le cadre du doctest de la fonction fournir_note, je n'ai mis que des réponses valides dès le premier coup à cause de la présence du while. Ma façon de gérer les strings avec les doctests ne permet pas de fournir plusieurs réponses simulées de input à la suite.

Les docstests permettent donc d'éviter les bugs. Surtout ceux liés à des modifications de code qui fonctionnaient, avant modification ! Mais qui a inventé le terme ?

Beaucoup de gens attribuent faussement l'invention du mot à Grace Hopper.

3 - Morpion

Deux petits exos qui reprennent une grande partie des connaissances vues aujourd'hui.

On vous demande de créer des fonctions. Sont fournies directement dans la documentation :

  • Les explications rapides
  • Les préconditions
  • Les postconditions

Cela vous permettra également de vous montrer comment vous allez pouvoir travailler en équipe, à savoir

  1. On part d'un prototype non fonctionnel mais qui permet de visualiser les choses (la ligne avec def et les paramètres).
  2. On documente les fonctions en expliquant ce qu'on voudrait qu'elles fassent. Notamment, on crée tout de suite les doctests.
  3. Chacun travaille sur quelques fonctions et le projet devient peu à peu fonctionnel.

La clé de la réussite dans ce type de travail commun vient notamment de l'étape 1 : les retours des fonctions non fonctionnelles. Elles doivent renvoyer des réponses qui ne vont pas provoquer d'erreurs ailleurs, en attendant de fournir la réponse CORRECTE.

Nous allons donc travailler sur un prototype de morpion. Le programme est non finalisé, notamment car il n'utilise pas de tableaux. Cela rend le tout plus difficile à concevoir de façon harmonieuse.

09° Utiliser le programme ci-dessous pour vérifier qu'il fonctionne. S'il ne fonctionne pas, penser à vérifier les modules dans Thonny ou Python.

Comme vous n'avez pas encore fait de projet sur Tkinter, vous ne devriez pas vraiment comprendre le code. Néanmoins, après la première petite activité sur Tkinter (celle avec la balle qui rebondit), nous pouvons voir que :

  • Lignes 195 à 255, on crée les widgets de classe Label en leur donnant une couleur particulière. On utilise pour cela la fonction creer_carre.
  • Les widgets sont numérotés dans l'ordre 
    • 1er ligne : 1 - 2 - 3
    • 2e ligne :  4 - 5 - 6
    • 3e ligne :  7 - 8 - 9
  • On crée un évenement à surveiller ligne 225 : lorsqu'on clique sur le premier bouton de la souris, cela active la fonction choix_case.
  • ligne 226 : On lance la surveillance en boucle de la fenêtre avec la méthode mainloop.

Ne cherchez pas à rentrer dans les détails, survoler simplement vaguement le code pour l'instant.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226
'''Attention : Code non fonctionnel dans le cadre d'un projet. Ce code est volontairement non optimisé de façon à ne pas pouvoir être utilisé tel quel dans le cadre d'un projet. Il ne contient ni tableau, ni génération automatique des cases. On teste parfois les variables symbole1, parfois directement en notant 'O'... Bref, ne recopier pas ceci bêtement. Vous êtes prévenu. ''' # - - - - - - - - - # 1 - importation # - - - - - - - - - import tkinter as tk # - - - - - - - - - - - - - - - - # 2 - Var. globales et constantes # - - - - - - - - - - - - - - - - - symbole1 = "O" # symbole affiché pour le joueur 1 couleur1 = "red" # couleur pour le joueur 1 symbole2 = "X" # symbole affiché pour le joueur 2 couleur2 = "blue" # couleur pour le joueur 2 joueur = symbole1 # Variable contenant le symbole du joueur actif couleur = couleur1 # Variable contenant la couleur du joueur actif message = "Jeu en cours" vide = "?" # Symbole d'une case encore vide # - - - - - - - - - - - - - - # 3 - Fonctions - - - - - - - # - - - - - - - - - - - - - - def initialisation() : '''Procédure qui initialise les différents widgets et variables''' global joueur global couleur joueur = symbole1 couleur = couleur1 carre1.configure(text='?', bg="#777777") carre2.configure(text='?', bg="#777777") carre3.configure(text='?', bg="#777777") carre4.configure(text='?', bg="#777777") carre5.configure(text='?', bg="#777777") carre6.configure(text='?', bg="#777777") carre7.configure(text='?', bg="#777777") carre8.configure(text='?', bg="#777777") carre9.configure(text='?', bg="#777777") communication.configure(text=message) def creer_carre(couleur_fond, couleur_texte) : '''Fonction qui génére un widget carré coloré et renvoie sa référence ::param couleur_fond(str) :: une couleur de fond valide pour Tk ::param couleur_texte(str) :: une couleur d'écriture valide pour Tk ::return (tk.Label) :: la référence du widget créé .. note:: on peut fournir les couleurs sous forme 'red' ou '#FF0000' ''' zone = tk.Label(fen_princ, text = "?", fg=couleur_texte, bg=couleur_fond, width=10, height=5) return zone def changer_joueur(): '''Procédure qui inverse les propriétés symbole et couleur du joueur en cours''' global joueur global couleur if joueur == symbole1: joueur = symbole2 couleur = couleur2 else : joueur = symbole1 couleur = couleur1 def verifier(chaine): '''Fonction qui vérifie si chaine peut correspondre à une victoire d'un des joueurs ::param chaine (str) :: un string concaténant le contenu d'une configuration gagnante ::return (int) :: le numéro du gagnant (1 si 'OOO' ou 2 si 'XXX'), 0 sinon >>> verifier("OOO") 1 >>> verifier("XXX") 2 >>> verifier("X?O") 0 ''' return 0 def fin_de_jeu(c1,c2,c3,c4,c5,c6,c7,c8,c9): '''Fonction qui renvoie le numéro du joueur qui a gagné, 0 sinon ::param c1 à c9 (str) :: contenu de la case (normalement '?', 'O' pour joueur 1 ou 'X' pour joueur 2) ::return (int) :: renvoie 0 si personne n'a gagné, 1 ou 2 sinon >>> fin_de_jeu('?','?','?','?','?','?','?','?','?') 0 >>> fin_de_jeu('O','O','O','?','?','?','?','?','?') 1 >>> fin_de_jeu('X','O','O','X','?','?','X','?','?') 2 >>> fin_de_jeu('O','X','O','?','O','?','?','?','O') 1 ''' # Vérification des différents cas gagnants en ligne reponse = verifier(c1+c2+c3) if reponse != 0 : return reponse reponse = verifier(c4+c5+c6) if reponse != 0 : return reponse reponse = verifier(c7+c8+c9) if reponse != 0 : return reponse # Vérification des différents cas gagnants en colonne # Manque des tests ici # Vérification des différents cas gagnants en diagonale # Manque des tests ici # Si on arrive ici, c'est que personne n'a gagné pour l'instant total = c1+c2+c3+c4+c5+c6+c7+c8+c9 if "?" in total : # S'il reste encore au moins une case vide return 0 else : # Si on arrive ici c'est que personne ne peut gagner return -1 def possible(contenu_case): '''Fonction booléenne qui renvoie True si le contenu de la case n'est pas encore choisi ::param contenu_case(str) :: le contenu de la case à surveiller ::return (bool) :: True uniquement si le contenu vaut '?' >>> possible('?') True >>> possible(vide) True >>> possible(symbole1) False >>> possible(symbole2) False ''' return True def choix_case(event): '''Procédure évenementielle principale du jeu ::param event (Event) :: l'événement ayant provoqué l'activation de la fonction ''' # On regarde le messsage dans communication pour savoir si on relance une partie ou non if communication.cget("text") == message : w_case = event.widget # On récupère la référence du widget sur lequel on vient de cliquer if isinstance(w_case,tk.Label) == True : # Si le joueur n'a pas cliqué sur le fond de la fenêtre if possible(w_case.cget('text')) : changer_case(w_case) changer_joueur() # On récupère le str affiché dans les 9 Labels c1 = carre1.cget('text') c2 = carre2.cget('text') c3 = carre3.cget('text') c4 = carre4.cget('text') c5 = carre5.cget('text') c6 = carre6.cget('text') c7 = carre7.cget('text') c8 = carre8.cget('text') c9 = carre9.cget('text') fin = fin_de_jeu(c1,c2,c3,c4,c5,c6,c7,c8,c9) if fin != 0: gagnant = f"Joueur {fin} gagne ! Appuyer pour recommencer" communication.configure(text=gagnant) else : # On a cliqué alors que le jeu était fini : il faut relancer initialisation() def changer_case(w_case): '''Procédure qui modifie le contenu et la couleur de la case''' w_case.configure(bg=couleur) w_case.configure(text=joueur) # - # Corps du programme # - if __name__ == "__main__": import doctest doctest.testmod() fen_princ = tk.Tk() fen_princ.geometry("600x600") carre1 = creer_carre("black","yellow") carre1.place(x=50, y=25) carre2 = creer_carre("black","yellow") carre2.place(x=150, y=25) carre3 = creer_carre("black","yellow") carre3.place(x=250, y=25) carre4 = creer_carre("black","yellow") carre4.place(x=50, y=125) carre5 = creer_carre("black","yellow") carre5.place(x=150, y=125) carre6 = creer_carre("black","yellow") carre6.place(x=250, y=125) carre7 = creer_carre("black","yellow") carre7.place(x=50, y=225) carre8 = creer_carre("black","yellow") carre8.place(x=150, y=225) carre9 = creer_carre("black","yellow") carre9.place(x=250, y=225) communication = tk.Label(fen_princ, text=message, fg='grey', bg='black', width=40, height=5) communication.place(x=50, y=425) fen_princ.bind('<Button-1>',choix_case) fen_princ.mainloop()

En jouant un peu, vous devriez vous rendre compte que le jeu est loin d'être finalisé.

Commençons par la fonction verifier

10° Compléter la fonction verifier de façon à ce qu'elle remplisse correctement sa tâche : vous devrez comparer le paramètre chaine avec les cas gagnants pour renvoyer la bonne réponse à l'aide d'un return. Vous aurez donc besoin de faire des tests avec un if.

Utilisez la documentation et le doctest pour obtenir plus d'explications sur les entrées et les sorties de votre fonction.

Tant que le doctest de la fonction vous parle, c'est qu'il y a un problème. Pour les deux autres fonctions, c'est normal : elles ne sont pas encore finalisées.

11° Compléter la fonction fin_de_jeu de façon à ce qu'elle remplisse correctement sa tâche : pour l'instant, on ne règle que le cas des solutions horizontales. Reste les pions verticaux ou en diagonale. Tant que les doctests sont faux, c'est que le code n'est pas bon !

N'oubliez pas qu'on sort du code de la fonction immédiatement après avoir exécuté le return. C'est pour cela que je n'ai pas placé de elif à la suite des premiers if. Vous pouvez bien entendu. Mais ca ne changera rien au déroulé.

ATTENTION : le cas du return est un CAS PARTICULIER. Normalement, il aurait fallu mettre des elif à la suite les uns des autres.

12° Finaliser le jeu avec la fonction possible : c'est elle qui dit si on a le droit de mettre son pion sur une case. Pour l'instant, on peut mettre son pion sur une case déjà sélectionnée par l'adverse 

...CORRECTION 10-11-12...

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
def verifier(chaine): '''Fonction qui vérifie si chaine peut correspondre à une victoire d'un des joueurs ::param chaine (str) :: un string concaténant le contenu d'une configuration gagnante ::return (int) :: le numéro du gagnant (1 si 'OOO' ou 2 si 'XXX'), 0 sinon >>> verifier("OOO") 1 >>> verifier("XXX") 2 >>> verifier("X?O") 0 ''' # Les chaînes gagnantes : concaténation des 3 symbôles du joueur j1_win = 3 * symbole1 j2_win = 3 * symbole2 # Vérification cas gagnant ? if chaine == j1_win : return 1 elif chaine == j2_win : return 2 return 0 def fin_de_jeu(c1,c2,c3,c4,c5,c6,c7,c8,c9): '''Fonction qui renvoie le numéro du joueur qui a gagné, 0 sinon ::param cX (str) :: contenu de la case (normalement '?', 'O' pour joueur 1 ou 'X' pour joueur 2) ::return (int) :: renvoie 0 si personne n'a gagné, 1 ou 2 sinon >>> fin_de_jeu('?','?','?','?','?','?','?','?','?') 0 >>> fin_de_jeu('O','O','O','?','?','?','?','?','?') 1 >>> fin_de_jeu('X','O','O','X','?','?','X','?','?') 2 >>> fin_de_jeu('O','X','O','?','O','?','?','?','O') 1 ''' # Vérification des différents cas gagnants en ligne reponse = verifier(c1+c2+c3) if reponse != 0 : return reponse reponse = verifier(c4+c5+c6) if reponse != 0 : return reponse reponse = verifier(c7+c8+c9) if reponse != 0 : return reponse # Vérification des différents cas gagnants en colonne reponse = verifier(c1+c4+c7) if reponse != 0 : return reponse reponse = verifier(c2+c5+c8) if reponse != 0 : return reponse reponse = verifier(c3+c6+c9) if reponse != 0 : return reponse # Vérification des différents cas gagnants en diagonale reponse = verifier(c1+c5+c9) if reponse != 0 : return reponse reponse = verifier(c3+c5+c7) if reponse != 0 : return reponse # Si on arrive ici, c'est que personne n'a gagné pour l'instant total = c1+c2+c3+c4+c5+c6+c7+c8+c9 if "?" in total : # S'il reste encore au moins une case vide return 0 else : # Si on arrive ici c'est que personne ne peut gagner return -1 def possible(contenu_case): '''Fonction booléenne qui renvoie True si le contenu de la case n'est pas encore choisi ::param contenu_case(str) :: le contenu de la case à surveiller ::return (bool) :: True uniquement si le contenu vaut '?' >>> possible('?') True >>> possible(vide) True >>> possible(symbole1) False >>> possible(symbole2) False ''' if contenu_case == "?" : return True else : return False

4 - Assertion

La documentation n'est en aucun cas une contrainte pour quelqu'un qui veut faire appel à votre fonction.

Imaginons qu'on ai besoin d'une fonction transforme un pourcentage (exprimé entre 0 et 1) en un nombre entier exprimé entre 0 et 100.

1 2 3 4 5 6 7 8
def transforme(pourcentage) : '''Transforme un pourcentage (de 0 à 1) en un entier compris entre 0 et 100 :: param pourcentage(float) :: un flottant compris dans l'intervalle [0;1] :: return (int) :: un entier compris entre 0 et 100 ''' return int(pourcentage*100)

La documentation impose clairement de n'envoyer que des flotants dans [0;1]. Enfin, impose... Non : la documentation explique qu'il ne faut envoyer qu'un flottant dans [0;1].

Voyons ce que se passe si on passe outre :

>>> transforme(1.12) 112 >>> transforme(-0.01) -1

Et oui : aucune erreur. La fonction renvoie pourtant des valeurs non prévues puisque les entrées ne sont pas dans l'intervalle attendu.

Imaginons maintenant que votre fonction fasse partie d'un processus de démarrage d'un système critique (une fusée ? Une centrale nucléaire ?). Dans ce cas, nous aimerions que le système stoppe et signale le problème non ?

Et c'est possible en utilisant par exemple les assertions.

Assertions avec assert

Il arrive parfois qu'on désire détecter les cas non prévus ou stopper un processus avant qu'il ne puisse causer de dommages en envoyant des données erronées.

Pour cela, on utilise le mot-clé assert.

Son utilisation basique se résume à une succession de trois éléments :

  1. Le mot-clé assert
  2. Un espace suivi de la condition qu'on veut vérifier sous forme d'une expression booléenne
  3. Une virgule suivi du texte à afficher si l'expression est évaluée à False

En résumé :

1
assert note >= 0 and note <= 20, "Votre note n'est pas dans l'intervalle [0;20] !"

Cette assertion peut donc provoquer l'arrêt du programme et signaler la raison à l'utilisateur en affichant le texte. Ce cas arrive si la condition n'est pas évaluée à True.

Exemple :

>>> note = 15 >>> assert note >= 0 and note <= 20, "Votre note n'est pas dans l'intervalle [0;20] !" >>> note = 25 >>> assert note >= 0 and note <= 20, "Votre note n'est pas dans l'intervalle [0;20] !" Traceback (most recent call last): AssertionError: Votre note n'est pas dans l'intervalle [0;20] !

On voit que le message est clair.

D'ailleurs, il s'agit du même type de message lorsque c'est l'interpréteur qui détecte une erreur :

>>> note = 'a' >>> assert note >= 0 and note <= 20, "Votre note n'est pas dans l'intervalle [0;20] !" Traceback (most recent call last): TypeError: '>=' not supported between instances of 'str' and 'int'
13° Rajouter à la fonction pourcentage une assertion permettant de vérifier la validité de la précondition sur le cas >=0 puis sur le cas <=1.

Pour l'instant, on impose juste par une assertion que le paramètre soit bien un entier ou un flottant.

1 2 3 4 5 6 7 8 9 10
def transforme(pourcentage) : '''Transforme un pourcentage (de 0 à 1) en un entier compris entre 0 et 100 :: param pourcentage(float) :: un flottant compris dans l'intervalle [0;1] :: return (int) :: un entier compris entre 0 et 100 ''' assert isinstance(pourcentage, float) or isinstance(pourcentage, int), "pourcentage n'est pas un entier ou un flottant" return int(pourcentage*100)

...CORRECTION...

1 2 3 4 5 6 7 8 9 10 11 12
def transforme(pourcentage) : '''Transforme un pourcentage (de 0 à 1) en un entier compris entre 0 et 100 :: param pourcentage(float) :: un flottant compris dans l'intervalle [0;1] :: return (int) :: un entier compris entre 0 et 100 ''' assert isinstance(pourcentage, float) or isinstance(pourcentage, int), "pourcentage n'est pas un entier ou un flottant" assert pourcentage >=0, "pourcentage inférieur à 0 !" assert pourcentage <=1, "pourcentage supérieur à 1 !" return int(pourcentage*100)

14° Rajouter à la fonction pourcentage une assertion permettant de vérifier la validité de la postcondition.

1 2 3 4 5 6 7 8 9 10
def transforme(pourcentage) : '''Transforme un pourcentage (de 0 à 1) en un entier compris entre 0 et 100 :: param pourcentage(float) :: un flottant compris dans l'intervalle [0;1] :: return (int) :: un entier compris entre 0 et 100 ''' reponse = int(pourcentage*100) return reponse

...CORRECTION...

1 2 3 4 5 6 7 8 9 10 11
def transforme(pourcentage) : '''Transforme un pourcentage (de 0 à 1) en un entier compris entre 0 et 100 :: param pourcentage(float) :: un flottant compris dans l'intervalle [0;1] :: return (int) :: un entier compris entre 0 et 100 ''' reponse = int(pourcentage*100) assert reponse >=0 and reponse <=100, "La réponse n'est pas dans l'intervalle [0;100]. Problème." return reponse

Le module doctest est vraiment pratique pour créer des documentations qui servent aussi à réaliser des jeux de tests.

Mais on peut aussi facilement réaliser des jeux de tests avec d'autres modules (pytest ou unitest).

Finalement, on peut même se passer de ces modules et n'utiliser que des asserts contenus dans une fonction qu'on lancera pour tester nos fonctions.

1 2 3 4 5 6
def addition(a, b) : return a*b def test_maison() : assert addition(10,5) == 15, 'Echec du test : addition(10,5) == 15' assert addition(10,-5) == 5, 'Echec du test : addition(10,-5) == 5'

15° Mettre les deux fonctions précédentes en mémoire.

Lancer les tests en tapant ceci dans le Shell :

>>> test_maison()

Vous devriez détecter une erreur. A vous de regarder la fonction d'addition pour trouver d'où cela vient.

Et on ne peut pas l'activer automatiquement au lancement ?

Si, il suffit de faire comme avec doctest par exemple : on lancera simplement test_maison.

1 2 3 4 5 6 7 8 9
def addition(a, b) : return a+b def test_maison() : assert addition(10,5) == 15, 'Echec du test : addition(10,5) == 15' assert addition(10,-5) == 5, 'Echec du test : addition(10,5) == 15' if __name__ == '__main__' : test_maison()

On retiendra qu'il faut documenter vos fonctions même s'il ne s'agit que d'une simple indication. On y indique les types des paramètres et du retour, ainsi que les préconditions et les postconditions. Attention : il ne s'agit que de simples textes indicatifs, rien de contraignant pour quelqu'un qui voudrait "casser" le système.

Si on veut imposer l'arrêt du programme en cas de non respect des préconditions ou d'échec de la postcondition, on peut alors utiliser des assertions, créées par le mot-clé assert en Python.

Enfin, on peut réaliser des jeux de tests, automatisés ou pas, de façon à tester les fonctionnalités de la fonction. Cela permet de vérifier qu'une modification mineure pour améliorer une fonctionnalité, n'a pas dégradé une autre fonctionnalité.

Pour réaliser ces tests, on peut utiliser :

  • une fonction indépendante contenant des assertions et testant vos fonctions
  • le module doctest qui a l'avantage de l'automatisation et d'utiliser les exemples d'utilisation qu'on place dans la documentation !
  • d'autres modules encore plus puissants comme pytest ou unitest. Ces modules permettent de réaliser des tests sur tout un ensemble de programmes. On nomme cela des tests unitaires.

Si on récapitule, nous avons vu sur différentes activités :

  • la différence entre fonction (avec retour), et procédure (sans retour)
  • les paramètres des fonctions
  • la portée des variables interagissant avec les fonctions
  • la documentation
  • les doctests visant à fournir de la documentation ET à tester les fonctions
  • la structuration d'un programme complexe en multiples petites fonctions

Activité publiée le 28 08 2019
Dernière modification : 19 07 2020
Auteur : ows. h.