Tester ses contrôleurs (views) avec Django

Cette page vous montrera comment écrire des cas de test simples et efficaces pour vos contrôleurs d'application Django. Nous écrirons pour cela des classes de test, fondées sur unittest, avec une approche TDD.

 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 pratique create(), 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éthode add() 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.py
class 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.py
class 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 un QuerySet (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 un QuerySet 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.py
class 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 quelle base de données sont exécutés les tests ?
  • 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.
Qu'advient-t-il des méthodes d'une classe héritant de 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).
Quelle est l'utilité de l'assertion 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.
Comment simuler le fait qu'une page est appelée par un utilisateur authentifié ?
  • 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.
Que signifie le 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.

Que réalise la commande 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.