PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Un gros guide bien gras sur les tests unitaires en Python, partie 2

lundi 17 février 2014 à 21:37

La partie précédente vous a donné une vague idée de ce qu’était les tests unittaires, à quoi ça servait, et quelle forme ça avait.

Dans cette partie, nous allons aborder comment on rédige des tests unitaires avec la lib de standard de Python.

En effet, bien qu’on puisse se contenter de faire des assert et attendre que ça plante, ce n’est pas un moyen très efficace de faire ses tests. Des bibliothèques existent donc pour rendre le test plus puissant. En Python, c’est le module unittest qui s’en charge.

Article long, musique, tout ça…

Le test le plus simple

Pour l’exercice nous allons utiliser une fonction à tester qui soit un peu plus réaliste. Par exemple, les dictionnaires ont une méthode get() qui permet de récupérer une élément du dictionnaire. Si celui-ci n’existe pas, une valeur par défaut est retournée :

>>> simple_comme_bonjour = {"pomme": "banane", "geographie": "litterature"}
>>> simple_comme_bonjour.get("pomme", "je laisse la main")
"banane"
>>> simple_comme_bonjour.get("kamoulox", "je laisse la main")
"je laisse la main"

Une telle fonction n’existe pas pour les itérables, nous allons donc en créer une :

def get(lst, index, default=None):
    """
        Retourne l'élément de `lst` situé à `index`.
 
        Si aucun n'élément ne se trouve à `index`,
        retourne la valeur par défaut.
    """
    try:
        return lst[index]
    except IndexError:
        return default

Ça s’utilise ainsi :

>>> simple_comme_bonjour = ('pomme', 'banane')
>>> get(simple_comme_bonjour, 0, "je laisse la main")
'pomme'
>>> get(simple_comme_bonjour, 1000, "je laisse la main")
'je laisse la main'

Afin de sécuriser les futures améliorations de cette fonction, nous allons lui adjoindre des tests unitaires. Utiliser le module unitest est beaucoup plus verbeux que faire des assert, il suppose de faire une classe qui va regrouper tous les tests qu’on veut faire :

import unittest
 
# Le code à tester doit être importable. On
# verra dans une autre partie comment organiser
# son projet pour cela.
from mon_module import get
 
# Cette classe est un groupe de tests. Son nom DOIT commencer
# par 'Test' et la classe DOIT hériter de unittest.TestCase.
class TestFonctionGet(unittest.TestCase):
 
    # Chaque méthode dont le nom commence par 'test_'
    # est un test.
    def test_get_element(self):
 
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
 
        # Le test le plus simple est un test d'égalité. On se
        # sert de la méthode assertEqual pour dire que l'on
        # s'attend à ce que les deux éléments soient égaux. Sinon
        # le test échoue.
        self.assertEqual(element, 'pomme')
 
# Ceci lance le test si on exécute le script
# directement.
if __name__ == '__main__':
    unittest.main()

On met tout ça dans un fichier nommé “test_quelquechose”, et on l’exécute :

$ python test_get.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
 
OK

Cela signifie qu’un test a été exécuté (il y a un point affiché par test). Il n’y a eu aucune erreur.

Ajoutons un test pour essayer le cas où l’élément n’existe pas :

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    # Il faut choisir un nom explicite pour chaque méthode de test
    # car ça aide à débugger.
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')

La sortie nous montre maintenant deux tests passés sans erreur :

$ python test_get.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
 
OK

Erreur durant le test

Il y a deux types d’erreur : une erreur logique (le code plante) ou un test qui échoue.

Commençons par le premier cas. Je rajoute une erreur à la noix :

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    # Ce code ne peut pas marcher car il n'y a pas 1000
    # éléments dans mon tuple.
    def test_avec_error(self):
        simple_comme_bonjour = ('pomme', 'banane')
        simple_comme_bonjour[1000]

Et zou :

$ python test_get.py
E..
======================================================================
ERROR: test_avec_error (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 40, in test_avec_error
    simple_comme_bonjour[1000]
IndexError: tuple index out of range
 
----------------------------------------------------------------------
Ran 3 tests in 0.001s
 
FAILED (errors=1)

Cette fois Python lance bien 3 tests, mais un génère une erreur. Il me signale laquelle par un “E” et donne la stacktrace qui permet de débugger le problème.

Vous voyez ici le premier intérêt d’utiliser un outil fait pour les tests plutôt que des assert à la main : Python n’a pas arrêté au premier plantage. Tous les tests ont été exécutés. Cela permet de savoir précisément quels tests parmi tous ceux que vous avez, sont touchés, et lesquels passent.

Retirons l’erreur logique, et ajoutons un un test qui échoue. Un test qui échoue c’est quand une méthode assertQuelquechose s’aperçoit que les valeurs ne correspondent pas à ce que le test voudrait.

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    def test_avec_echec(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        # Ici j'ajoute ARTIFICIELLEMENT une erreur, mais on est bien d'accord
        # que normalement, si ça échoue ici, c'est que votre code ne se comporte
        # pas comme prévu. Personne ne nique ses tests volontairement, sauf
        # les rédacteurs de tutos et les étudiants en histoire de l'art.
        self.assertEqual(element, 'Je tres clair, Luc')
 
        # element ne sera pas égal à "Je tres clair, Luc", il sera égal à
        # 'Je laisse la main'. assertEqual va s'en rendre compte et va
        # déclarer que le test a échoué, puisque qu'elle vérifie l'égalité.

Et voici là nouvelle sortie :

$ python test_get.py
F..
======================================================================
FAIL: test_avec_echec (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 45, in test_avec_echec
    self.assertEqual(element, 'Je tres clair, Luc')
AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc
 
 
----------------------------------------------------------------------
Ran 3 tests in 0.002s
 
FAILED (failures=1)

L’échec est noté avec un F (pour Fail), et on vous donne le nom du test qui a échoué (c’est pour ça que je vous recommande de choisir un bon nom pour chaque test).

Là ça devient beaucoup plus intéressant qu’avec un assert à la main car vous voyez que non seulement on vous dit où est l’erreur, non seulement Python ne plante pas à la première erreur, mais en plus vous avez des informations supplémentaires.

Ici, cette information est :

AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc

On vous dit très explicitement que c’est un échec sur une égalité, et voici les deux valeurs testées. Cela vous permet de rapidement identifier ce qui a merdé.

Il existe de nombreuses méthodes assertTruc, et chacune d’elles produit des informations différentes en cas d’échec.

Ah, certes

Il y a pas mal de méthodes assertBidule. En rédigeant ce tuto, sous Python 3, j’ai fait ça :

>>> [print (x) for x in dir(self) if x.startswith('assert')]
assertAlmostEqual
assertAlmostEquals
assertCountEqual
assertDictContainsSubset
assertDictEqual
assertEqual
assertEquals
assertFalse
assertGreater
assertGreaterEqual
assertIn
assertIs
assertIsInstance
assertIsNone
assertIsNot
assertIsNotNone
assertLess
assertLessEqual
assertListEqual
assertMultiLineEqual
assertNotAlmostEqual
assertNotAlmostEquals
assertNotEqual
assertNotEquals
assertNotIn
assertNotIsInstance
assertNotRegex
assertRaises
assertRaisesRegex
assertRaisesRegexp
assertRegex
assertRegexpMatches
assertSequenceEqual
assertSetEqual
assertTrue
assertTupleEqual
assertWarns
assertWarnsRegex

Selon votre installation de Python, vous en aurez plus où moins de nombreuses, et je vous invite à vous taper la doc pour voir tout ce qui s’offre à vous.

Voici quelques exemples des possibilités qui s’offrent à vous :

assertAlmostEqual

Va vérifier qu’un nombre est presque égal à un autre, à un arrondi près.

assertDictContainsSubset

Va vérifier que toutes les paires clé/valeur d’un dico sont contenues dans un autre.

assertRaises

Va vérifier que la fonction va lever une exception.

assertRegex

Va vérifier que la chaîne est validée par la regex donnée.

Setup et TearDown

Comme vous avez pu le constater dans notre exemple, à chaque test on recrée le tuple simple_comme_bonjour = ('pomme', 'banane'). Ce n’est pas très pratique. D’autant plus qu’on pourrait avoir des choses bien plus compliquées comme la connexion à une base de données ou une génération de données aléatoires.

Les méthodes setUp et tearDown sont là pour y pallier. Elles permettent respectivement de lancer un code avant chaque test et après chaque test.

class TestFonctionGet(unittest.TestCase):
 
    # Cette méthode sera appelée avant chaque test.
    def setUp(self):
        self.simple_comme_bonjour = ('pomme', 'banane')
 
    # Cette méthode sera appelée après chaque test.
    def tearDown(self):
        print('Nettoyage !')
 
    def test_get_element(self):
        # plus besoin de créer le tuple ici
        element = get(self.simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        element = get(self.simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    def test_avec_echec(self):
        element = get(self.simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je tres clair, Luc')
$ python test_get.py
Nettoyage !
FNettoyage !
.Nettoyage !
.
======================================================================
FAIL: test_avec_echec (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 37, in test_avec_echec
    self.assertEqual(element, 'Je tres clair, Luc')
AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc
 
 
----------------------------------------------------------------------
Ran 3 tests in 0.001s
 
FAILED (failures=1)

Vous voyez qu’il n’y a plus besoin de créer le tuple 3 fois manuellement, c’est fait pour vous. De plus, la chaîne “Nettoyage !” est bien affichée 3 fois, malgré l’échec d’un test. En effet, ces méthodes sont toujours appelées, même en cas d’erreur. Cela permet de créer une environnement propre pour les tests, ou de nettoyer derrière (fermer un fichier, une connexion, etc).

Lancer plusieurs modules de test

Quand vous allez avoir plein de tests, vous n’allez pas tout mettre dans une classe, mais faire plein de fichiers avec des tests par sujet. Parfois vous ne lancerez qu’un fichier de test. Parfois vous voudrez tout lancer d’un coup.

Pour ce faire, assurez vous que vous vos modules de tests sont importables depuis le dossier où vous êtes. Tout doit être dans le PYTHON_PATH et les dossiers doivent contenir des fichiers __init__.py.

Ensuit, il suffit de lancer la commande :

python -m unittest discover

Python trouvera tous les fichiers de tests pour vous automatiquement, pourvu qu’ils soient nommés test_quelquechose.py.

La ptit’ clusion

Vous voyez maintenant comment utiliser un outil pour rédiger des tests, mais probablement pas COMMENT rédiger un test, ni QUAND et POUR QUOI. C’est tout à fait normal.

Dans les premières parties, je vais faire un tour de tous les outils de tests à votre disposition. Puis, dans les parties suivantes, je ferai un topo sur les questions existentielles du genre “ok mais je teste quel code ?” ou “à quel moment je mets un test” ou “pourquoi c’est moi qui écrit les tests et pas bob cet enculé ?”.

Bref, pour le moment, vous nagez un peu dans le brouillard, et il ne faut pas s’inquiéter. Le dossier sur les tests sera long et comportera surement 8 parties ou plus. Donc on se détend, et on profite du voyage.


Télécharger le code de l’article

flattr this!

Sondage! Aimeriez-vous un espace petites-annonces chez S&M ?

samedi 15 février 2014 à 17:53

Pendant que Sam vous mijotte des bons petits tutos à se lécher les babines moi je vais vour divertir un peu l’oignon avec ce petit vote.

On a quelques e-mails par mois assez surprenants, certains nous demandent si on ne veut pas bosser pour eux, d’autres veulent faire la publicité de leur app/produit, d’autres encore aimeraient nous rencontrer, voire même certaines coucher avec moi (ah non… merde)

On ne peut pas répondre favorablement à ces requêtes et c’est peut-être l’occasion de permettre à certains d’entre-vous d’échanger, vendre, se rencontrer via une sorte de pages jaunes/craiglist/ads (appelez ça comme vous voulez).

En gros des petites-annonces dans lesquelles nous définirons des catégories précises (on ne veut pas d’annonce pour vendre la recette miracle pour la repousse de la moustache).

On préfère vous demander avant car on estime que c’est une bonne chose de proposer plutôt que d’imposer. Et puis pour tâter le potentiel.

Les annonces sont bien évidement gratoches, on mettra peut-être des quotas si il y a des abus.

Quelques catégories pour donner une idée:
- Lans (parties)
- hack parties
- ventes de matos info
- trucs geek (gadgets, etc)
- demande de cours d’info
- donne des cours d’info
- donne des cours de math
- sallons info
- propositions de collaboration
- projets de conquête du monde
- demande d’aide sur des sujets particuliers
etc.

On a pas encore de liste précise des catégories mais il y a de quoi faire dans notre domaine. Si vous avez des idées quand aux catégories n’hésitez pas, balancez !

Aller, détente….

Note: There is a poll embedded within this post, please visit the site to participate in this post's poll.

flattr this!

Mettez des fonctions dans vos structures de données

samedi 15 février 2014 à 10:50

En Python les fonctions sont des objets de premier ordre. On peut les manipuler, copier les références, les supprimer…

Exemple :

# On definit une fonction.
# Jusqu'ici tout va bien...
def pizza():
    return "miam"
 
 
print(pizza())
## miam
 
# On peut assigner la référence de
# la fonction à une variable.
delice = pizza
 
# Et utiliser la fonction depuis la variable
print(delice())
## miam
 
# On peut même supprimer la variable
# originale.
del pizza
print(delice())
## miam
 
pizza()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-3-dc0f1f08233f> in <module>()
----> 1 pizza()
 
NameError: name 'pizza' is not defined

La fonction est un objet ordinaire qui peut donc se mettre dans une variable, mais aussi se passer en paramètre, se retourner comme valeur, etc. J’ai déjà expliqué ça dans le tuto sur les décorateurs plus en détail, mais un rappel ne fait pas de mal.

Cela veut dire que l’on peut mettre la référence d’une fonction dans une structure de données.

Par exemple dans une liste :

def pizza():
    return "miam"
 
def epinard():
    return "miam aussi"
 
def arepas():
    return "decidement tout est miam"
 
# On met les fonctions dans la liste :
liste_de_fonctions = [pizza, epinard, arepas]
 
# Et du coup on peut boucler sur les fonctions
for func in liste_de_fonctions:
    print(func())
 
## miam
## miam aussi
## decidement tout est miam
 
# Le monde des listes s'ouvre à vous
 
print(liste_de_fonctions[-1]())
## decidement tout est miam
 
f1, f2 = liste_de_fonctions[:2]
print(f2())
## miam aussi

Mais aussi dans un dictionnaire :

dico_de_fonction = {
    "choix1": pizza,
    "choix2": epinard,
    "choix3": arepas
}
 
print(dico_de_fonction["choix2"]())
## miam aussi
 
for key, func in dico_de_fonction.items():
    print("%s: %s" % (key, func()))
 
## choix1: miam
## choix3: decidement tout est miam
## choix2: miam aussi

En fait, ça marche avec tout (les fonctions sont hashables) : les sets, les tuples, les deques

On peut même faire un générateur qui yield des fonctions :

import random
 
def fonctions_aleatoires(nombre, fonctions=liste_de_fonctions):
    for x in range(nombre):
        yield random.choice(liste_de_fonctions)
 
for func in fonctions_aleatoires(5):
    print(func())
 
## miam aussi
## miam
## decidement tout est miam
## miam
## miam aussi

Python n’a certes pas les capacités de Lisp ou même Javascript pour faire de la programmation fonctionnelle, mais les fonctions restent des objets très puissants dans ce langage.

D’ailleurs, cet article est valable aussi pour les classes et les modules :)

flattr this!

Et pour quelques astuces de plus

vendredi 14 février 2014 à 18:00

Sublime Text n’en fini pas d’être sublime.

Depuis le dernier article, j’ai découvert d’autres trucs super géniaux.

Echanger deux selections

Ctrl + T.

Selection en colonne

Shift + Clic droit.

Selection similaires

Ctrl + D

Attention, ça ne marche pas quand le champ “recherche” est activé.

Déplacer une ligne

Shift + Ctrl + Flèche haut ou bas.

Supprimer une ligne

Ctrl + k deux fois pour supprimer la ligne après le curseur. On rajoute shift pour supprimer la ligne complete.

Je suppose que sous Mac plein de raccourcis sont différents avec des touches commandes à la place du shift et autre…

flattr this!

Le namedtuple, la structure mal aimée

jeudi 13 février 2014 à 17:29

Figurez-vous qu’on a un chat IRC. Si, si ! freenode#sametmax administré par foxmask, qui est aussi le seul participant du chan.

Bon j’exagère, d’ailleurs tout à l’heure un lien de JEDI_BC m’a donné l’idée de cet article : qu’est-ce qu’un namedtuple et à quoi ça sert.

Les tuples, vous connaissez : c’est une séquence ordonnées non modifiable d’éléments hétérogènes.

Pardon, je recommence. C’est comme une liste, mais plus rapide, et pas modifiable. Ca se créer avec l’opérateur “,” :

>>> un_tuple = 1, 2, 3
>>> print(un_tuple)
(1, 2, 3)
>>> type(un_tuple)
<type 'tuple'>
>>> un_tuple[0]
1
>>> un_tuple[:-1]
(1, 2)
>>> autre_tuple = ("pomme", 1, True)

Les parenthèses sont optionnelles mais permettent d’éviter les ambiguïtés.

Bref, les tuples, c’est top : on peut les slicer, les indexer, et en plus c’est très très rapide car non modifiable :

>>> autre_tuple[0] = "banane"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

Le tuple est donc une bonne alternative aux listes si on sait que le contenu ne bougera pas. Ca prend moins de mémoire, c’est plus rapide, c’est tout aussi flexible, et comme c’est un itérable on peut le plugger un peu partout.

Un gros défaut du tuple, néanmoins, c’est l’absence d’attributs nommés. Il faut connaitre les indices des valeurs, et c’est pas évident à lire si le tuple est gros.

Pour cette raison, la lib standard vient avec le namedtuple, qui est simplement un tuple dont les valeurs sont nommées.

Ca s’utilise en deux étapes. D’abord, définir un nouveau type de namedtuple avec la liste des attributs qu’il va contenir. Un peu comme définir une classe :

>>> from collections import namedtuple
>>> Joueur = namedtuple('Joueur', ['nom', 'age', 'signe_particulier'])

À partir de là nous avons une nouvelle “classe” de namedtuple appelée Joueur qui peut créer des namedtuples avec 3 attributs : ‘nom’, ‘age’ et ‘signe_particulier’.

Et ça s’utilise comme un tuple :

>>> jdg = Joueur('M. Grenier', 31, 'Un langage fleuri')
>>> jdg[0]
'M. Grenier'
>>> nom, age, signe = jdg
>>> nom
'M. Grenier'

Mais aussi comme un objet en read-only :

>>> jdg.nom
'M. Grenier'
>>> sam = Joueur(nom="Sam", age="Tu sauras pas", signe_particulier="Est parfaitement impartial")
>>> sam.age
'Tu sauras pas'

flattr this!