Tests avancés avec Django

Coast Guard 47' Motor Lifeboat in Morro Bay, CA 04 Dec 2007

Imaginez un combat homérique entre les forces du bien et du mal, luttant pour la domination du monde. Dans le camp des forces du mal : la procrastination, Internet Explorer et les cycles en V ; dans le camp des forces du bien : Python, le café et les tests automatisés.

Tout le monde aujourd'hui est d'accord pour dire qu'écrire des tests est probablement la tâche la plus efficace quand il s'agit de garantir un code robuste et maintenable. (Enfin, presque tout le monde ; ceux qui dénigrent les tests sont en général ceux qui sont trop flemmards pour en écrire.)

À ce titre, Django se montre exemplaire, et la documentation relative aux tests est aussi complète que détaillée. Seulement voilà, quiconque a déjà travaillé sur un projet Django pendant plus de quelques semaines sait que de nombreuses questions sont laissées de côté. Et notre développeur / testeur est réduit à parcourir les bas-fonds du web pour trouver réponse à ses questions.

  • Comment écrire une suite de tests maintenable ?
  • Comment tester des fonctionnalités qui utilisent javascript intensément ?
  • Comment tester des fonctionnalités qui font appel à des web services et sont fortement couplés ?
  • Comment être certain que les tests qui tournent sur ma machine fonctionneront aussi sur la prod ?

Sans plus tarder, voici quelques réponses à ces questions.

Comment écrire des tests maintenables dans le temps ?

Les tests permettent d'écrire du code maintenable. Mais les tests sont du code, et eux aussi devront être maintenus. Quand un projet s'aggrandit, gagne en ampleur et en complexité, la taille de la suite de tests augmente en conséquence, et le moindre refactoring augmente les chances de devoir mettre à jour plusieurs tests.

Mais le pire, ce sont les fixtures. Si un jour je vais en enfer, je suis sûr que le démon m'aura réservé un jeu de fixtures à maintenir en guise de damnation éternelle. Quand les données de tests sont réparties à travers des milliers de lignes de yaml ou de json (ou de xml, pour ceux qui assument leurs tendances perverses), la moindre modifications d'un modèle prend des proportions épiques, digne d'inspirer des épopées aux ménestrels en collants blancs. L'autre ennui avec les fixtures, c'est qu'elles rendent le traitement des tests beaucoup plus long et lourd.

find . -name "*yaml" -exec cat {} \; | wc --lines
3226
# gloups !

Des fixtures sans fioritures

Première solution pour ne pas galérer avec les fixtures : n'en écrivez pas. Que chaque tests créé les données qui lui sont nécessaires. Évidemment, ce n'est pas toujours évident lorsque le modèle augmente en complexité. C'est alors qu'intervienne des bibliothèques comme Factory Boy. Factory Boy permet de générer des fixtures à la volée de manière simple et efficace, en définissant des factories pour différents modèles.

Prenons l'exemple d'un utilisateur qui peut consulter une liste de ses contacts dans son carnet d'adresse.

pip install factory_boy

Définissons deux factories, une pour créer le compte utilisateur, une pour créer les contacts.

# accounts/tests/factories.py
import factory

from accounts.models import User


class UserFactory(factory.DjangoModelFactory):
    FACTORY_FOR = User

    phone = '+33612345678'
    email = factory.Sequence(lambda n: 'toto{0}@tata.com'.format(n))
    password = '1234'
    is_active = True

    @classmethod
    def _prepare(cls, create, **kwargs):
        password = kwargs.pop('password', None)
        user = super(UserFactory, cls)._prepare(create, **kwargs)
        if password:
            user.set_password(password)
            if create:
                user.save()
        return user
# contacts/tests/factories.py
import factory

from accounts.tests.factories import UserFactory
from contacts.models import Contact


class ContactFactory(factory.DjangoModelFactory):
    FACTORY_FOR = Contact

    user = factory.SubFactory(UserFactory)
    first_name = factory.Sequence(lambda n: 'John{0}'.format(n))
    last_name = factory.Sequence(lambda n: 'Doe{0}'.format(n))
    mobile = '+33612345678'

L'utilisation de ces factories facilite grandement les tests, qui ne nécessitent plus de maintenir des fixtures statiques.

# contacts/tests/test_views.py
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.utils.translation import activate

from accounts.tests.factories import UserFactory
from contacts.tests.factories import ContactFactory


class ContactViewsTests(TestCase):
    def setUp(self):
        self.user = UserFactory.create()
        self.client.login(username='toto@tata.com', password='1234')
        self.url = reverse('contact_list')
        activate('en')

    def test_empty_contact_list(self):
        res = self.client.get(self.url)
        self.assertContains(res, 'There are no contacts here')

    def test_contact_list(self):
        ContactFactory.create(user=self.user)
        ContactFactory.create(user=self.user)
        ContactFactory.create(user=self.user)

        res = self.client.get(self.url)
        self.assertContains(res, 'John1 Doe1')
        self.assertContains(res, 'John2 Doe2')
        self.assertContains(res, 'John3 Doe3')

    def test_delete_contacts(self):
        c1 = ContactFactory.create(user=self.user)
        c2 = ContactFactory.create(user=self.user)
        c3 = ContactFactory.create(user=self.user)

        res = self.client.post(self.url, {'_delete': 'delete',
                                          'id': [c1.id, c2.id]},
                               follow=True)

        self.assertNotContains(res, c1.full_name)
        self.assertNotContains(res, c2.full_name)
        self.assertContains(res, c3.full_name)
        self.assertContains(res, 'Selected reminders were deleted')

    def test_create_contact(self):
        res = self.client.post(self.url, {'_create': 'create',
                                          'first_name': 'Harry',
                                          'last_name': 'Cotta',
                                          'mobile': '+33612345678'},
                               follow=True)
        self.assertContains(res, 'Your new contact was created')
        self.assertContains(res, 'Harry Cotta')

    def test_create_invalid_contact(self):
        res = self.client.post(self.url, {'_create': 'create',
                                          'first_name': 'Harry',
                                          'last_name': 'Cotta',
                                          'mobile': ''},
                               follow=True)
        self.assertNotContains(res, 'Your new contact was created')
        self.assertContains(res, 'This field is required')

Ainsi, une modification du modèle ne nécessitera plus qu'une adaptation de la Factory correspondante, au lieu d'une mise à jour systèmatique de toutes les fixtures pré-générées.

Organiser ses tests

L'autre gros problèmes des tests, c'est qu'il devient difficile de s'y retrouver quand ils commencent à grossir en nombre. Par défaut, Django créé un seul fichier tests.py dans chaque application générée. Nous nous empresserons de le supprimer, et de le remplacer par un répertoire tests, qui contiendra les tests de l'application répartis dans plusieurs fichiers.

Pendant un temps, j'ai essayé de ranger les tests par type : fonctionnels et unitaires. C'est une catégorisation qui manque de précision et qui n'est pas suffisante. J'ai fini par adopter la méthode proposée par Audrey Roy et Daniel Greenfeld dans Two Scoops of Django (un livre plein de trés bons conseils et une saine lecture), à savoir séparer les tests par fichier testé.

  • test_models.py
  • test_views.py
  • test_forms.py
  • test_signals.py
  • test_js.py
  • test_commands.py
  • etc.

Ce type de découpage permet de s'y retrouver trés facilement, d'arriver à une bonne couverture de tests, et d'éviter les redondances.

Je peux pas tester, j'ai plein de Javascript

De simples tests unitaires et fonctionnels deviennent insuffisants lorsque votre application fait appel à du Javascript, et que de plus en plus de traitements sont effectués côté client. Il va donc falloir dégainer l'artillerie lourde et utiliser des outils un peu plus puissants.

Il existe différents outils qui permettent de prendre le contrôle d'un véritable navigateur pour écrire des tests dans les conditions les plus « réelles possibles ». Citons l'excellentissime Casperjs. Grâce à Casper, il est possible d'écrire des tests qui s'exécuteront dans le contexte d'un véritable navigateur. La partie navigateur est elle-même fournie par PhantomJS, outil de script JS d'un moteur Webkit.

Il est tout à fait possible d'utiliser CasperJS pour écrire des tests d'un projet Django, mais cela ne va pas sans poser quelques problèmes. D'abord, il faudra assurer la liaison entre Python et Javascript (par exemple grâce au projet Django-casper). Ensuite, il faudra évidemment maintenir les deux suites de tests en parallèle.

Pour ma part, j'ai tendance à privilégier plus de cohérence, et j'utilise en général Sélénium + PhantomJS. Notez que Phantom doit être déjà installé, ce qui est relativement simple. Puis :

pip install selenium

En guise d'exemple, voici les tests d'un formulaire de paiement par CB géré par Paymill. Une api javascript valide les champs de formulaires en local avant la soumission du formulaire.

# /accounts/tests/test_js.py

from django.test import LiveServerTestCase
from django.utils.translation import activate

from selenium.webdriver.phantomjs.webdriver import WebDriver
from selenium.common.exceptions import NoSuchElementException


class RegisterFormTests(LiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        cls.selenium = WebDriver()
        super(RegisterFormTests, cls).setUpClass()

    @classmethod
    def tearDownClass(cls):
        cls.selenium.quit()
        super(RegisterFormTests, cls).tearDownClass()

    def setUp(self):
        self.selenium.get('%s%s' % (self.live_server_url, '/account/register/'))
        activate('en')

    def test_payment_fields_have_no_names(self):
        """Payment fields exists, but have no names.

        This is required by the Paymill api, so payment data does not
        crosses our server.
        """
        self.selenium.find_element_by_class_name('card-number')
        try:
            self.selenium.find_element_by_name('card-number')
            self.fail('The element should have no name')
        except NoSuchElementException:
            pass

    def test_error_message_is_hidden(self):
        error = self.selenium.find_element_by_id('payment-errors')
        self.assertFalse(error.is_displayed())

    def test_missing_card_number_raises_error(self):
        self.selenium.find_element_by_id('submit-btn').click()

        error = self.selenium.find_element_by_id('payment-errors')
        self.assertTrue(error.is_displayed())
        self.assertEqual(error.text, 'Invalid card number')

    def test_missing_cvc_raises_error(self):
        self.selenium.find_element_by_class_name('card-number') \
            .send_keys('4111111111111111')
        self.selenium.find_element_by_id('submit-btn').click()

        error = self.selenium.find_element_by_id('payment-errors')
        self.assertTrue(error.is_displayed())
        self.assertEqual(error.text, 'Invalid validation code')

    def test_missing_expiration_date_raises_error(self):
        self.selenium.find_element_by_class_name('card-number') \
            .send_keys('4111111111111111')
        self.selenium.find_element_by_class_name('card-cvc') \
            .send_keys('123')
        self.selenium.find_element_by_id('submit-btn').click()

        error = self.selenium.find_element_by_id('payment-errors')
        self.assertTrue(error.is_displayed())
        self.assertEqual(error.text, 'Invalid expiration date')

Remplacer du code

Tiens, puisqu'on parle de paiement par carte bleue, imaginons le cas d'une application web dont l'utilisation est payante. Le formulaire d'enregistrement permet de saisir ses informations de paiement. La procédure de création de compte fonctionne alors en deux étapes :

  1. Envoyer les infos au prestataire de paiement
  2. Si réponse ok, créer le compte du client

L'ennui, c'est que je ne veux pas dépendre d'une api extérieure pour mes tests. Pour résoudre ce problème, nous allons utiliser la libraire Mock. Mock permet de patcher voir remplacer certaines portions du code à tester.

Voici le code à tester.

# accounts/views.py
@render_to('register.html')
def register(request):
    form = RegistrationForm(request.POST or None)

    if form.is_valid():
        token = request.POST['paymillToken']

        try:
            # Use Paymill API to process payment
            client = card = subscription = None
            py = Pymill(settings.PAYMILL_PRIVATE_KEY)
            client = py.new_client(form.cleaned_data['email'])
            card = py.new_card(token, client.id)
            offer_id = settings.PAYMILL_OFFER_ID
            subscription = py.new_subscription(client.id, offer_id, card.id)
        except Exception:
            logger.error('Payment error bla bla bla')
            message = _('There was a problem processing your credit card. '
                        'Your account was not created. Please, try again in '
                        'a few minutes or with different payment informations.')
            messages.error(request, message)
            return {'form': form}

        # So payment was created, and form data is valid
        # Let's create this user account
        data = form.cleaned_data
        user = User.objects.create_user(data['email'], data['phone'])
        message = _('Congratulations! Your account was created. You will receive '
                    'your activation email in  a few seconds.')
        messages.success(request, message)
        return redirect('login')

    return {
        'form': form,
        'PAYMILL_PUBLIC_KEY': settings.PAYMILL_PUBLIC_KEY,
    }

La classe Pymill est responsable de la communication avec Paymill. C'est cette classe que nous allons remplacer pour nos tests.

# accounts/tests/test_views.py
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.utils.translation import activate
from django.contrib.auth import authenticate

from mock import patch, Mock

from accounts.models import User
from accounts.tests.factories import UserFactory


class Empty:
    """Empty object to use as return data in the Paymill mock."""
    id = 'value'
mock_data = Empty()

paymill_mock = Mock()
instance = paymill_mock.return_value
instance.new_client.return_value = mock_data
instance.new_card.return_value = mock_data
instance.new_subscription.return_value = mock_data


class RegisterTests(TestCase):

    def setUp(self):
        self.url = reverse('register')
        self.data = {
            'email': 'toto@tata.com',
            'phone': '+33612345678',
            'paymillToken': 'random_token',
        }

    @patch('pymill.Pymill', paymill_mock)
    def test_signing_up_creates_new_account(self):
        self.assertEqual(User.objects.all().count(), 0)
        res = self.client.post(self.url, self.data)
        self.assertEqual(User.objects.all().count(), 1)
        self.assertEqual(User.objects.all()[0].email, self.data['email'])
        self.assertRedirects(res, reverse('login'))

Aucune API n'a été blessée durant ces tests. Sympa, non ?

Votre code fonctionne, mais est-il beau ?

Le ramage n'est pas tout, encore faut-il que le plumage s'y rapporte. Heu… En bref, même si votre code fonctionne correctement, encore faut-il qu'il soit lisible et si possible respecte la pep8.

Nous allons utiliser l'outil flake8, qui combine pep8 et pyflakes.

pip install flake8

Attention, si vous lancez ce script sur un projet existant alors que vous ne vous étiez jamais soucié avant de respecter la pep8, vous risquez de vous faire mal aux yeux :

flake8 --exclude=migrations,urls.py --ignore=E501 .
./accounts/views.py:26:17: E127 continuation line over-indented for visual indent
…

Évidemment, il faut se contraindre à lancer la vérification avant chaque commit ce qui peut vite devenir laborieux. Pour ceux qui utilisent l'éditeur des champions (vim, of course), je recommande l'installation de syntastic, qui lance un validateur python à chaque enregistrement de fichier. Very pratique. On peut aussi mettre en place des pre-commit hooks, c'est selon.

Une bonne couverture de code

Écrire des tests, c'est bien, encore faut-il ne pas oublier de larges portions de votre application. Pour s'assurer que toute votre base de code est couverte par des tests, nous allons utiliser l'outil coverage.

pip install coverage

Cet outil permet de générer des rapports qui indiquent le pourcentage de code couvert par votre suite de tests. À la racine du projet Django :

$ coverage run --source='.' manage.py test
$ coverage report

accounts/forms                                             38      2    95%
accounts/models                                            56      8    86%
accounts/urls                                               3      0   100%
accounts/views                                             58      6    90%
…

Combiner et automatiser tout ça

Impossible, quand un projet prend de l'ampleur, de faire tourner tous les tests et validateurs avant chaque commit (sauf quand on cherche une excuse pour ne pas travailler). C'est pour ça qu'on a inventé l'intégration continue.

Si votre projet est hébergé sur Github, la mise en place d'une plate-forme d'intégration continue est triviale grâce à Travis ci.

Avant toute chose, créons un fichier de configuration à la racine de notre projet :

# .travis.yml
language: python
python:
  - "2.6"
  - "2.7"
install:
  - "pip install -r requirements/test.txt --use-mirrors"
script:
  - export DJANGO_SETTINGS_MODULE=settings.test
notifications:
  - recipients:
    - mon@adresse.com
  - on_success: change
  - on_failure: always

Il y aura bien évidemment des adaptations à faire en fonction du rangement de votre projet Django.

Une fois commité, créez votre compte sur Travis, rendez-vous dans les paramètres de votre compte, et activez le dépôt concerné. À chaque commit, un ou plusieurs builds seront lancés en fonction de votre fichier de configuration. Il est ainsi possible de créer des matrices de builds (combiner plusieurs versions de python avec plusieurs moteurs de base de données différentes, etc.)

Exemple de build avec Travis CI

Il manque deux choses à notre processus de build : l'intégration de flake8 et coverage.

Pour flake8, inutile de faire de la dentelle : si notre projet contient des erreurs de validation, le build doit échouer.

Pour coverage, Travis ne permet malheureusement pas de présenter des rapports d'évolution des statistiques de couverture. Nous allons donc créer un compte sur Coveralls.io qui palie admirablement à cette lacune. L'intégration avec Travis est simplissime, il suffit de faire appel à coveralls à la fin du build.

Voici notre fichier requirements mis à jour

# requirements/test.txt
-r base.txt

selenium==2.33.0
coverage==3.6
django-discover-runner==0.4
flake8==2.0
coveralls==0.2
mock==1.0.1
factory-boy==2.0.2

Et notre .travis.yml à jour :

language: python
python:
  - "2.6"
  - "2.7"
install:
  - "pip install -r requirements/test.txt --use-mirrors"
script:
  - export DJANGO_SETTINGS_MODULE=settings.test
  - coverage run --source='.' manage.py test
  - flake8 --exclude=migrations,urls.py --ignore=E501 .
after_success:
  - coveralls
notifications:
  - recipients:
    - mon@adresse.com
  - on_success: change
  - on_failure: change
Statistiques de couverture avec Coveralls

Tout de suite, ça donne envie d'écrire des tests, non ?