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.
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.
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.
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.
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.
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.
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.
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é.
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.
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.
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
.
@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.
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.
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.
[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.