Passer de unittest/nose à py.test

Passer à py.test

Sommaire

  • Qu'est-ce que py.test
  • Pourquoi py.test au lieu de unittest/nose
  • Comment passer à py.test

Quoi

Qu'est-ce que py.test

Par Holger Krekel, l'auteur entre autres de Tox, pypy, devpi

Un équivalent à Unittest, mâture, avec une excellent API et d'excellentes possibilité de personalisation.

Toujours activement développé, et très bien documenté.

Installation : pip install pytest

Fonctionalités

Écriture de tests sans boilerplate:

  • Avec py.test : assert 1 + 1 == 2
  • Avec unittest : self.assertEqual(1 + 1, 2)

py.test est compatible avec votre code actuel : unittest, nose, doctest, autres

Fonctionalités

  • Système de plugins et de personalisation avancé
  • Liste des 10 tests les plus lents : --duration=10
  • S'arrêter après le premier échec : -x (ou --maxfail=n)
  • Direct dans pdb : --pdb
  • Direct sur pastebin : --pastebin=failed
  • Modification des tracebacks : --showlocals --tb=long

Fonctionalités

  • Marquer certains tests : @pytest.mark.functional
  • Lancer seulement certains tests
    par nom : py.test -k test_foo
    par chemin : py.test tests/bar/
    par module : py.test test_baz.py
    par « mark » : py.test -m functional ou py.test -m "not functional"

rapports d'échecs intelligents :

        def test_eq_dict(self):
        >       assert {'a': 0, 'b': 1, 'c': 0} == {'a': 0, 'b': 2, 'd': 0}
        E       assert {'a': 0, 'b': 1, 'c': 0} == {'a': 0, 'b': 2, 'd': 0}
        E         Omitting 1 identical items, use -v to show
        E         Differing items:
        E         {'b': 1} != {'b': 2}
        E         Left contains more items:
        E         {'c': 0}
        E         Right contains more items:
        E         {'d': 0}
        E         Use -v to get the full diff
      

Injection de dépendance

Fixtures par injection de dépendance, peuvent être utilisées à la place de (setUp|tearDown)(function|class|module|package)

        def test_view(rf, settings):
            settings.LANGUAGE_CODE = 'fr'
            request = rf.get('/')
            response = home_view(request)
            assert response.status_code == 302
      

Les paramètres

Écrire une seule fois le test, le lancer avec plusieurs entrées

        @pytest.mark.parametrize(("input", "expected"),
                                 [("3+5", 8), ("2+4", 6)])
        def test_eval(input, expected):
            assert eval(input) == expected
      

Configuration : pytest.ini

Au niveau global, dans le fichier pytest.ini : principalement pour la déclaration des « mark » et les options par défaut

        [pytest]
        addopts = --reuse-db --tb=native
        python_files=test*.py
        markers =
            es_tests: mark a test as an elasticsearch test.
      

Configuration : conftest.py

Au niveau local, impacte le répertoire en cours et ses descendants : pour les fixtures, les plugins, les hooks

        import pytest, os
         
        def pytest_configure():
            # Shortcut to using DJANGO_SETTINGS_MODULE=... py.test
            os.environ['DJANGO_SETTINGS_MODULE'] = 'settings_test'
            # There's a lot of python path hackery done in our manage.py
            import manage  # noqa
      

Plugins

Il existe de très nombreux plugins intégrés, mais aussi de la communauté 

pytest-django : réutiliser la DB

  • Réutiliser la DB : --reuse-db
  • Forcer la création de la DB : --create-db
  • Options par défaut dans le fichier pytest.ini
        [pytest]
        addopts = --reuse-db
      

Autoriser l'accès à la DB explicitement

        @pytest.mark.django_db
        def test_avec_db(self):  # fonction/méthode
      
        @pytest.mark.django_db
        class TestFoo(TestCase):  # classe
      
        pytestmark = pytest.mark.django_db  # module
      

Autoriser l'accès à la DB tout le temps

dans conftest.py

        def pytest_collection_modifyitems(items):
            for item in items:
                item.keywords['django_db'] = pytest.mark.django_db
      

Pourquoi

py.test est pythonique

Utilisation de simples assert

Pas de camelCase comme self.assertEqual

  • assert 1 == 2 ou self.assertEqual(1, 2)
  • assert True ou self.assertTrue(True)
  • assert not None ou self.assertIsNotNone(None)

py.test est activement maintenu

Oui mais pourquoi changer ?

Pourquoi se passer de quelque chose de

  • mieux maintenu
  • compatible avec les tests actuels
  • tous les tests dans l'avenir pourront être "py.test"

Naming is hard

nose versus python + test

Pourquoi ne pas changer ?

Pas d'injection de dépendance (et donc pas non plus de paramètres), bien qu'on puisse utiliser les fixtures autouse

Pas de fixture bundling, donc les tests sont plus lents, mais Django 1.8 test case data setup et la possibilité de faire des builds en parallèle avec Travis

Comment

Fichiers de tests

find . -name test* | wc -l

248

TestCases

ag "class Test" | wc -l

472

Tests

ag "def test_" | wc -l

3626

Assertions

ag "self.assert|ok_|eq_" | wc -l

6210

LAULE

Je l'ai déjà dis deux fois, py.test est compatible avec votre code actuel.

la preuve

Nan mais en vrai pour l'ajout d'un fichier de fixtures de données.

Utilisation

py.test versus
REUSE_DB=1 python manage.py test --noinput --logging-clear-handlers --with-id

Autoriser la DB

Rajout de @pytest.mark.django_db sur notre TestCase de base

Rajouts de pytestmark = pytest.mark.django_db dans les 35 fichiers qui en ont besoin

        def pytest_collection_modifyitems(items):
            for item in items:
                item.keywords['django_db'] = pytest.mark.django_db
      

Nettoyage

Il manquait des appels à super() dans les (setUp|tearDown)(class), magouille sur le PYTHONPATH dans manage.py :

        import manage.py
         
        @pytest.fixture(autouse=True, scope='session')
        def _load_testapp():
            installed_apps = getattr(settings, 'INSTALLED_APPS')
            setattr(settings, 'INSTALLED_APPS', installed_apps + extra_apps)
            django.db.models.loading.cache.loaded = False
      

HASHSEED

tox permet d'avoir un PYTHONHASHSEED différent à chaque run. Mais du coup, il faut faire attention aux tests sur les dictionnaires (non ordonnés !)

  • dictionnaires eux-mêmes
  • urls avec paramètres (construits à partir de dicts)
  • Dump ou load de json
  • Elasticsearch et ses queries
  • csv.DictReader

Bonus

Pas de recompilation et récupération des statics pour les tests :

        @pytest.fixture(autouse=True)
        def mock_inline_css(monkeypatch):
            import amo.helpers
            monkeypatch.setattr(amo.helpers, 'is_external',
                                lambda css: True)
      

Bonus : builds parallèles

Avec py.test, tox et travis, c'est un jeu d'enfant

split builds

De plus de 50 minutes à moins de 20

Conclusion

pip install pytest && py.test

formation à py.test : http://www.merlinux.eu/~hpk/testing.pdf

ces slides : http://mathieu.agopian.info/presentations/2015_05_djangocong/

Questions !?