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 depuiscapsys.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. Contientmonkeypatch.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.
Roberto et gabriela sont en concert en novembre a paris :) le 26 je crois et merci pour l’article
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.
@bussiere: j’ai vu, je suis passé devant l’affiche :)
@Raphi, ah oui, faut bien effectivement :)
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
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”…
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.
La suite du dossier, <3.
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! ;)
@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. :-)
On attend la suite :'(
Attention question conne: est-ce qu’il faut documenter les fonctions de test? Si oui on pourrait, dans une certaine mesure, lancer des doctests..!
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.