Les tests unitaires dans Flask

Les tests unitaires peuvent s'utiliser dans Flask tels que nous l'avons vu dans la section précédente.

Nous allons reprendre l'exemple du site de Poudlard vu en cours.

Rappel du site de Poudlard

Le site de Poudlard a un modèle qui permet de récupérer les étudiants par maison.

 1from poudlard.models.etudiant import get_etudiants
 2
 3def get_etudiant_by_maison():
 4    """
 5    Retourne un dictionnare avec comme clé les maisons, et comme valeur la liste
 6     des étudiants dans cette maison.
 7    """
 8
 9    etudiants = get_etudiants()
10    maisons = {}
11    for etudiant in etudiants:
12        if etudiant["maison"] not in maisons:
13            etudiant_maison = []
14            maisons[etudiant["maison"]] = etudiant_maison
15        etudiant_maison.append(etudiant)
16    return maisons

Mais est-ce que cette fonction marche bien ? On va la tester.

Ecriture d'un test

Nous allons écrire les tests dans un package « tests » (c'est-à-dire un dossier avec un fichier __init__.py vide) dans le dossier de notre application. Le fichier de test pour les étudiants peut s'appeler par exemple test_etudiant.py.

Nous créons une classe TestUser qui étend unittest.TestCase comme pour une application Python quelconque. Nous allons écrire un test pour la fonction get_etudiant_by_maison.

Nous allons tester si la fonction retourne bien un dictionnaire avec 4 maisons, puis les 4 maisons et enfin tester pour un étudiant dont le nom de famille est "Potter".

class TestUser(unittest.TestCase):

   def test_search_by_maison(self):
     maisons_dict = get_etudiant_by_maison()
      self.assertEqual(len(maisons_dict), 4)
      maisons = list(maisons_dict.keys())
      self.assertEqual(len(maisons), 4)
      maisons = sorted(maisons)
      self.assertEqual(maisons, sorted(['Gryffondor', 'Poufsouffle', 'Serpentard', 'Serdaigle']))
      etudiants = maisons_dict['Gryffondor']
      self.assertIn('Potter', [e["nom"] for e in etudiants])

Lancer les tests

Pour lancer les tests, on peut utiliser la commande suivante :

$ python3 -m unittest discover -v -s ./poudlard/tests
test_search_by_maison (test_etudiant_model.TestUser) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.026s

OK

Ici le test a fonctionné correctement. Envisageons maintenant un test qui échoue, par exemple en changeant le nom de la maison "Gryffondor" en "Gryffindor" dans le code de la fonction get_etudiant_by_maison. Le résultat du test sera alors le suivant :

$ coverage run --source ./poudlard/ -m unittest discover -v -s ./poudlard/tests

test_search_by_maison (test_etudiant_model.TestUser.test_search_by_maison) ... FAIL

======================================================================
FAIL: test_search_by_maison (test_etudiant_model.TestUser.test_search_by_maison)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/tbarbette/workspace/LINFO1002/poudlard-full/poudlard/tests/test_etudiant_model.py", line 9, in test_search_by_maison
   self.assertEqual(len(maisons_dict), 4)
AssertionError: 1 != 4

Ran 1 tests in 0.028s

FAILED (failures=1)

Initialization et nettoyage

Parfois, il est nécessaire d'initialiser des données avant de lancer un test, ou de nettoyer après un test. Ce sera nécessaire notamment quand nous intégregerons une base de donnée SQL dans notre application Flask, et que nous voudrons tester les fonctions qui interagissent avec la base de donnée. En effet, il faudra initialiser la base de donnée avant le test, et la nettoyer après le test.

Unittest fournit une méthode setUp qui est appelée avant chaque test. Nous allons donc créer une base de donnée de test dans cette méthode. Nous allons aussi créer une méthode tearDown qui sera appelée après chaque test pour nettoyer la base de donnée.

def setUp(self):
    # faire quelque chose pour initialiser le test
    pass

def tearDown(self):
    # faire quelque chose pour nettoyer après le test
    pass

Coverage

On peut aussi utiliser le module coverage pour voir le pourcentage de code couvert par les tests. Pour cela, on installe le module coverage avec pip, et on lance les tests avec la commande suivante :

$ coverage run --source ./poudlard/ -m unittest discover -v -s ./poudlard/tests
test_search_by_maison (test_etudiant_model.TestUser) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.027s

OK

Le résultat devrait ressembler à ceci :

../_images/poudlard-coverage.png

Vous y trouvez le pourcentage de code couvert par les tests. Il est normal de ne pas atteindre 100% de coverage. Par exemple wsgi.py est un module de Flask qui gère les requêtes HTTP, qui n'est pas testable car utilisé par les serveurs de production (voir Chapitre 1). Par contre pour l'instant le contrôleur n'est pas beaucoup testé, nous pouvons y remédier avec des tests d'intégration.

Tests d'intégration

Les tests vus jusqu'ici sont des tests unitaires : ils testent une fonction isolée, sans dépendance extérieure. Mais comment vérifier qu'une page entière qui appelle plusieur fonctions, des vues, etc est fonctionnelle? En d'autres termes, nous voulons tester les routes de notre application Flask, c'est-à-dire vérifier que l'URL / retourne bien une page avec Potter dedans, par exemple?

Ces tests sont appelés des tests d'intégration, car ils testent plusieurs composants ensemble : le routeur Flask, les vues, les templates… Flask fournit un client de test (app.test_client()) qui simule des requêtes HTTP complètes sans avoir besoin de lancer un vrai serveur.

import unittest
from poudlard import create_app

class TestRoutes(unittest.TestCase):

    def setUp(self):
        app = create_app()
        self.client = app.test_client()

    def test_index(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)
        self.assertIn(b"Potter", response.data)

    def test_about(self):
        response = self.client.get("/about.html")
        self.assertEqual(response.status_code, 200)

La méthode setUp crée une instance de l'application et un client de test avant chaque test. self.client.get("/") simule une requête HTTP GET sur la route /. On peut ensuite vérifier le code de statut (200 = succès) et le contenu de la réponse (response.data contient le HTML retourné sous forme d'octets, d'où le préfixe b).

Vous remarquerez que le "coverage" a maintenant augmenté pour le contrôlleur (etudiant.py) car les routes sont maintenant testées.

Tests dans l'intégration continue

Ca serait très pratique si on pouvait lancer tous ces tests à chaque fois que quelqu’un push sur git, et envoyer un email si la branche est « cassée ». C’est ce que fait l’intégration continue.

../_images/ci.jpg

Dans le pipeline donné dans votre projet, l'étape "tests" lance les tests unitaires. Si un test échoue, le pipeline s'arrête et vous recevez un email. Si tous les tests passent, le pipeline continue.

../_images/ci-coverage.jpg

Si vous cliquez sur l'étape test, vous aurez également directement en ligne le rapport de coverage. Il est également repris sur la partie de droite.