Couverture des tests

En écrivant des tests unitaires pour votre application, vous vérifiez que le code que vous avez écrit fonctionne comme vous le pensez. Flask comprend un client qui simule les requêtes faites à l’application et retourne les informations contenues dans les réponses.

Vous devriez tester votre code le plus possible. Le code d’une fonction n’est exécuté que lorsque cette fonction est appelée et le code qui se trouve dans des branches, comme les instructions conditionnelles, ne s’exécute que lorsque la condition est vérifiée. Vous devez vous organiser pour que chaque fonction soit testée avec des données qui permettent d’exercer toutes ses branches.

The closer you get to 100% coverage, the more comfortable you can be that making a change won’t unexpectedly change other behavior. However, 100% coverage doesn’t guarantee that your application doesn’t have bugs. In particular, it doesn’t test how the user interacts with the application in the browser. Despite this, test coverage is an important tool to use during development.

Note

Les tests sont introduits assez tardivement dans le tutoriel, mais dans vos projets futurs, vous devriez écrire les tests en parallèle avec le code que vous développez.

Vous utiliserez pytest et coverage pour tester et mesurer votre code. Installez les tous les deux:

$ pip install pytest coverage

Configuration et agencements

Le code de vos tests se trouve dans le répertoire tests. Ce répertoire est juste à côte du package flaskr mais en dehors de celui-ci. Le fichier tests/conftest.py contient les fonctions de configurations appelées fixtures que chaque test utilise. En Python, les tests sont des modules dont le nom commence par test_.

Each test will create a new temporary database file and populate some data that will be used in the tests. Write a SQL file to insert that data.

tests/data.sql
INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

La fixture app va appeler l’usine à applications et lui passer l’argument test_config pour configurer l’application et la base de données pour réaliser des tests plutôt que d’utiliser la configuration de votre environnement de développement.

tests/conftest.py
import os
import tempfile

import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db

with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
    _data_sql = f.read().decode('utf8')


@pytest.fixture
def app():
    db_fd, db_path = tempfile.mkstemp()

    app = create_app({
        'TESTING': True,
        'DATABASE': db_path,
    })

    with app.app_context():
        init_db()
        get_db().executescript(_data_sql)

    yield app

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


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def runner(app):
    return app.test_cli_runner()

La fonction tempfile.mkstemp() crée et ouvre un fichier temporaire et retourne un objet file et le chemin correspondant. Le chemin DATABASE est remplacé de façon à ce qu’il pointe vers ce répertoire temporaire plutôt que l’instance folder. Après avoir fixé ce chemin, les tables de la base de données sont créées et les données de test sont insérées. Après la fin du test, le fichier temporaire est fermé et supprimé.

TESTING indique à Flask que l’application est en mode test. Flask change son fonctionnement interne de façon à simplifier les tests et des extensions peuvent aussi faire de même pour simplifier ces tests.

La fixture client appelle app.test_client() avec l’objet application créé par la fixture ``app`. Les tests utiliseront le client pour faire des requêtes à l’application sans devoir exécuter le serveur.

La fixture runner est similaire à celle du client. app.test_cli_runner() crée un runner qui peut lancer les commandes Click enregistrées avec l’application.

Pytest utilise des fixtures en mettant en correspondance les noms des fonctions avec les noms des arguments des fonctions de test. Par exemple, la fonction test_hello que vous aller écrire bientôt prend comme argument un client. Pytest met cela en correspondance avec la fonction fixture client et passe les valeurs retournées à la fonction de test.

Usine à applications

Il n’y a pas grand chose de spécifique à tester dans l’usine à applications elle-même. La plupart de son code sera exécuté pour chaque test de toute façon. Si une partie de cette fonction est erronée, d’autres tests le détecteront.

La seul comportement qui peut changer est la réussite du test de configuration. Si celui-ci ne passe pas, il devrait y avoir une configuration par défaut, sinon la configuration devrait être écrasée.

tests/test_factory.py
from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

Vous avez ajouté la route hello comme exemple en écrivant l’usine à applications au début de ce tutoriel. Cette fonction retourne « Hello, World! » et donc le test vérifie que la réponse contient cette chaîne de caractères.

Base de données

Dans le contexte d’une application, get_db doit retourner la même connexion chaque fois qu’elle est appelé. Après l’arrêt de ce contexte, la connexion doit être fermée.

tests/test_db.py
import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e.value)

La commande init-db devrait appeler la fonction init_db et afficher un message.

tests/test_db.py
def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

Ce test utilise la fixture monkeypatch de Pytest pour remplacer la fonction init_db par une fonction qui enregistre qu’elle a été appelée. La fixture runner que vous avez écrit ci-dessus est utilisée pour appeler la commande init-db en utilisant son nom.

Authentification

Pour la plupart des vues, un utilisateur doit être connecté. La façon la plus simple de faire cela dans les tests est de faire une requête POST à la vue login avec le client. Plutôt que d’écrire cela à chaque fois, vous pouvez écrire une classe contenant des méthodes qui font cela et utiliser une fixture pour la passer au client pour chaque test.

tests/conftest.py
class AuthActions(object):
    def __init__(self, client):
        self._client = client

    def login(self, username='test', password='test'):
        return self._client.post(
            '/auth/login',
            data={'username': username, 'password': password}
        )

    def logout(self):
        return self._client.get('/auth/logout')


@pytest.fixture
def auth(client):
    return AuthActions(client)

Avec la fixture auth, vous pouvez appeler auth.login() dans un test pour vous connecter comme l’utilisateur test qui a été inséré dans les données de test à l’intérieur de la fixture app.

La vue register doit retourner un bon rendu pour les requêtes GET. Pour une requête POST avec des données valides, elle doit rediriger vers l’URL de login et les données de l’utilisateur doivent être dans la base de données. Des données invalides doivent provoquer l’affichage des messages d’erreur.

tests/test_auth.py
import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert 'http://localhost/auth/login' == response.headers['Location']

    with app.app_context():
        assert get_db().execute(
            "select * from user where username = 'a'",
        ).fetchone() is not None


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('', '', b'Username is required.'),
    ('a', '', b'Password is required.'),
    ('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
    response = client.post(
        '/auth/register',
        data={'username': username, 'password': password}
    )
    assert message in response.data

La fonction client.get() fait une requête GET et retourne l’objet Response retourné par Flask. De la même façon, client.post() fait une requête POST et convertit le dictionnaire data dans les données du formulaire.

Pour vérifier si le rendu d’une page est correct, une requête simple est faite et on vérifie qu’elle retourne 200 OK status_code. Si le rendu échoue, Flask retourne le code d’erreur 500 Internal Server Error.

headers contiendra un Location header contenant l’URL de login lorsque la vue register redirige vers la vue de login.

data contient le body de la réponse comme une suite de bytes. Si vous vous attendez à ce qu’une certain valeur s’affiche sur la page, vérifiez qu’elle est présente dans ` data``. Les bytes doivent être comparées à des bytes. Si vous voulez comparer du texte en format Unicode, utilisez plutôt get_data(as_text=True).

pytest.mark.parametrize demande à Pytest de lancer la même fonction de test avec des arguments différents. Vous l’utilisez ici pour tester des entrées invalides et les messages d’erreur correspondants sans écrire le même code trois fois.

Les tests pour la vue login sont très proches de ceux de la vue register. Plutôt que de tester les données dans la base de données, session devrait avoir son user_id initialisé après que l’utilisateur se soit connecté.

tests/test_auth.py
def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers['Location'] == 'http://localhost/'

    with client:
        client.get('/')
        assert session['user_id'] == 1
        assert g.user['username'] == 'test'


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('a', 'test', b'Incorrect username.'),
    ('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data

L’avantage d’utiliser client dans un bloc with est qu’il est possible d’accéder à des variables de contexte telles que session après que la réponse aie été retournée. Normalement, l’accès à une session en dehors d’une requête devrait provoquer une erreur.

Le test de l’opération de logout``est l'opposé de celui du ``login. session ne devrait pas contenir de user_id après une fin de connexion.

tests/test_auth.py
def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

Blog

Toutes les vues du blog utilisent la fixture auth que vous avez écrit précédemment. Appelez auth.login() et les requềtes faites après par le client seront faites par l’utilisateur test.

La vue index devrait afficher de l’information à propos du message ajouté avec l’information correcte. En étant connecté en tant qu’auteur, il devrait y avoir un lien permettant d’éditer ce message.

Vous pouvez aussi tester d’autres comportements liés à l’authentification en testant la vue index. En n’étant pas connecté, chaque page doit contenir un lien pour se connecter et un autre pour s’enregistrer. En étant connecté, chaque page doit contenir un lien pour se déconnecter.

tests/test_blog.py
import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get('/')
    assert b'Log Out' in response.data
    assert b'test title' in response.data
    assert b'by test on 2018-01-01' in response.data
    assert b'test\nbody' in response.data
    assert b'href="/1/update"' in response.data

Un utilisateur doit être connecté pour accéder aux vues create, update, et delete. L’utilisateur connecté doit être l’auteur du message pour pouvoir accéder à update et delete. Sinon, une erreur 403 Forbidden doit être retournée. Si le post ayant l’identifiant id n’existe pas, alors update et delete doivent retourner 404 Not Found.

tests/test_blog.py
@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
    '/1/delete',
))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers['Location'] == 'http://localhost/auth/login'


def test_author_required(app, client, auth):
    # change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()

    auth.login()
    # current user can't modify other user's post
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get('/').data


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

Les vues create et update doivent retourner une page et un statut 200 OK pour une requête GET. Lorsqu’un donnée valide est envoyée dans une requête POST, create doit insérer la donnée de la requête dans la base de données et update doit mettre à jour l’information déjà présente. Ces deux pages doivent afficher un message d’erreur si les données sont invalides.

tests/test_blog.py
def test_create(client, auth, app):
    auth.login()
    assert client.get('/create').status_code == 200
    client.post('/create', data={'title': 'created', 'body': ''})

    with app.app_context():
        db = get_db()
        count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get('/1/update').status_code == 200
    client.post('/1/update', data={'title': 'updated', 'body': ''})

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post['title'] == 'updated'


@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={'title': '', 'body': ''})
    assert b'Title is required.' in response.data

La vue delete devrait rediriger vers l’URL de l’index et le message ne devrait plus exister dans la base de données.

tests/test_blog.py
def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers['Location'] == 'http://localhost/'

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

Exécution des tests

Une configuration supplémentaire, mais qui n’est pas requise pour l’exécution des tests, mais rend leur exécution moins verbeuse, peut être ajoutée dans le fichier setup.cfg du projet.

setup.cfg
[tool:pytest]
testpaths = tests

[coverage:run]
branch = True
source =
    flaskr

Pour exécuter les tests, utilisez la commande pytest. Elle trouvera et exécutera toutes les fonctions de test que vous avez écrites.

$ pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

Si un test échoue, Pytest affichera l’erreur. Vous pouvez utiliser pytest -v pour lister chaque fonction de test plutôt que de voir des points.

Pour mesurer la couverture de vos tests, utilisez la commande coverage pour lancer pytest plutôt que de le lancer directement.

$ coverage run -m pytest

Vous pouvez soit voir un rapport de couverture simple dans le terminal:

$ coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

Un rapport HTML vous permet de voir les lignes qui sont couvertes par les tests dans chaque fichier:

$ coverage html

Ceci génère des fichiers dans le répertoire htmlcov. Ouvrez le fichier htmlcov/index.html dans votre navigateur pour voir le rapport.

Continuez en lisant le Mise en production.