Tester ses contrôleurs (views) avec Django
Grrr. Sous Django, les contrôleurs s'appellent des vues (chacun sa logique !). Pour ne pas brusquer les lecteurs habitués au nommage « MVC », nous utiliserons sur cette page le terme contrôleur, systématiquement suivi, entre parenthèses du terme view. À chaque fois que nous parlerons de contrôleur, vous verrez donc ceci : « contrôleur (view) ».
On commence par écrire les tests !
TDD messieurs dames, on en a déjà parlé… Nous allons donc dès maintenant, avant d'écrire le moindre contrôleur (view), écrire quelques tests qui nous aideront à mettre à plat ce que nous souhaitons coder.
Mais… quels tests ?
Des tests, OK, mais lesquels ? Pour tester nos contrôleurs (views), nous devons avoir une bonne idée de ce que va devoir faire notre application, du moins dans sa toute première version.
Puisque nous sommes sportifs et agiles, nous n'allons pas essayer de spécifier l'ensemble du logiciel tout de suite, mais quelques écrans fondamentaux.
Chistera est une application de gestion de projet Scrum : elle doit a minima permettre :
- d'afficher un écran d'accueil, une sorte de dashboard (tableau de bord), su lequel on retrouvera la liste des équipes et la liste des backlogs ;
- de consulter un backlog pour visualiser les user-stories qu'il contient.
C'est tout pour le moment !
Préparation du jeu de test
Pour tester efficacement, nous avons besoin de quelques données concrètes : on appelle cela un jeu de données ou encore jeu d'essai. C'est ce que nous allons commencer par définir !
Gestion des jeux d'essai Il existe plusieurs manières de gérer le problème épineux des jeux de test : on peut utiliser des fixtures, déployer des factories pour générer les données de test, ou encore, au plus simple, de créer « à la main » les données de test avec les tests eux-mêmes. Cette dernière méthode est la plus simple, c'est celle que nous utiliserons dans un premier temps.
Le fichier tests.py
Au sein de notre application chistera (donc dans le répertoire chistera), Django a créé pour nous un fichier tests.py (comme si ce vilain bougre voulait nous inciter à tester…). Ce fichier possède un contenu d'exemple, par défaut :
chistera/tests.py"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
from django.test import TestCase
class SimpleTest(TestCase):
def test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.assertEqual(1 + 1, 2)
Si vous avez lu la page sur les tests en Python, vous n'êtes pas vraiment surpris par cette syntaxe. En fait, vous aurez remarqué que Django définit une sur-couche au dessus de unittest, tout simplement. La syntaxe est donc analogue, sauf qu'au lieu de faire hériter nos classes de test de unittest.TestCase
, nous sous-classons plutôt django.test.TestCase
.
La méthode setUp()
: initialiser les données de test
Comme dans beaucoup de frameworks de test, nous avons avec unittest (et par conséquent avec django.test.TestCase
) la possibilité de définir, pour chacune de nos classes de test, une méthode d'initialisation nommée setUp()
.
Nous allons utiliser cette méthode d'initialisation pour créer notre jeu de test : des utilisateurs, des backlogs, des équipes, des user-stories… Pour cela, nous allons simplement instancier quelques objets et les persister.
Remarque importante Vous devez bien comprendre que le test runner (le composant qui est chargé d'exécuter les tests) va créer, à chaque fois que vous lancerez vos tests, une nouvelle base de données de test. Il va ensuite la peupler avec votre jeu de données. À la fin de l'exécution des tests, il la supprimera. À aucun moment vos bases de données de développement ou de production ne seront utilisées ni impactées.
Allons-y : dans le fichier chistera/tests.py, saisissez les lignes suivantes :
chistera/tests.py# -*- coding: UTF-8 -*-
from django.test import TestCase
from django.core.urlresolvers import reverse
from chistera.models import *
class ChisteraTest(TestCase):
def setUp(self):
self.pascal = User.objects.create_user('pascal', 'pascal@test.com', 'pascal')
self.john = User.objects.create_user('john', 'john@test.com', 'john')
self.jimmi = User.objects.create_user('jimmi', 'jimmi@test.com', 'jimmi')
self.gaston = User.objects.create_user('gaston', 'gaston@test.com', 'gaston')
self.fo_team = Team.objects.create(name='Front office team')
self.fo_team.members.add(self.pascal)
self.fo_team.members.add(self.john)
self.bo_team = Team.objects.create(name='Back office team')
self.bo_team.members.add(self.jimmi)
self.bo_team.members.add(self.gaston)
self.fo_backlog = ProductBacklog.objects.create(team=self.fo_team, name="Backlog de l'équipe front")
self.bo_backlog = ProductBacklog.objects.create(team=self.bo_team, name="Backlog de l'équipe back")
self.fo_backlog_story_1 = UserStory.objects.create(product_backlog=self.fo_backlog, name="Nouvelle présentation de la fiche article")
self.fo_backlog_story_2 = UserStory.objects.create(product_backlog=self.fo_backlog, name="Insertion auto de keywords dans les balises alt")
self.bo_backlog_story_1 = UserStory.objects.create(product_backlog=self.bo_backlog, name="Ajout de l'autocompletion pour la recherche de produits")
Notre méthode setUp()
crée des instances de nos modèles, et les persiste dans la BDD de test.
C'est le moment de vous présenter quelques notions pratiques de l'ORM de Django :
- Chacun de nos modèles dispose d'un manager nommé
objects
. Ce manager permet de faire beaucoup de choses sur un modèle, comme par exemple rechercher des objets, en créer, en supprimer, etc. Nous utilisons ici la méthode pratiquecreate()
, qui permet de créer une instance puis de la sauvegarder (persister) en une seule ligne. - Pour manipuler les relations de type
ManyToMany
, Django met à notre disposition des méthodes spécifiques. Nous utilisons ici la méthodeadd()
pour ajouter un membre à une équipe.
OK, nous disposons à présent de nos données de test. Il ne reste plus qu'à… écrire les tests !
Tests du dashboard
Comme nous l'avons vu plus haut, notre écran d'accueil doit permettre d'afficher les équipes et les backlogs existants. Cet écran ne doit être accessibles qu'aux utilisateurs enregistrés, et pas aux autres.
Nous allons commencer par nous assurer que l'écran n'est pas accessibles aux visiteurs non authentifiés. Pour cela, nous créons une méthode de test, que nous appellerons test_dashboard_not_authenticated_user()
.
Remarque importante Toutes les méthodes de test doivent être préfixées par test_
pour être exécutées. Ceci vous autorise notamment à définir des méthodes internes dans vos classes de test, qui ne seront pas exécutées par le test runner mais que vous pouvez appeler vous-même.
Voici le contenu de notre méthode :
chistera/tests.pyclass ChisteraTest(TestCase):
def setUp(self):
#...
def test_dashboard_not_authenticated_user(self):
url = reverse('chistera:dashboard')
response = self.client.get(url)
self.assertTemplateNotUsed(response, 'chistera/dashboard.html')
self.failUnlessEqual(response.status_code, 302)
C'est tout ! Nous nous sommes assurés, grâce aux assertions assertTemplateNotUsed
et failUnlessEqual
que lorsqu'un utilisateur non authentifié accède à la page dont l'URL est celle associée à notre contrôleur (view) dashboard
:
- le template (~ la vue en parlé MVC) utilisé n'est pas celui de notre dashboard ;
- l'utilisateur est redirigé sur une autre page (code 302) : en l'occurence, c'est une page d'authentification, nous verrons plus tard comment tester qu'il est bien redirigé précisément sur cette page de loggin.
Pour aller plus loin, vous pouvez consulter la liste complète des assertions définies par Django en plus de celles propres à unittest.
Bien. Définissons maintenant un autre test case qui correspond à la l'affichage du dashboard quand l'utilisateur est bien authentifié. Il ne doit pas être redirigé, et bien voir notre dashboard :
chistera/tests.pyclass ChisteraTest(TestCase):
def setUp(self):
#...
def test_dashboard_authenticated_user(self):
self.client.login(username='pascal', password='pascal')
response = self.client.get(reverse('chistera:dashboard'))
self.assertEqual(type(response.context['backlogs']), QuerySet)
self.assertEqual(len(response.context['backlogs']), 2)
self.assertEqual(type(response.context['teams']), QuerySet)
self.assertEqual(len(response.context['teams']), 2)
self.failUnlessEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'chistera/dashboard.html')
self.client.logout()
Vous remarquez que nous connectons ici artificiellement, pour les besoins du test, l'utilisateur qui a le username « pascal » et que nous avons créé dans la méthode setUp()
. Ceci se fait très simplement grâce à la méthode login()
. À la fin du test, nous le déconnectons avec logout()
.
Cette méthode de test test_dashboard_authenticated_user()
nous permet de vérifier pas mal de choses :
- qu'un utilisateur connecté accède bien au dashboard avec un code HTTP
200
(OK) ; - que le template utilisé est notre template de dashboad
dashboard.html
(que nous n'avons toujours pas écrit, du reste…) ; - que dans le contexte passé au template par le contrôleur (view), il y a une variable
backlogs
qui est unQuerySet
(un jeu de résultats d'une requête) et qui contient 2 éléments ; - que dans le contexte passé au template par le contrôleur (view), il y a une variable
teams
qui est aussi unQuerySet
et qui contient 2 éléments.
Lancement des tests (on craint le pire…)
Il est temps à présent de lancer nos tests. Sans grand espoir de réussite : vu que nous n'avons pas écrit le code de l'application, il n'y a pas de risque qu'elle fonctionne (sinon, il faudrait revoir les tests, puisque ceci voudrait dire qu'ils sont totalement inutiles !).
Dans le répertoire de notre projet, nous allons utiliser le script manage.py
pour demander le lancement des tests. Cette commande permet de lancer tous les tests, ou seulement les tests d'une application du projet, voire même d'une seule méthode de test au sein d'une application. Lançons donc tous les tests de l'application chistera :
$ python manage.py test chistera
Creating test database for alias 'default'...
EE
======================================================================
ERROR: test_dashboard_authenticated_user (chistera.tests.ChisteraTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/pascal/django/miage_scrum/chistera/tests.py", line 40, in test_dashboard_authenticated_user
response = self.client.get(reverse('chistera:dashboard'))
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 496, 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 382, in _reverse_with_prefix
possibilities = self.reverse_dict.getlist(lookup_view)
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 297, in reverse_dict
self._populate()
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 263, in _populate
for pattern in reversed(self.url_patterns):
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 351, in url_patterns
raise ImproperlyConfigured("The included urlconf %s doesn't have any patterns in it" % self.urlconf_name)
ImproperlyConfigured: The included urlconf doesn't have any patterns in it
======================================================================
ERROR: test_dashboard_not_authenticated_user (chistera.tests.ChisteraTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/pascal/django/miage_scrum/chistera/tests.py", line 33, in test_dashboard_not_authenticated_user
url = reverse('chistera:dashboard')
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 496, 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 382, in _reverse_with_prefix
possibilities = self.reverse_dict.getlist(lookup_view)
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 297, in reverse_dict
self._populate()
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 263, in _populate
for pattern in reversed(self.url_patterns):
File "/Library/Python/2.7/site-packages/django/core/urlresolvers.py", line 351, in url_patterns
raise ImproperlyConfigured("The included urlconf %s doesn't have any patterns in it" % self.urlconf_name)
ImproperlyConfigured: The included urlconf doesn't have any patterns in it
----------------------------------------------------------------------
Ran 2 tests in 0.811s
FAILED (errors=2)
Destroying test database for alias 'default'...
Nos deux tests échouent : pas d'inquiétude, c'est normal !
Tests de l'écran backlog
Qu'à cela ne tienne, nous poursuivons avec deux nouveaux tests pour notre écran backlog.
chistera/tests.pyclass ChisteraTest(TestCase):
#...
def test_backlog_not_authenticated_user(self):
response = self.client.get(reverse('chistera:backlog', kwargs={'backlog_id': 1}))
self.assertTemplateNotUsed(response, 'chistera/backlog.html')
self.failUnlessEqual(response.status_code, 302)
def test_backlog_authenticated_user(self):
self.client.login(username='pascal', password='pascal')
response = self.client.get(reverse('chistera:backlog', kwargs={'backlog_id': 1})) # L'id 1 correspond au backlog fo_backlog
self.assertEqual(response.context['backlog'], self.fo_backlog)
self.assertEqual(type(response.context['stories']), QuerySet)
self.assertEqual(len(response.context['stories']), 2)
self.failUnlessEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'chistera/backlog.html')
self.client.logout()
Testez vos connaissances…
- Sur la base de données de développement.
- Sur une base de données temporaire que le programmeur doit créer lui-même.
- Sur la base de données de production.
- Sur une base de données temporaire créée par Django lui même de manière transparente.
django.test.TestCase
et ne commençant par par test_
?- Elles ne sont pas considérées comme un test.
- Elles sont exécutées comme toutes les autres méthodes de la classe.
- Elles ne sont pas exécutées lors du lancement des tests avec
python manage.py test
. - Elle peuvent être utilisées comme méthode utilitaire (par exemple pour préparer des jeux de test ou réaliser des traitements intermédiaires).
assertTemplateNotUsed
?- Cela permet de s'assurer qu'un template n'est pas utilisé après le traitement d'un contrôleur.
- Cette assertion permet de vérifier la consistance d'un template.
- Ce n'est pas une assertion Python.
- Lors du lancement des tests, Django s'assure automatiquement de travailler avec un user authentifié pour faciliter les tests.
- On utilise la méthode
self.client.login(username=le_user, password=son_password)
. - Il n'est pas possible de faire ce genre de vérification car ce n'est pas de la responsabilité des tests unitaires.
reverse
dans l'expression response = self.client.get(reverse('chistera:dashboard'))
?- C'est une assertion réversible.
- Cela permet de calculer l'assertion inverse de l'assertion courante.
reverse()
permet de calculer dynamiquement une URL en fonction du nom d'un pattern nommé.reverse()
réalise ce que l'on appelle du routage inversé : au lieu de trouver le contrôleur qui correspond à une URL, on cherche l'URL qui correspond à un contrôleur.
Rappel (encore) : ne hard-codez jamais vos URLs, utilisez toujours reverse()
pour calculer une URL sur la base du contrôleur et de ses paramètres éventuels.
python manage.py test
?- Quand elle est exécutée dans le répertoire d'une application, elle lance tous les tests de l'application.
- Elle lance les tests de toutes les applications utilisées par le projet en cours.
- Elle lance les tests de l'application chistera.
- Elle permet de manager les tests Django.