Tester les applications Flask

Quelque chose qui n’est pas testé est cassé.

L’origine de cette citation est inconnue et, bien qu’elle ne soit pas tout à fait correcte, elle n’est pas loin de la vérité. Les applications non testées rendent difficile l’amélioration du code existant et les développeurs d’applications non testées ont tendance à devenir assez paranoïaques. Si une application dispose de tests automatisés, vous pouvez apporter des modifications en toute sécurité et savoir instantanément si quelque chose se casse.

Flask fournit un moyen de tester votre application en exposant le Werkzeug Client de test et en gérant les contextes locaux pour vous. Vous pouvez ensuite l’utiliser avec votre solution de test préférée.

Dans cette documentation, nous utiliserons le paquet pytest comme infrastructure logicielle de base pour nos tests. Vous pouvez l’installer avec pip, comme ceci :

$ pip install pytest

L’application

Tout d’abord, nous avons besoin d’une application à tester ; nous utiliserons l’application du Tutoriel. Si vous n’avez pas encore cette application, récupérez le code source depuis ces exemples.

Pour que nous puissions importer correctement le module flaskr, nous devons exécuter pip install -e . dans le dossier tutorial.

Le squelette des tests

Nous commençons par ajouter un répertoire tests sous la racine de l’application. Ensuite, nous créons un fichier Python pour stocker nos tests (test_flaskr.py). Si nous formatons le nom du fichier comme test_*.py, il sera auto-découvrable par pytest.

Ensuite, nous créons un pytest fixture appelé client() qui configure l’application pour les tests et initialise une nouvelle base de données :

import os
import tempfile

import pytest

from flaskr import create_app
from flaskr.db import init_db


@pytest.fixture
def client():
    db_fd, db_path = tempfile.mkstemp()
    app = create_app({'TESTING': True, 'DATABASE': db_path})

    with app.test_client() as client:
        with app.app_context():
            init_db()
        yield client

    os.close(db_fd)
    os.unlink(db_path)

Cette fixture client sera appelée par chaque test individuel. Il nous donne une interface simple avec l’application, où nous pouvons déclencher des requêtes de test vers l’application. Le client gardera également la trace des cookies pour nous.

Lors de l’installation, le flag de configuration TESTING est activé. Ceci a pour effet de désactiver la capture des erreurs pendant le traitement des requêtes, de sorte que vous obtenez de meilleurs rapports d’erreurs lorsque vous effectuez des requêtes de test contre l’application.

Parce que SQLite3 est basé sur le système de fichiers, nous pouvons facilement utiliser le module tempfile pour créer une base de données temporaire et l’initialiser. La fonction mkstemp() fait deux choses pour nous : elle retourne un gestionnaire de fichier de bas niveau et un nom de fichier aléatoire, ce dernier étant utilisé comme nom de base de données. Nous devons juste garder le db_fd pour pouvoir utiliser la fonction os.close() pour fermer le fichier.

Pour supprimer la base de données après le test, le dispositif ferme le fichier et le supprime du système de fichiers.

Si nous exécutons maintenant la suite de tests, nous devrions voir la sortie suivante :

$ pytest

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 0 items

=========== no tests ran in 0.07 seconds ============

Même s’il n’a pas exécuté de réels tests, nous savons déjà que notre application flaskr est syntaxiquement valide, sinon l’importation aurait échoué avec une exception.

Le premier test

Il est maintenant temps de commencer à tester la fonctionnalité de l’application. Vérifions que l’application affiche « No entries here so far » si nous accédons à la racine de l’application (/). Pour ce faire, nous ajoutons une nouvelle fonction de test au fichier test_flaskr.py, comme ceci :

def test_empty_db(client):
    """Start with a blank database."""

    rv = client.get('/')
    assert b'No entries here so far' in rv.data

Remarquez que nos fonctions de test commencent par le mot test ; cela permet à pytest d’identifier automatiquement la fonction comme un test à exécuter.

En utilisant client.get, nous pouvons envoyer une requête HTTP GET à l’application avec le chemin donné. La valeur de retour sera un objet response_class. Nous pouvons maintenant utiliser l’attribut data pour inspecter la valeur de retour (sous forme de chaîne) de l’application. Dans ce cas, nous nous assurons que 'No entries here so far' fait partie de la sortie.

Exécutez-le à nouveau et vous devriez voir un test réussi :

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 1 items

tests/test_flaskr.py::test_empty_db PASSED

============= 1 passed in 0.10 seconds ==============

Connexion et déconnexion

La majorité des fonctionnalités de notre application n’est disponible que pour l’utilisateur administratif. Nous devons donc trouver un moyen de connecter et déconnecter notre client de test de l’application. Pour ce faire, nous envoyons des requêtes aux pages de connexion et de déconnexion avec les données de formulaire requises (nom d’utilisateur et mot de passe). Et parce que les pages de connexion et de déconnexion redirigent, nous disons au client de suivre les redirections.

Ajoutez les deux fonctions suivantes à votre fichier test_flaskr.py:

def login(client, username, password):
    return client.post('/login', data=dict(
        username=username,
        password=password
    ), follow_redirects=True)


def logout(client):
    return client.get('/logout', follow_redirects=True)

Maintenant, nous pouvons facilement tester que la connexion et la déconnexion fonctionnent et qu’elles échouent si les informations d’identification ne sont pas valides. Ajoutez cette nouvelle fonction de test :

def test_login_logout(client):
    """Make sure login and logout works."""

    username = flaskr.app.config["USERNAME"]
    password = flaskr.app.config["PASSWORD"]

    rv = login(client, username, password)
    assert b'You were logged in' in rv.data

    rv = logout(client)
    assert b'You were logged out' in rv.data

    rv = login(client, f"{username}x", password)
    assert b'Invalid username' in rv.data

    rv = login(client, username, f'{password}x')
    assert b'Invalid password' in rv.data

Test d’ajout de messages

Nous devons également tester que l’ajout de messages fonctionne. Ajoutez une nouvelle fonction de test comme ceci :

def test_messages(client):
    """Test that messages work."""

    login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'])
    rv = client.post('/add', data=dict(
        title='<Hello>',
        text='<strong>HTML</strong> allowed here'
    ), follow_redirects=True)
    assert b'No entries here so far' not in rv.data
    assert b'&lt;Hello&gt;' in rv.data
    assert b'<strong>HTML</strong> allowed here' in rv.data

Ici, nous vérifions que le HTML est autorisé dans le texte mais pas dans le titre, ce qui est le comportement souhaité.

L’exécution de ce test devrait maintenant nous donner trois tests réussis :

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 3 items

tests/test_flaskr.py::test_empty_db PASSED
tests/test_flaskr.py::test_login_logout PASSED
tests/test_flaskr.py::test_messages PASSED

============= 3 passed in 0.23 seconds ==============

Autres astuces de test

Outre l’utilisation du client de test comme indiqué ci-dessus, il existe également la méthode test_request_context() qui peut être utilisée en combinaison avec l’instruction with pour activer temporairement un contexte de requête. Avec cette méthode, vous pouvez accéder aux objets request, g et session comme dans les fonctions de vue. Voici un exemple complet qui démontre cette approche :

from flask import Flask, request

app = Flask(__name__)

with app.test_request_context('/?name=Peter'):
    assert request.path == '/'
    assert request.args['name'] == 'Peter'

Tous les autres objets liés au contexte peuvent être utilisés de la même manière.

Si vous voulez tester votre application avec différentes configurations et qu’il ne semble pas y avoir de bon moyen de le faire, envisagez de passer aux fabriques d’applications (voir Application Factories).

Notez cependant que si vous utilisez un contexte de requête de test, les fonctions before_request() et after_request() ne sont pas appelées automatiquement. Cependant, les fonctions teardown_request() sont effectivement exécutées lorsque le contexte de la requête de test quitte le bloc with. Si vous souhaitez que les fonctions before_request() soient également appelées, vous devez appeler vous-même preprocess_request():

app = Flask(__name__)

with app.test_request_context('/?name=Peter'):
    app.preprocess_request()
    ...

Cela peut être nécessaire pour ouvrir des connexions à des bases de données ou quelque chose de similaire, selon la façon dont votre application a été conçue.

Si vous voulez appeler les fonctions after_request(), vous devez faire appel à process_response() qui nécessite toutefois que vous lui passiez un objet de réponse :

app = Flask(__name__)

with app.test_request_context('/?name=Peter'):
    resp = Response('...')
    resp = app.process_response(resp)
    ...

Ceci est en général moins utile car à ce moment-là, vous pouvez directement commencer à utiliser le client de test.

Contournement des ressources et du contexte

Changelog

Nouveau dans la version 0.10.

Un modèle très courant consiste à stocker les informations d’autorisation des utilisateurs et les connexions aux bases de données dans le contexte de l’application ou dans l’objet flask.g. Le modèle général pour cela est de mettre l’objet à cet endroit lors de la première utilisation et de le supprimer lors du démontage. Imaginez par exemple ce code pour obtenir l’utilisateur actuel :

def get_user():
    user = getattr(g, 'user', None)
    if user is None:
        user = fetch_current_user_from_database()
        g.user = user
    return user

Pour un test, il serait bien de pouvoir remplacer cet utilisateur de l’extérieur sans avoir à modifier le code. Ceci peut être accompli en accrochant le signal flask.appcontext_pushed :

from contextlib import contextmanager
from flask import appcontext_pushed, g

@contextmanager
def user_set(app, user):
    def handler(sender, **kwargs):
        g.user = user
    with appcontext_pushed.connected_to(handler, app):
        yield

Et puis pour l’utiliser :

from flask import json, jsonify

@app.route('/users/me')
def users_me():
    return jsonify(username=g.user.username)

with user_set(app, my_user):
    with app.test_client() as c:
        resp = c.get('/users/me')
        data = json.loads(resp.data)
        assert data['username'] == my_user.username

Maintenir le contexte

Changelog

Nouveau dans la version 0.4.

Parfois, il est utile de déclencher une requête régulière mais de conserver le contexte un peu plus longtemps afin de permettre une introspection supplémentaire. Avec Flask 0.4, cela est possible en utilisant le test_client() avec un bloc with :

app = Flask(__name__)

with app.test_client() as c:
    rv = c.get('/?tequila=42')
    assert request.args['tequila'] == '42'

Si vous utilisiez seulement le test_client() sans le bloc with, le assert échouerait avec une erreur car request n’est plus disponible (parce que vous essayez de l’utiliser en dehors de la requête actuelle).

Accéder et modifier les sessions

Changelog

Nouveau dans la version 0.8.

Parfois, il peut être très utile d’accéder ou de modifier les sessions à partir du client de test. Il y a généralement deux façons de procéder. Si vous voulez simplement vous assurer que certaines clés d’une session ont certaines valeurs, vous pouvez conserver le contexte et accéder à flask.session :

with app.test_client() as c:
    rv = c.get('/')
    assert session['foo'] == 42

Cependant, cela ne permet pas de modifier la session ou d’accéder à la session avant qu’une requête ne soit lancée. À partir de Flask 0.8, nous fournissons une « transaction de session » qui simule les appels appropriés pour ouvrir une session dans le contexte du client de test et pour la modifier. A la fin de la transaction, la session est stockée et prête à être utilisée par le client de test. Ceci fonctionne indépendamment du backend de session utilisé :

with app.test_client() as c:
    with c.session_transaction() as sess:
        sess['a_key'] = 'a value'

    # once this is reached the session was stored and ready to be used by the client
    c.get(...)

Notez que dans ce cas, vous devez utiliser l’objet sess au lieu du proxy flask.session. Cependant, l’objet lui-même fournira la même interface.

Test des API JSON

Changelog

Nouveau dans la version 1.0.

Flask offre une excellente prise en charge de JSON et constitue un choix populaire pour la création d’API JSON. Faire des requêtes avec des données JSON et examiner les données JSON dans les réponses est très pratique :

from flask import request, jsonify

@app.route('/api/auth')
def auth():
    json_data = request.get_json()
    email = json_data['email']
    password = json_data['password']
    return jsonify(token=generate_token(email, password))

with app.test_client() as c:
    rv = c.post('/api/auth', json={
        'email': 'flask@example.com', 'password': 'secret'
    })
    json_data = rv.get_json()
    assert verify_token(email, json_data['token'])

En passant l’argument json dans les méthodes du client de test, les données de la requête sont définies comme un objet sérialisé JSON et le type de contenu est défini comme application/json. Vous pouvez récupérer les données JSON de la requête ou de la réponse avec get_json.

Test des commandes CLI

Click est fourni avec des utilitaires pour tester vos commandes CLI. Un CliRunner exécute les commandes de manière isolée et capture la sortie dans un objet Result.

Flask fournit test_cli_runner() pour créer une FlaskCliRunner qui passe l’application Flask à la CLI automatiquement. Utilisez sa méthode invoke() pour appeler les commandes de la même manière qu’elles seraient appelées depuis la ligne de commande.

import click

@app.cli.command('hello')
@click.option('--name', default='World')
def hello_command(name):
    click.echo(f'Hello, {name}!')

def test_hello():
    runner = app.test_cli_runner()

    # invoke the command directly
    result = runner.invoke(hello_command, ['--name', 'Flask'])
    assert 'Hello, Flask' in result.output

    # or by name
    result = runner.invoke(args=['hello'])
    assert 'World' in result.output

Dans l’exemple ci-dessus, invoquer la commande par son nom est utile car cela permet de vérifier que la commande a été correctement enregistrée dans l’application.

Si vous voulez tester la façon dont votre commande analyse les paramètres, sans exécuter la commande, utilisez sa méthode make_context(). Cette méthode est utile pour tester des règles de validation complexes et des types personnalisés.

def upper(ctx, param, value):
    if value is not None:
        return value.upper()

@app.cli.command('hello')
@click.option('--name', default='World', callback=upper)
def hello_command(name):
    click.echo(f'Hello, {name}!')

def test_hello_params():
    context = hello_command.make_context('hello', ['--name', 'flask'])
    assert context.params['name'] == 'FLASK'