Mise en œuvre de tests d'intégration avec Django

Django est un framework de de développement pour le web, qui permet de développer rapidement des applications de qualité. Du reste, il propose une sur-couche intéressante au framework de test unittest de Python : nous allons l'exploiter démontrer la mécanique des tests d'intégration, selon une approche TDD.

Si vous débutez sous Django, vous pouvez consulter mon tutoriel de formation Django !

Présentation du cas concret : UPJV-News

Nous allons illustrer la mise en œuvre des tests d'intégration sur un exemple concret, celui d'un quotidien (fictif) en ligne, nommé UPJV-News.

Chaque jour, le journal UPJV-News propose une une constituée sur la base d'articles rédigés dans la nuit par des journalistes du quotidien. En complément, tout au long de la journée, des dépêches AFP sont affichées en « direct » sur le site.

Un accès « Premium » permet aux clients enregistrés de consulter l'ensemble des articles, alors que les visiteurs non authentifiés ne peuvent en consulter qu'un sous-ensemble.

Cas d'utilisation

Voici une vue globale des fonctionnalités du système :

Use cases UML
Diagramme de cas d'utilisation UML

Wireframe

Ce dessin d'interface (wireframe) permet de comprendre comment se présentera l'application :

Wireframes
Vue d'ensemble de l'interface de l'application (wireframe)

Technologies utilisées

Le site du journal est un front office développé en Python, avec le framework Django. Les données utilisées sont stockées dans une base de données relationnelle, qui est mappée aux objets métier de l'application grâce à un ORM.

Certaines données proviennent de sources externes au système : c'est le cas des news AFP, qui sont récupérées toutes les 5 minutes via une tâche planifiée qui interroge une API exposée par l'AFP sur ses serveurs (appel de web service authentifié permettant de récupérer les dépêches).

Développement et tests selon une approche TDD

Nous allons dans cette section montrer comment construire ce projet depuis zéro, en adoptant une approche dirigée par les tests (TDD) : le développement sera piloté par les tests.

Préambule

Ce que nous devrons tester…

Nous éditons le site du journal : notre périmètre de test se cantonne donc à ce site, et à ses routines d'alimentation. Il n'est bien entendu pas question ici de tester l'API de l'AFP !

Attention Un des plus gros écueils en matière de testing unitaire et d'intégration et de tout mélanger en voulant tout tester en même temps. Pensez-y !

Nous testerons donc le cœur du système :

  • Affichage des articles et des pages
  • Problématiques d'accès restreint
  • Enregistrement en base des news en provenance du WS de l'AFP

Ces problématiques font intervenir différents composants, comme les accès aux bases de données, aux API, etc. : c'est pour cette raison que nous parlons de tests d'intégration.

Quelques rappels sur le TDD

Pour rappel, la démarche de travail dans le cadre d'une stratégie TDD est la suivante :

  1. Écriture d'un cas de test
  2. Vérification que ce cas de test échoue (sinon il ne sert à rien, étant donné que le code n'est pas encore écrit !)
  3. Écriture du code métier associé
  4. Exécution du test et vérification que tout se passe bien

Le framework Django est fondé sur le pattern MVC : modèle/vue/contrôleur. Nous allons donc écrire ces différents composants.

Écriture des modèles

Commençons par écrire les modèles qui décrivent les concepts de notre application !

models.py
class Article(models.Model):
    title = models.CharField(max_length=255)
    author = models.CharField(max_length=100)
    summary = models.TextField()
    content = models.TextField()
    publication_date = models.DateTimeField()
    premium = models.BooleanField(default=False)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    def __unicode__(self):
        return self.title

class News(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    publication_date = models.DateTimeField()
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    def __unicode__(self):
        return self.title

Écriture des patterns d'URL

D'après les spécifications décrites plus haut, notre site doit pouvoir présenter une liste d'articles et des news, ainsi qu'une page spécifique par article (consultation de l'article complet).

Décrivons donc les patterns d'URL permettant d'accéder à ces ressources :

urls.py
urlpatterns = patterns('',
    url(r'^$', 'journal.views.articles_list', name='articles_list'),
    url(r'^article,(?P[0-9]+).html$', 'journal.views.article', name='article')
)

Création de quelques données de test…

Pour tester notre module Article, nous allons utiliser une fixture décrivant un état de BDD, de manière à ne pas avoir à mettre en place manuellement un jeu de test.

Cette fixture sera chargée dans notre classe de test, de la manière suivante :

tests.py
class ArticlesTest(TestCase):

    fixtures = ['journal_dataset.json']

    # TODO Écrire les tests...

Processus de test-développement : écriture des tests et du code

Nous allons tenter de décrire et tester les principaux use-cases de ce module : consultation de la liste des articles, consultation d'un article public, consultation d'un article privé (avec profil premium ou non), consultation d'un article inexistant

Tests pour le use case « Affichage de la liste des articles »

Ce use case est simple : l'utilisateur consulte une page présentant une liste d'articles. Pour cela, derrière la scène, notre application doit router un pattern d'URL vers un contrôleur (que l'on appelle view en Django, ne soyez pas perturbés !), contrôleur qui lui même fera appel à un modèle pour récupérer les données nécessaires (les articles) et qui déclenchera ensuite le rendu d'une vue.

Nous allons donc commencer par nous assurer que :

  • La vue articles_list.html est utilisée.
  • La liste d'articles (un objet QuerySet) est fournie dans le contexte.
  • Le code de retour HTTP de la page est bien 200 (OK !).

Voici un premier test case qui nous permettra de tester tout ceci :

tests.py
def test_index(self):
    """
    Affichage de la page d'accueil : liste des articles.
    """
    response = self.client.get(reverse('journal.views.articles_list'))
    self.failUnless(isinstance(response.context['articles'], QuerySet))
    self.assertTemplateUsed(response, 'journal/articles_list.html')
    self.failUnlessEqual(response.status_code, 200)

Notez bien la puissance du framework de test, qui nous permet de tester l'intégration de différents composants de manière très simple et efficace.

  • La première ligne nous permet de demander au client de test de charger la page qui nous intéresse. Puisque nous souhaitons ne pas nous répéter (application du principe DRY), nous de hard-codons pas l'URL, mais la calculons via une fonction de routage inverse (nous fournissons le contrôleur, la fonction reverse retourne l'URL !).
  • La deuxième ligne est une assertion failUnless, en d'autre terme, un test qui échoue si une condition n'est pas vérifiée. En l'occurrence, notre condition est que la variable article présente dans le contexte de la réponse soit de type QuerySet.
  • La troisième ligne nous permet de nous assurer que la vue (template en jargon Django) utilisée pour générer la page est bien journal/articles_list.html et pas autre chose.
  • Enfin, la quatrième ligne nous permet d'être certains que le code HTTP de réponse est bien 200, qui signifie « OK, tout s'est bien passé ! ».

Important Encore une fois, les tests d'intégration vont donc bien au delà de simples tests unitaires du code que l'on a écrit (ou que l'on va écrire) : ils permettent de s'assurer du bon fonctionnement de notre application en lien avec d'autres composants du framework, du serveur, etc.

Lançons à présent nos tests pour vérifier que ce premier test case d'intégration fonctionne comme il se doit (ce qui serait fort étonnant, m'enfin, soyons fous !) :

$ python manage.py test journal
Creating test database for alias 'default'...

E
======================================================================
ERROR: test_index (journal.tests.ArticlesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/pascal/upjv_news/journal/tests.py", line 14, in test_index
    response = self.client.get(reverse('journal.views.articles_list'))
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 509, in reverse
    return iri_to_uri(resolver._reverse_with_prefix(view, prefix, *args, **kwargs))
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 387, in _reverse_with_prefix
    possibilities = self.reverse_dict.getlist(lookup_view)
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 296, in reverse_dict
    self._populate()
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 285, in _populate
    lookups.appendlist(pattern.callback, (bits, p_pattern, pattern.default_args))
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 229, in callback
    self._callback = get_callable(self._callback_str)
  File "/Library/Python/2.7/site-packages/django/utils/functional.py", line 32, in wrapper
    result = func(*args)
  File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 117, in get_callable
    (lookup_view, mod_name))
ViewDoesNotExist: Could not import journal.views.article. View does not exist in module journal.views.

----------------------------------------------------------------------
Ran 1 test in 0.041s

FAILED (errors=1)

Ce qui devait arriver arrive : le test échoue ! Rien de surprenant : le code qu'il est censé testé (le SUT) n'est simplement… pas encore écrit ! Ceci est confirmé par le rapport de test : ViewDoesNotExist: Could not import journal.views.article. View does not exist in module journal.views..

La prochaine étape est donc d'écrire ce code.

Code métier pour le use case « Affichage de la liste des articles »

Notre contrôleur va ressembler à ceci :

views.py
def articles_list(request):
    articles = Article.objects.all()
    return render_to_response('journal/articles_list.html', {'articles': articles})

Ce contrôleur est très simple (merci Django !) : il récupère l'ensemble des articles existants en BDD via l'ORM dans une variable articles, puis demande de rendu de la vue journal/articles_list.html en plaçant dans le contexte la variable articles.

Voilà qui devrait calmer la colère du test runner : relançons les tests…

$ python manage.py test journal
Creating test database for alias 'default'...

E
======================================================================
ERROR: test_index (journal.tests.ArticlesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/pascal/upjv_news/journal/tests.py", line 14, in test_index
    response = self.client.get(reverse('journal.views.articles_list'))
  File "/Library/Python/2.7/site-packages/django/test/client.py", line 473, in get
    response = super(Client, self).get(path, data=data, **extra)
  File "/Library/Python/2.7/site-packages/django/test/client.py", line 280, in get
    return self.request(**r)
  File "/Library/Python/2.7/site-packages/django/test/client.py", line 444, in request
    six.reraise(*exc_info)
  File "/Library/Python/2.7/site-packages/django/core/handlers/base.py", line 114, in get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/pascallando/Sites/django/test_recette/__project/upjv_news/journal/views.py", line 13, in articles_list
    return render_to_response('journal/articles_list.html', {'articles': articles})
  File "/Library/Python/2.7/site-packages/django/shortcuts/__init__.py", line 29, in render_to_response
    return HttpResponse(loader.render_to_string(*args, **kwargs), **httpresponse_kwargs)
  File "/Library/Python/2.7/site-packages/django/template/loader.py", line 162, in render_to_string
    t = get_template(template_name)
  File "/Library/Python/2.7/site-packages/django/template/loader.py", line 138, in get_template
    template, origin = find_template(template_name)
  File "/Library/Python/2.7/site-packages/django/template/loader.py", line 131, in find_template
    raise TemplateDoesNotExist(name)
TemplateDoesNotExist: journal/articles_list.html

----------------------------------------------------------------------
Ran 1 test in 0.088s

FAILED (errors=1)
Destroying test database for alias 'default'...

Hum. La situation a évolué, plus de problème de contrôleur mais à présent le test runner se plaint de ne pas trouver la vue (template) journal/articles_list.html. Normal, nous ne l'avons pas encore créée : faisons-le maintenant.

articles_list.html
<h1>Liste des articles</h1>

{% for article in articles %}
    <h2>{{ article.title }}</h2><div>
        {{ article.content }}
    </div>
{% endfor %}

Et nous relançons une nouvelle fois nos tests…

$ python manage.py test journal
Creating test database for alias 'default'...

.
----------------------------------------------------------------------
Ran 1 test in 0.045s

OK
Destroying test database for alias 'default'...

OK, tout est correct à présent ! Nous pouvons donc continuer l'écriture du code, pour les autres fonctionnalités…

Tests pour le use case « Affichage d'un article public »

Nous allons à présent écrire le code de test pour l'affichage d'un article public, puis nous écrirons le code de la fonctionnalité. Nous allons réaliser les tests suivants :

  • La vue article.html doit être utilisée.
  • L'objet article (instance du modèle Article) doit être fourni dans le contexte.
  • Le code de retour HTTP de la page doit être 200 si l'article existe.

Nous ajoutons donc une méthode de test à notre classe ArticlesTest :

tests.py
def test_existing_public_article(self):
    """
    Affichage d'un article public existant, sans restriction d'accès.
    """
    response = self.client.get(reverse('journal.views.article', kwargs={'id': 1}))
    self.failUnless(isinstance(response.context['article'], Article))
    self.assertTemplateUsed(response, 'journal/article.html')
    self.failUnlessEqual(response.status_code, 200)

Inutile de commenter en détail les assertions décrites ici : elles sont du même ordre que ce qui a été proposé à la section précédente. Idem pour le résultat : en lançant à nouveau les tests de l'application, nous allons faire face à un échec, normal puisque le contrôleur et le template ne sont pas encore écrits. L'idée est donc d'écrire ce code et de relancer les tests, jusqu'à ce que l'ensemble fonctionne.

Tests pour le use case « Affichage d'un article public inexistant »

Un cas particulier doit être prévu : si l'article demandé n'existe pas, l'application ne doit pas planter ou afficher un message plus ou moins aléatoire, elle doit simplement renvoyer à l'utilisateur un statut 404 (page introuvable).

Intégrons ceci à nos tests, qui ne passera que si une page 404 est retournée en cas de demande d'un article inexistant :

tests.py
def test_404_public_article(self):
    """
    Tentative d'affichage d'un article inexistant.
    """
    response = self.client.get(reverse('journal.views.article', kwargs={'id': 19829897}))
    self.failUnlessEqual(response.status_code, 404)

Tests pour le use case « Affichage d'un article premium »

Les articles « premium » ne peuvent être visionnés que par des membres premiums. Nous devons donc nous assurer qu'aucun membre non-premium ne parviendra à visionner ce type d'article (et s'il essaie, il devra se voir délivré un statut 403 : accès interdit).

tests.py
def test_premium_article_not_authenticated(self):
    """
    Affichage d'un article premium pour un utilisateur non logué.
    Une page 403 "Forbidden" doit être présentée.
    """
    response = self.client.get(reverse('journal.views.article', kwargs={'id': 3}))
    self.failUnlessEqual(response.status_code, 403)

En revanche, ils doivent pouvoir être vus par les utilisateurs qui sont enregistrés (premium). Dans ce cas, les éléments suivants doivent s'appliquer :

  • La vue journal/article.html doit être utilisée.
  • La liste articles (QuerySet) doit être fournie dans le contexte.
  • Le code de retour HTTP de la page doit être 200.
tests.py
def test_premium_article_authenticated(self):
    """
    Affichage d'un article premium pour un utilisateur logué.
    L'accès est autorisé comme pour un article public.
    """
    self.client.login(username='testeur', password='secret')
    response = self.client.get(reverse('journal.views.article', kwargs={'id': 3}))
    self.failUnless(isinstance(response.context['article'], Article))
    self.assertTemplateUsed(response, 'journal/article.html')
    self.failUnlessEqual(response.status_code, 200)

Testing du module News

Les choses se compliquent un peu quand il est question d'écrire des tests pour le module News.

En effet, ce module possède une dépendance envers un composant AFPClient dont le rôle est de consommer le web-service de l'AFP et de transformer ses retours en liste d'objets News. Ce composant n'est pas encore développé : nous allons devoir le mocker.

Nous allons utiliser la méthode setUp() de notre classe de test xUnit pour créer et initialiser notre mock à l'aide de MagicMock. De cette manière, il sera disponible pour tous les tests de la classe.

tests.py
def setUp(self):
    """
    Création et initialisation des objets utiles aux tests.
    """
    self.afp_client = AFPClient()

    self.news_1 = News(title="Test", content="Tata", publication_date=datetime.datetime(2007, 12, 5))
    self.news_2 = News(title="Une autre news", content="Belle news", publication_date=datetime.datetime(2007, 12, 6))

    self.afp_client.getLastNews = MagicMock(return_value=[self.news_1,self.news_2])

Important Remarquez bien que nous avons utilisé un mock (simulacre) et pas un stub (bouchon) : c'est le comportement d'une vraie instance d'AFPClient qui est modifié pour les tests, nous n'avons pas créé un « faux objet AFPClient » qui ressemblerait à une instance d'AFPClient. Si vous n'êtes pas à l'aise avec cette notion, revoyez le tutoriel Simulacres et bouchons.

À présent, nous pouvons tester notre module News comme bon nous semble (et surtout comme-ci le web service de l'AFP nous répondait !), et notamment la partie import de news. En particulier :

  • Le bon nombre d'objets News doivent être persistés en base de données.
  • Les objets persistés doivent être ceux attendus (autrement dit ceux que le WS fournit au module News).
tests.py
def test_save_news(self):
    """
    Teste l'import des news
    """
    News.saveLastNews(self.afp_client.getLastNews())
    news_in_database = News.objects.all()

    self.assertEqual(news_in_database.count(), 2)
    self.assertQuerysetEqual(news_in_database, ['', ''])

C'est fait ! Notre composant d'import de news est testé. C'est insuffisant certes, mais les premières vérifications importantes sont réalisées, et charge au développeur de compléter ces vérifications par la suite : quand il rencontrera un bug, quand il fera évoluer le code, etc.