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.