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


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 :

  • De découvrir les tests automatiquement, et facilement.
  • D’écrire le moins de boiler plate possible.
  • D’avoir un style de test naturel.

Ç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 :

  • capsys : permet de capturer ce qui a été écrit sur stdin/out et les lire depuis capsys.readouterr. Pratique pour tester une lib qui print des choses sans les retourner.
  • capsys : permet de capturer ce qui a été écrit vers un file descriptor.
  • monkeypatch : permet de modifier un objet, la modification sera inversée automatiquement à la fin du test. Contient monkeypatch.setattr, monkeypatch.setitem, monkeypatch.setenv, monkeypatch.syspath_prepend, monkeypatch.chdir et quelques autres.
  • tmpdir : met à disposition un dossier temporaire avec un chemin unique pour le test donné.

[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 :

  • -k EXPRESSION : lance uniquement les tests qui contiennent cette chaîne. Pratique quand on a beaucoup de tests longs et qu’on travaille sur un en particulier.
  • -x : s’arrêter à la première erreur. Pour le debug, ça évite de se taper tous les tests après ce qu’on veut explorer.
  • --doctest-modules: lance les doctests de tous les fichiers *.py trouvés récursivement dans le dossier.
  • --ignore=PATH: ignore un chemin. Je l’utilise souvent pour éviter que pytest n’aille lancer les tests des libs de mon virtualenv.

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.

12 thoughts on “Un gros guide bien gras sur les tests unitaires en Python, partie 3

  • bussiere

    Roberto et gabriela sont en concert en novembre a paris :) le 26 je crois et merci pour l’article

  • Raphi

    Cool, sam et max ressucite \o/

    Jamais pris le temps de m’intéresser plus que ça à py.test mais c’est vrai que ca à l’air chouette. Une petite question, par contre:

    Quand tu dis “pas besoin d’import”, pour le fait qu’on importe rien de pytest, ok, il fait sa tambouille tout seul pendant l’execution des tests. Tes fonctions à toi par contre (dans tes exemples, get), faut bien que tu les importe explicitement, non ? Sinon, même à coup de magie, je vois pas comment il peut deviner d’ou elle vient, sachant que tu pourrais très bien avoir plusieurs fois les memes noms de fonction dans des modules differents.

  • Sam Post author

    @bussiere: j’ai vu, je suis passé devant l’affiche :)

    @Raphi, ah oui, faut bien effectivement :)

  • Romain

    J’ai du mal à me mettre à py.test malgré le prosélytisme incessant de S&M :). Je suis perverti par trop d’utilisation de JUnit ce qui me rend aficionado d’unittest. Je vais me pencher dessus à nouveau mais mon ressenti aujourd’hui c’est :

    * la sortie (par défaut ?) est super verbeuse. Après, unittest est pas tout le temps propre non plus, notamment quand le code fait des print ou des traces de warning (avec requests sur python 3.4, c’est moche par exemple).
    * Il faut faire un pip install, donc penser à mettre un requirement.txt, etc… (flemme)
    * L’utilisation des fixtures pour les setup et teardown, ça me déroute.

    L’auto discovery, tu l’as avec unittest quand tu utilise la CLI, avec une bonne granularité en python 3 :


    python -m unittest module.ClasseDeTest

  • Fab

    Yes, merci beaucoup, j’attendais la suite de cette série avec impatience. Merci encore pour ce site; de plus en plus lorsque je fais une recherche dans google, c’est avec “site:sametmax.com”…

  • foxmask

    j’ai réussi à me mettre aux tests unitaires (mode persevere apres des années;) avec django à partir de ce tuto qui a un an tout pile. J’attends le vôtre du coup pour voir si j’ai tout bien comprendu la mécanique ;)
    C’est long à mettre en place quand on ne fait pas de TDD dès le début, mais au moins ça marche bien, surtout couplé à coverage pour suivre l’évolution des TU.

  • foxmask

    j’ai pu lire tranquille l’article dans le air euh air, du coup je rebondis sur l’appareil tof avec péloches – ba c’est très pratique quand t’as pas envie que tes gosses se le fassent braquer quand ils sont en voyage “pédagogique” à l’étranger – elles étaient couleurs et réussies – moqueur! ;)

  • Stéphane

    @bussiere: Roberto y Gabriella passe un peu partout en France (et à côté aussi). Amiens, Rouen, Marseille, à côté de Bordeaux, Lyon, Rennes, Zurich, etc.
    Pas besoin d’aller à Paris pour ça si on n’est pas du coin. :-)

  • furankun

    Attention question conne: est-ce qu’il faut documenter les fonctions de test? Si oui on pourrait, dans une certaine mesure, lancer des doctests..!

  • Sam Post author

    On peut documenter ses fonctions de tests pour que quand il foire on sache ce qui a foirer : les libs s’en servent pour afficher du contexte. Mais en général, un nom explicite suffit.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.