Si vous débutez sous Django, vous pouvez consulter mon tutoriel de formation Django !
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.
Voici une vue globale des fonctionnalités du système :
Ce dessin d'interface (wireframe) permet de comprendre comment se présentera l'application :
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).
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.
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 :
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.
Pour rappel, la démarche de travail dans le cadre d'une stratégie TDD est la suivante :
Le framework Django est fondé sur le pattern MVC : modèle/vue/contrôleur. Nous allons donc écrire ces différents composants.
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
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')
)
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...
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
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 :
articles_list.html
est utilisée.QuerySet
) est fournie dans le contexte.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.
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
.journal/articles_list.html
et pas autre chose.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.
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…
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 :
article.html
doit être utilisée.Article
) doit être fourni dans le contexte.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.
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)
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 :
journal/article.html
doit être utilisée.articles
(QuerySet
) doit être fournie dans le contexte.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)
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 :
News
doivent être persistés en base de données.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.