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 3

mardi 26 août 2014 à 11:24

Pas mal de temps s’est écoulé depuis notre dernier article sur les tests. Ok, le dernier article Python tout court puisque je vous ai lâchement abandonnés pendant plus d’un mois. Je vous rassure, je n’ai pas du tout pensé à vous, je me suis bien amusé.

Mais je ne vous avais pas non plus oublié. J’étais juste parfaitement fainéant. C’est que ça demande du taff ces petites bêtes là.

Aujourd’hui, nous allons voir la même chose que la partie précédente, mais avec une autre lib.

En effet, si vous voulez rester sains d’esprit et ne pas perdre votre motivation à rédiger des tests, utiliser le module unittest est une mauvaise idée. C’est verbeux, lourd, pas pratique. C’est caca.

Il existe bien mieux, et toutes les personnes que je connais qui sont sérieuses à propos des tests l’utilisent : PyTest.

Et pour donner un petit goût de fiesta :

Principe

Je vous en avais parlé ici, principe de pytest, c’est :

Ça s’installe avec pip :

pip install pytest

Et en gros, au lieu de faire :

import unittest

class TestBidule(unittest.TestCase):

    def test_machin(self):
        self.assertEqual(foo, bar)

if __name__ == '__main__':
    unittest.main()

On fait:

def test_machin():
    assert foo == bar

Yep, c’est tout. Même pas d’import. C’est beau non ?

Il y a beaucoup de magie pour que ça marche. D’abord, le lanceur de pytest détecte toutes les fonctions nommées test_* contenues dans des modules nommés également avec ce motif, et les lance comme un test. Ensuite, il analyse les assert, et devine ce que vous voulez faire avec, et fait le bon test qui va bien.

Ce genre d’opération est un des rares endroits où je tolère de la grosse magie en Python. En effet, les tests, c’est tellement relou que si on n’a pas un moyen ultra simple de les faire, on ne les fait pas.

Traduction

On va donc prendre les exemples qu’on a vus avec unittest, et les traduire dans leur équivalent pytest.

import unittest

from mon_module import get

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')


if __name__ == '__main__':
    unittest.main()

Devient alors :

from mon_module import get

def test_get():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 0)
    assert element == 'pomme'

def test_element_manquant():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je laisse la main'

On lance la commande py.test (le point est important), sans spécifier de fichier :

$ py.test .
============================= test session starts ==============================
platform linux2 -- Python 2.7.6 -- py-1.4.23 -- pytest-2.6.1
collected 2 items

test_get.py ..

Arf, j’ai lancé Python 2.7 au lieu du 3.4. Les vieilles habitudes ont la vie dure. Pas grave, c’est pareil avec les deux versions de Python.

Dans tous les cas, pytest va parcourir le dossier donné récursivement, et détecter tous les modules Python nommés test_ puis extraire les tests qu’il contient. L’effort à fournir est minimal, et c’est ce qu’on lui demande.

Les erreurs

Vu qu’on n’utilise pas de méthode assertChose, on pourrait croire que les informations qu’on obtient en retour sont limitées. Que nenni, pytest fait beaucoup d’efforts pour extrapoler du sens depuis nos assert et va nous pondre un rapport tout à fait complet.

Prenons le cas :

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_error(self):
        simple_comme_bonjour = ('pomme', 'banane')
        simple_comme_bonjour[1000]

Qui donnait :

$ 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)

Avec pytest, le code est allégé :

def test_get():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 0)
    assert element == 'pomme'

def test_element_manquant():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je laisse la main'

def test_avec_error():
    simple_comme_bonjour = ('pomme', 'banane')
    simple_comme_bonjour[1000]

Et la sortie est pourtant un peu plus lisible :

$ py.test
============================= test session starts ==============================
platform linux2 -- Python 2.7.6 -- py-1.4.23 -- pytest-2.6.1
collected 3 items

test_get.py ..F

=================================== FAILURES ===================================
_______________________________ test_avec_error ________________________________

    def test_avec_error():
        simple_comme_bonjour = ('pomme', 'banane')
>       simple_comme_bonjour[1000]
E       IndexError: tuple index out of range

test_get.py:22: IndexError

Le plus intéressant est la manière dont sont gérées les erreurs logiques. Encore une fois l’exemple précédent :

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')
        self.assertEqual(element, 'Je tres clair, Luc')

Et :

$ 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)

Peut-on faire mieux ? Of course :

def test_get():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 0)
    assert element == 'pomme'

def test_element_manquant():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je laisse la main'


def test_avec_echec():
    simple_comme_bonjour = ('pomme', 'banane')
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je tres clair, Luc'

Et malgré cette concision, pytest est très prolixe dans sa sortie :

$ py.test
============================= test session starts ==============================
platform linux2 -- Python 2.7.6 -- py-1.4.23 -- pytest-2.6.1
collected 3 items

test_get.py ..F

=================================== FAILURES ===================================
_______________________________ test_avec_echec ________________________________

    def test_avec_echec():
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
>       assert element == 'Je tres clair, Luc'
E       assert 'Je laisse la main' == 'Je tres clair, Luc'
E         - Je laisse la main
E         + Je tres clair, Luc

test_get.py:24: AssertionError

Je m’arrête pour faire une pause “vis ma vie de Sam”. Je suis en train de rédiger cet article en face d’une caricature de hipster (j’écris beaucoup dans les transports) : la barbe de baroudeur en brousse, la coupe de cheveux de “Thrift shop” absolument immaculée, les lunettes de mamie, le Mac book tout neuf, le petit t-shirt discret mais branché, tout y est. Sauf que là, il vient de sortir un appareil à pellicule pour prendre une photo du paysage, et j’ai beaucoup de mal à me retenir de rire. Ce paragraphe me permet de maintenir une apparence civile. Putain je suis sûr que c’est du noir et blanc et qu’il les développe lui-même.

Fin de la parenthèse.

Contrairement à unittest, pytest n’a pas besoin d’une floppée de méthodes assert*, et il comprend parfaitement les idiomes Python :

assertDictEqual => assert a == b
assertFalse => assert not a
assertGreater => assert a > b
assertIn => assert a in b
assertIs => assert a is b

Etc.

Setup et TearDown à la demande

Pytest ne possède pas de méthode setup() et teardown(). A la place, il y a un mécanisme dit “de fixture”.

Il s’agit de marquer une fonction avec un décorateur. Ensuite, si vous la déclarez en paramètre d’un test, pytest va automatiquement l’appeler au lancement de ce test. C’est une forme d’injection de dépendance, un peu à la angularjs.

C’est pas clair, hein ? Je sens que c’est pas clair.

Mais les exemples sont là pour ça :

import pytest # cette fois il faut un import

# Je déclare une fixture, qui peut (ce n'est pas obligatoire), retourner
# quelque chose
@pytest.fixture()
def simple_comme_bonjour():
    return ('pomme', 'banane')

# Pour chaque test où je déclare le nom de la fixture en paramètre, pytest
# va appeler la fonction juste avant le test et passer son résultat
# (fut-il None), en argument de ce test
def test_get(simple_comme_bonjour):
    element = get(simple_comme_bonjour, 0)
    assert element == 'pomme'

def test_element_manquant(simple_comme_bonjour):
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je laisse la main'

L’avantage du système de fixtures, c’est qu’on n’est pas obligé d’exécuter la fixture pour tous les tests, seulement ceux pour lesquels on en a besoin. On peut combiner plusieurs fixtures de manière très souple, juste en déclarant plusieurs paramètres, sans avoir à faire des classes dans tous les sens. En fait, les fixtures peuvent avoir des fixtures en paramètre, histoire de faire des chaînes de dépendance.

Ici, il n’y a que l’exemple du setup, mais pas du tear down. Pour cela, on peut utiliser un autre type de fixture, qui demande l’utilisation d’un générateur :

import pytest


# On passe de pytest.fixture() a pytest.yield_fixture()
@pytest.yield_fixture()
def simple_comme_bonjour():
    # tout ce qui est setup() va au dessus du yield. Ca peut etre vide.
    print('Avant !')

    # Ce qu'on yield sera le contenu du parametre. Ca peut etre None.
    yield ('pomme', 'banane')

    # Ce qu'il y a apres le yield est l'equivalent du tear down et peut être
    # vide aussi
    print('Apres !')

def test_get(simple_comme_bonjour):
    element = get(simple_comme_bonjour, 0)
    assert element == 'pomme'

def test_element_manquant(simple_comme_bonjour):
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je laisse la main'

def test_avec_echec(simple_comme_bonjour):
    element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
    assert element == 'Je tres clair, Luc'

Comme pour avec unittest, le mot “Apres !” apparait bien malgré l’échec du 3eme test, on peut donc sans problème mettre des opérations de nettoyage dedans :

$ py.test -s
============================= test session starts ==============================
platform linux2 -- Python 2.7.6 -- py-1.4.23 -- pytest-2.6.1
collected 3 items

test_get.py Avant !
.Apres !
Avant !
.Apres !
Avant !
FApres !


=================================== FAILURES ===================================
_______________________________ test_avec_echec ________________________________

simple_comme_bonjour = ('pomme', 'banane')

    def test_avec_echec(simple_comme_bonjour):
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
>       assert element == 'Je tres clair, Luc'
E       assert 'Je laisse la main' == 'Je tres clair, Luc'
E         - Je laisse la main
E         + Je tres clair, Luc

test_get.py:33: AssertionError
====================== 1 failed,

Notez que j’utilise l’option -s, qui demande à pytest de ne pas capturer la sortie de mon programme. Sinon je ne verrai pas mes prints.

Contrairement aux setup et tear down, on n’est pas obligé d’utiliser une fixture pour un test donné, il suffit de ne pas l’ajouter en paramètre, et ça ne sera pas lancé pour ce test là. Mais parfois, on veut qu’une fixture soit lancée partout et on se fiche de la valeur de retour. Dans ce cas, on peut utiliser @pytest.fixture(autouse=True).

Outils

Pytest possède beaucoup d’extensions tierces parties qui fournissent des fixtures. Par exemple, pytest-django fournit des fixtures pour le client HTTP de test, l’override temporaire des settings et le reset de la base de données. La lib elle-même embarque quelques fixtures pratiques, dont :

[LOL, mon hispter vient de se passer la main dans les cheveux, avec une emphase consciencieuse, comme un chat fait sa toilette. Je m'attends à ce qu'il se lèche les poils d'ici quelques minutes.]

Plus que cela, pytest vient également avec une pléthore d’options, et je ne saurais trop vous conseiller de lire l’output de py.test --help afin de faire le tour de ce qui s’offre à vous. Quelques exemples :

Par ailleurs, pytest est très sociable et s’entend bien avec tous les outils de tests existants. Il va détecter les tests unittest, il prend en compte les fichiers tox.ini, et il existe même un plugin nose intégré.

D’ailleurs, j’utilise souvent un fichier tox.ini à la racine de mes tests contenant ceci :

[pytest]
addopts = --ignore="virtualenv" -s

Cela ajoute automatiquement ces options à la commande py.test, puisque je les utilise tout le temps. Ça m’évite de les taper.

400 lignes pour dire, n’utilisez pas le module unittest, utilisez pytest.

Dans la prochaine partie, je me chargerai de faire le point sur les doctests, puis nous glisserons sur des sujets plus philosophiques comme “quand tester”, “quoi tester”, “comment tester”, “que faire si je suis testé positif”, etc.

Si le dieu de la procrastination le veut, on fera même un petit tour par les mocks, les outils d’intégration continue et le test end2end. C’est pas forcément du test unitaire, mais c’est du test, et je vais pas renommer mon dossier maintenant que ces belles URLs sont référencées pour Google.

Ne pas faire d’enfant est-il égoïste ?

dimanche 10 août 2014 à 08:26

Je vais faire court car j’ai piscine!

On est dimanche matin, il fait beau dehors, les oiseaux gazouillent, un petit fond de musique des années 90.
J’ai ma tasse de café “mon grain de saveur“, je vérifie le chiffre d’affaire de mes sites internet, l’argent rentre, faudra peut-être investir ici après tout. J’habite en Asie depuis un an, il y a peu de mauvais et beaucoup de bon.

Dans un mois c’est la rentrée des classes en France, mes anciens amis sont quasiment tous en couple, avec 1 ou plusieurs enfants, certains sont déjà séparés avec gamins sur les bras, alternance des visites, etc. La routine quoi.

On ne peut pas être partout.

Moi je suis seul, je n’ai pas de femme, pas de gosse, pas de chien, même pas une petite mycose pour me tenir companie.
Le pire c’est que j’aime ça, si je veux partir à Tokyo demain à quoi dois-je penser ?
A qui vais-je demander la permission ? Personne.
Quelles sont mes contraintes ? Aucune
Quel est mon budget ? Très large.

Je suis le stéréotype du célibataire baroudeur, en couple 7 ans il y a plusieurs années de cela je pense avoir été vacciné, passé par toutes les étapes, de l’amour à la haine (dans l’ordre). Avec tous les clichés que l’on peut imaginer. Sans rien regretter au final.

Le tournant:

Seulement voilà, là où mes amis ont commencé à envisager de fonder une famille j’ai préféré investir dans ma vie professionnelle, de plus la crise était en route, la surpopulation me posait un problème de conscience. Ma vision de l’avenir se rapprochant toujours plus de ça.

Quelques années ont passé et j’ai vu mes amis “fonder” leur famille sur le si joli modèle que l’on nous vend, avec la télé 16:9 à crédit, la PS3 à crédit, l’iPhone à credit, le VolksWagen à crédit, le chien à crédit, bref une vie à crédit.
Le boulot dont ils parlent avec dégoût, “mais c’est alimentaire tu comprends, ça va changer” n’est pas amusant pour eux.
Les vacances ? “Je ne peux pas j’ai le crédit du nouveau VolksWagen que l’on vient d’acheter parce que tu comprends il a l’allume-cigare connecté à l’iPhone”. 35 000€, avec réduction de 5% sur le levier de vitesse en acajou véritable de Taiwan.

Certains s’en sortent mieux que d’autres quand même, soyons objectif.

Au final ?

Résulats des courses, ma mère me reproche d’être un égoïste car je n’ai pas fait d’enfant “comme tout le monde”, mes anciens amis me disent que j’ai de la chance mais qu’eux ne peuvent pas bouger à cause des enfants, des crédits, d’ailleurs c’est l’heure d’aller à Carrefour remplir le caddie.

Les enfants sont nécessaires à la survie de la race humaine, avoir des enfants est également un bonheur pour les parents, sans ça je ne serais pas là. Mais depuis 40 ans tout s’est accéléré.

– A l’heure où l’on soupçonne très fortement notre civilisation d’être condamnée à court terme du moins telle que nous la connaissons.

– Avec une démographie incontrôlable, déjà 7 Milliards.

Et oui ça fait mal au fion...

– Avec des contaminations nucléaires pas anodines.

– Avec une exploitation des ressources toujours plus intenses.

Je me pose souvent cette question:

Est-ce-que je suis égoïste de ne pas vouloir d’enfant ou est-ce que ce sont ceux qui ont des enfants qui sont égoïstes alors que la planète ne peut pas les accueillir convenablement ?

Je n’ai pas de problème avec ma mère.

Et vous ?

PS: Pour éviter le Fast-Trolling je précise que je ne suis pas contre la famille, bien au contraire, mais le mot famille n’a plus le même sens qu’il y a 40 ans il faut l’avouer. Avec un divorce au bout de 5 ans à la clef, des enfants à charge, c’est plus de la “consommation”.
Si cela ne tenait qu’à moi je ferais faire des tests de QI à ceux qui veulent avoir des enfants. J’ai eu droit à l’étiquette d’eugéniste en herbe avec cette phrase…

Recherche config pour relation sérieuse

vendredi 25 juillet 2014 à 15:14

RIP ma machine, mais ne tenant pas m’enliser dans le veuvage, je cherche à me recaser rapidement avec un ultra portable de bonne compagnie.

Malheureusement, le Samsung NP900X3C que je souhaitais demander en mariage est impossible à trouver. Les dernières boutiques en ligne sur lesquelles je l’ai vu en stock étaient soient très mal vues sur les forums, soient incapables de poursuivre la commande jusqu’à son terme (novotech…). Je pense que c’est parce que le modèle commence à être un peu vieux, et maintenant en informatique, dès que c’est #old, on euthanasie.

J’ai trouvé un modèle pas mal chez keynux qui s’en rapproche, mais allez savoir, ils ne l’ont plus. Or il fait très beau, aussi j’aimerais profiter de mon été comme tout geek normal en restant devant mon ordi. A la fnac, ils m’ont conseillé d’attendre fin août, mais j’aurais des coups de soleil d’ici là.

Aussi, si vous connaissez une config similaire, faites le moi savoir :

Préférablement connu pour fonctionner sous Linux et avec un son décent (de quoi écouter une vidéo en streaming sans grimacer). Une batterie avec 6h ou plus d’autonomie serait vraiment idéal mais je suis près à faire un compromis là dessus.

Je me branle du prix, du proc ou de la carte graphique. Je n’ai pas besoin de fonctionalités avancées ou de gadgets magiques. L’USB et le HDMI sont la seule connectique que j’utilise vraiment.

J’ai une bonne situation et fais très bien la cuisine. Merci.

Je passe

mardi 15 juillet 2014 à 11:07

Pas envie d’écrire en ce moment, donc pause.

flattr this!

Chercher dans plusieurs dicts à la fois avec ChainMap

vendredi 11 juillet 2014 à 10:09

Depuis Python 3.3 existe un nouvel outil pour travailler avec les dicos et j’étais complètement passé à côté : ChainMap.

Il permet de créer un objet qui se comporte comme un dict, mais qui va chercher dans plusieurs dictionnaires.

Un exemple sera plus clair.

Imaginez que vous ayez un système de configuration avec des valeurs par défaut :

default_config = {'DEBUG': False, 'HOST': 'localhost', 'PORT': 8080}

Puis votre utilisateur peut fournir un fichier de configuration settings.py qui contient :

DEBUG = True
PORT = 8000

Et avec un peu de parsing, vous le récupérez sous forme de dico :

import settings
user_config = {k: v for k, v in vars(settings).items() if k.isupper()}
## {'DEBUG': True, 'PORT': 8000}

Puis l’utilisateur peut passer la config via la ligne de commande, et une fois il fait :

--host 0.0.0.0

Et vous récupérez la config :

cmd_config = {"HOST": "0.0.0.0"}

Maintenant il faut prendre tout ça en compte. La ligne de commande écrase le fichier de config qui écrase les valeurs par défaut :

conf = {}
conf.update(default_config)
conf.update(user_config)
conf.update(cmd_config)
print(conf) # configuration finale
## {'DEBUG': True, 'HOST': '0.0.0.0', 'PORT': 8000}

Ça va marcher, mais ça a plusieurs défauts :

ChainMap résout ce problème en cherchant une clé dans une liste de dicos sous-jacent, mais en appliquant les modifications uniquement sur le premier dico.

>>> from collections import ChainMap
 
>>> conf = ChainMap({}, # <- ce mapping sera le seul modifié
                    # les clés seront cherchées dans cet ordre :
                    cmd_config, 
                    user_config, 
                    default_config)
 
>>> conf['HOST']
>>> '0.0.0.0'
>>> conf['DEBUG']
>>> True
>>> conf['PORT']
>>> 8000

Les dicos sont ici stockés par référence, ça ne prend pas de mémoire en plus, et si on modifie un dico :

user_config['DEBUG'] = False

Alors c’est reflété par ChainMap:

>>> conf['DEBUG']
False

Si on fait une modification, seul le dico le plus haut dans la chaine (ici notre dico vide), est modifié :

>>> conf["PORT"] = 7777
>>> conf
>>> ChainMap({'PORT': 7777}, {'HOST': '0.0.0.0'}, {'DEBUG': False, 'PORT': 8000}, {'DEBUG': False, 'HOST': 'localhost', 'PORT': 8080})

Et si on a besoin d’un contexte temporaire, on peut créer un enfant :

>>> sub_conf = conf.new_child()
>>> sub_conf
ChainMap({}, {'PORT': 7777}, {'HOST': '0.0.0.0'}, {'DEBUG': False, 'PORT': 8000}, {'DEBUG': False, 'HOST': 'localhost', 'PORT': 8080})

Cela crée un nouveau ChainMap, avec un dico vide en haut de la chaîne, qui permet donc de travailler temporairement avec de nouvelles valeurs, sans toucher au ChainMap d’origine.

flattr this!