mirror of https://github.com/mitsuhiko/flask.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
561 lines
18 KiB
561 lines
18 KiB
.. currentmodule:: flask |
|
|
|
Test Coverage |
|
============= |
|
|
|
Writing unit tests for your application lets you check that the code |
|
you wrote works the way you expect. Flask provides a test client that |
|
simulates requests to the application and returns the response data. |
|
|
|
You should test as much of your code as possible. Code in functions only |
|
runs when the function is called, and code in branches, such as ``if`` |
|
blocks, only runs when the condition is met. You want to make sure that |
|
each function is tested with data that covers each branch. |
|
|
|
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:: |
|
This is being introduced late in the tutorial, but in your future |
|
projects you should test as you develop. |
|
|
|
You'll use `pytest`_ and `coverage`_ to test and measure your code. |
|
Install them both: |
|
|
|
.. code-block:: none |
|
|
|
pip install pytest coverage |
|
|
|
.. _pytest: https://pytest.readthedocs.io/ |
|
.. _coverage: https://coverage.readthedocs.io/ |
|
|
|
|
|
Setup and Fixtures |
|
------------------ |
|
|
|
The test code is located in the ``tests`` directory. This directory is |
|
*next to* the ``flaskr`` package, not inside it. The |
|
``tests/conftest.py`` file contains setup functions called *fixtures* |
|
that each test will use. Tests are in Python modules that start with |
|
``test_``, and each test function in those modules also starts with |
|
``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. |
|
|
|
.. code-block:: sql |
|
:caption: ``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'); |
|
|
|
The ``app`` fixture will call the factory and pass ``test_config`` to |
|
configure the application and database for testing instead of using your |
|
local development configuration. |
|
|
|
.. code-block:: python |
|
:caption: ``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() |
|
|
|
:func:`tempfile.mkstemp` creates and opens a temporary file, returning |
|
the file object and the path to it. The ``DATABASE`` path is |
|
overridden so it points to this temporary path instead of the instance |
|
folder. After setting the path, the database tables are created and the |
|
test data is inserted. After the test is over, the temporary file is |
|
closed and removed. |
|
|
|
:data:`TESTING` tells Flask that the app is in test mode. Flask changes |
|
some internal behavior so it's easier to test, and other extensions can |
|
also use the flag to make testing them easier. |
|
|
|
The ``client`` fixture calls |
|
:meth:`app.test_client() <Flask.test_client>` with the application |
|
object created by the ``app`` fixture. Tests will use the client to make |
|
requests to the application without running the server. |
|
|
|
The ``runner`` fixture is similar to ``client``. |
|
:meth:`app.test_cli_runner() <Flask.test_cli_runner>` creates a runner |
|
that can call the Click commands registered with the application. |
|
|
|
Pytest uses fixtures by matching their function names with the names |
|
of arguments in the test functions. For example, the ``test_hello`` |
|
function you'll write next takes a ``client`` argument. Pytest matches |
|
that with the ``client`` fixture function, calls it, and passes the |
|
returned value to the test function. |
|
|
|
|
|
Factory |
|
------- |
|
|
|
There's not much to test about the factory itself. Most of the code will |
|
be executed for each test already, so if something fails the other tests |
|
will notice. |
|
|
|
The only behavior that can change is passing test config. If config is |
|
not passed, there should be some default configuration, otherwise the |
|
configuration should be overridden. |
|
|
|
.. code-block:: python |
|
:caption: ``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!' |
|
|
|
You added the ``hello`` route as an example when writing the factory at |
|
the beginning of the tutorial. It returns "Hello, World!", so the test |
|
checks that the response data matches. |
|
|
|
|
|
Database |
|
-------- |
|
|
|
Within an application context, ``get_db`` should return the same |
|
connection each time it's called. After the context, the connection |
|
should be closed. |
|
|
|
.. code-block:: python |
|
:caption: ``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) |
|
|
|
The ``init-db`` command should call the ``init_db`` function and output |
|
a message. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
This test uses Pytest's ``monkeypatch`` fixture to replace the |
|
``init_db`` function with one that records that it's been called. The |
|
``runner`` fixture you wrote above is used to call the ``init-db`` |
|
command by name. |
|
|
|
|
|
Authentication |
|
-------------- |
|
|
|
For most of the views, a user needs to be logged in. The easiest way to |
|
do this in tests is to make a ``POST`` request to the ``login`` view |
|
with the client. Rather than writing that out every time, you can write |
|
a class with methods to do that, and use a fixture to pass it the client |
|
for each test. |
|
|
|
.. code-block:: python |
|
:caption: ``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) |
|
|
|
With the ``auth`` fixture, you can call ``auth.login()`` in a test to |
|
log in as the ``test`` user, which was inserted as part of the test |
|
data in the ``app`` fixture. |
|
|
|
The ``register`` view should render successfully on ``GET``. On ``POST`` |
|
with valid form data, it should redirect to the login URL and the user's |
|
data should be in the database. Invalid data should display error |
|
messages. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
:meth:`client.get() <werkzeug.test.Client.get>` makes a ``GET`` request |
|
and returns the :class:`Response` object returned by Flask. Similarly, |
|
:meth:`client.post() <werkzeug.test.Client.post>` makes a ``POST`` |
|
request, converting the ``data`` dict into form data. |
|
|
|
To test that the page renders successfully, a simple request is made and |
|
checked for a ``200 OK`` :attr:`~Response.status_code`. If |
|
rendering failed, Flask would return a ``500 Internal Server Error`` |
|
code. |
|
|
|
:attr:`~Response.headers` will have a ``Location`` header with the login |
|
URL when the register view redirects to the login view. |
|
|
|
:attr:`~Response.data` contains the body of the response as bytes. If |
|
you expect a certain value to render on the page, check that it's in |
|
``data``. Bytes must be compared to bytes. If you want to compare |
|
Unicode text, use :meth:`get_data(as_text=True) <werkzeug.wrappers.BaseResponse.get_data>` |
|
instead. |
|
|
|
``pytest.mark.parametrize`` tells Pytest to run the same test function |
|
with different arguments. You use it here to test different invalid |
|
input and error messages without writing the same code three times. |
|
|
|
The tests for the ``login`` view are very similar to those for |
|
``register``. Rather than testing the data in the database, |
|
:data:`session` should have ``user_id`` set after logging in. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
Using ``client`` in a ``with`` block allows accessing context variables |
|
such as :data:`session` after the response is returned. Normally, |
|
accessing ``session`` outside of a request would raise an error. |
|
|
|
Testing ``logout`` is the opposite of ``login``. :data:`session` should |
|
not contain ``user_id`` after logging out. |
|
|
|
.. code-block:: python |
|
:caption: ``tests/test_auth.py`` |
|
|
|
def test_logout(client, auth): |
|
auth.login() |
|
|
|
with client: |
|
auth.logout() |
|
assert 'user_id' not in session |
|
|
|
|
|
Blog |
|
---- |
|
|
|
All the blog views use the ``auth`` fixture you wrote earlier. Call |
|
``auth.login()`` and subsequent requests from the client will be logged |
|
in as the ``test`` user. |
|
|
|
The ``index`` view should display information about the post that was |
|
added with the test data. When logged in as the author, there should be |
|
a link to edit the post. |
|
|
|
You can also test some more authentication behavior while testing the |
|
``index`` view. When not logged in, each page shows links to log in or |
|
register. When logged in, there's a link to log out. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
A user must be logged in to access the ``create``, ``update``, and |
|
``delete`` views. The logged in user must be the author of the post to |
|
access ``update`` and ``delete``, otherwise a ``403 Forbidden`` status |
|
is returned. If a ``post`` with the given ``id`` doesn't exist, |
|
``update`` and ``delete`` should return ``404 Not Found``. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
The ``create`` and ``update`` views should render and return a |
|
``200 OK`` status for a ``GET`` request. When valid data is sent in a |
|
``POST`` request, ``create`` should insert the new post data into the |
|
database, and ``update`` should modify the existing data. Both pages |
|
should show an error message on invalid data. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
The ``delete`` view should redirect to the index URL and the post should |
|
no longer exist in the database. |
|
|
|
.. code-block:: python |
|
:caption: ``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 |
|
|
|
|
|
Running the Tests |
|
----------------- |
|
|
|
Some extra configuration, which is not required but makes running |
|
tests with coverage less verbose, can be added to the project's |
|
``setup.cfg`` file. |
|
|
|
.. code-block:: none |
|
:caption: ``setup.cfg`` |
|
|
|
[tool:pytest] |
|
testpaths = tests |
|
|
|
[coverage:run] |
|
branch = True |
|
source = |
|
flaskr |
|
|
|
To run the tests, use the ``pytest`` command. It will find and run all |
|
the test functions you've written. |
|
|
|
.. code-block:: none |
|
|
|
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 ======================= |
|
|
|
If any tests fail, pytest will show the error that was raised. You can |
|
run ``pytest -v`` to get a list of each test function rather than dots. |
|
|
|
To measure the code coverage of your tests, use the ``coverage`` command |
|
to run pytest instead of running it directly. |
|
|
|
.. code-block:: none |
|
|
|
coverage run -m pytest |
|
|
|
You can either view a simple coverage report in the terminal: |
|
|
|
.. code-block:: none |
|
|
|
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% |
|
|
|
An HTML report allows you to see which lines were covered in each file: |
|
|
|
.. code-block:: none |
|
|
|
coverage html |
|
|
|
This generates files in the ``htmlcov`` directory. Open |
|
``htmlcov/index.html`` in your browser to see the report. |
|
|
|
Continue to :doc:`deploy`.
|
|
|