Тестовое покрытие¶
Написание модульных тестов для вашего приложения позволяет вам проверить, что написанный вами код работает так, как вы ожидаете. Flask предоставляет тестовый клиент, который имитирует запросы к приложению и возвращает ответные данные.
Вы должны тестировать как можно больше кода. Код в функциях выполняется только при вызове функции, а код в ветвях, таких как блоки if
, выполняется только при выполнении условия. Вы хотите убедиться, что каждая функция тестируется с данными, которые охватывают все ветви.
Чем ближе к 100% покрытию, тем больше уверенности в том, что внесение изменений не приведет к неожиданному изменению другого поведения. Однако 100% покрытие не гарантирует, что в вашем приложении нет ошибок. В частности, оно не проверяет, как пользователь взаимодействует с приложением в браузере. Несмотря на это, покрытие тестами является важным инструментом, который необходимо использовать во время разработки.
Примечание
Это вводится в учебник с опозданием, но в ваших будущих проектах вы должны тестировать по мере разработки.
Вы будете использовать pytest и coverage для тестирования и измерения вашего кода. Установите их оба:
$ pip install pytest coverage
Установка и приспособления¶
Тестовый код находится в каталоге tests
. Этот каталог находится рядом с пакетом flaskr
, а не внутри него. Файл tests/conftest.py
содержит функции настройки, называемые fixtures, которые будет использовать каждый тест. Тесты находятся в модулях Python, которые начинаются с test_
, и каждая функция теста в этих модулях также начинается с test_
.
Для каждого теста будет создаваться новый временный файл базы данных и заполняться некоторыми данными, которые будут использоваться в тестах. Напишите 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');
Приспособление app
будет вызывать фабрику и передавать test_config
для конфигурации приложения и базы данных для тестирования вместо использования локальной конфигурации разработки.
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()
tempfile.mkstemp()
создает и открывает временный файл, возвращая дескриптор файла и путь к нему. Путь DATABASE
переопределяется, поэтому он указывает на этот временный путь вместо папки экземпляра. После установки пути создаются таблицы базы данных и вставляются тестовые данные. После завершения теста временный файл закрывается и удаляется.
TESTING
сообщает Flask, что приложение находится в тестовом режиме. Flask изменяет некоторое внутреннее поведение, чтобы его было легче тестировать, и другие расширения также могут использовать этот флаг, чтобы облегчить их тестирование.
Приспособление client
вызывает app.test_client()
с объектом приложения, созданным приспособлением app
. Тесты будут использовать клиента для выполнения запросов к приложению без запуска сервера.
Приспособление runner
аналогично client
. app.test_cli_runner()
создает бегунок, который может вызывать команды Click, зарегистрированные в приложении.
Pytest использует фикстуры, сопоставляя имена их функций с именами аргументов в тестовых функциях. Например, функция test_hello
, которую вы напишете дальше, принимает аргумент client
. Pytest сопоставляет его с функцией фикстуры client
, вызывает ее и передает возвращаемое значение в тестовую функцию.
Завод¶
В самой фабрике не так много тестов. Большая часть кода уже будет выполнена для каждого теста, так что если что-то не получится, другие тесты это заметят.
Единственное поведение, которое можно изменить, - это передача тестового конфига. Если config не передается, то должна существовать некоторая конфигурация по умолчанию, в противном случае конфигурация должна быть переопределена.
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!'
Вы добавили маршрут hello
в качестве примера при написании фабрики в начале руководства. Он возвращает «Hello, World!», поэтому тест проверяет соответствие данных ответа.
База данных¶
В контексте приложения get_db
должен возвращать одно и то же соединение при каждом вызове. После завершения контекста соединение должно быть закрыто.
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)
Команда init-db
должна вызвать функцию init_db
и вывести сообщение.
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
В этом тесте используется приспособление Pytest monkeypatch
для замены функции init_db
функцией, которая фиксирует, что она была вызвана. Приспособление runner
, которое вы написали выше, используется для вызова команды init-db
по имени.
Аутентификация¶
Для большинства представлений необходимо, чтобы пользователь вошел в систему. Самый простой способ сделать это в тестах - сделать POST
запрос к представлению login
с помощью клиента. Вместо того чтобы писать это каждый раз, вы можете написать класс с методами для этого и использовать фикстуру для передачи ему клиента для каждого теста.
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)
С помощью приспособления auth
вы можете вызвать auth.login()
в тесте, чтобы войти в систему как пользователь test
, который был вставлен как часть тестовых данных в приспособлении app
.
Представление register
должно успешно отображаться на GET
. На POST
с валидными данными формы оно должно перенаправлять на URL входа, а данные пользователя должны быть в базе данных. Недействительные данные должны отображать сообщения об ошибках.
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 response.headers["Location"] == "/auth/login"
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
client.get()
делает запрос GET
и возвращает объект Response
, возвращаемый Flask. Аналогично, client.post()
выполняет запрос POST
, преобразуя дикту data
в данные формы.
Для проверки успешности рендеринга страницы выполняется простой запрос и проверяется наличие 200 OK
status_code
. Если рендеринг не удался, Flask вернет код 500 Internal Server Error
.
headers
будет иметь заголовок Location
с URL логина, когда представление регистрации перенаправляется на представление логина.
data
содержит тело ответа в виде байтов. Если вы ожидаете, что определенное значение будет отображено на странице, проверьте, что оно находится в data
. Байты должны сравниваться с байтами. Если вы хотите сравнить текст, используйте get_data(as_text=True)
вместо этого.
pytest.mark.parametrize
указывает Pytest запустить одну и ту же тестовую функцию с разными аргументами. Вы используете его здесь для тестирования различных недопустимых входных данных и сообщений об ошибках без необходимости писать один и тот же код три раза.
Тесты для представления login
очень похожи на тесты для register
. Вместо того чтобы тестировать данные в базе данных, session
должен иметь user_id
, установленный после входа в систему.
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers["Location"] == "/"
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
Использование client
в блоке with
позволяет получить доступ к контекстным переменным, таким как session
после возврата ответа. Обычно обращение к session
вне запроса приводит к ошибке.
Проверка logout
является противоположностью login
. session
не должен содержать user_id
после выхода из системы.
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
Блог¶
Все представления блога используют приспособление auth
, которое вы написали ранее. Вызов auth.login()
и последующие запросы от клиента будут выполняться от имени пользователя test
.
Представление index
должно отображать информацию о посте, который был добавлен с помощью тестовых данных. При входе в систему в качестве автора должна быть ссылка для редактирования сообщения.
Вы также можете протестировать еще несколько вариантов поведения при аутентификации во время тестирования представления index
. Если вы не вошли в систему, на каждой странице отображаются ссылки для входа в систему или регистрации. Когда вы вошли в систему, есть ссылка для выхода.
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
Пользователь должен войти в систему для доступа к представлениям create
, update
и delete
. Для доступа к update
и delete
пользователь должен быть автором сообщения, иначе возвращается статус 403 Forbidden
. Если post
с заданным id
не существует, update
и delete
должны возвращать 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"] == "/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
Представления create
и update
должны отображать и возвращать статус 200 OK
для запроса GET
. Когда в запросе POST
отправляются корректные данные, create
должен вставить новые данные поста в базу данных, а update
должен изменить существующие данные. Обе страницы должны показывать сообщение об ошибке, если данные недействительны.
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
Представление delete
должно перенаправить на индексный URL, а пост больше не должен существовать в базе данных.
def test_delete(client, auth, app):
auth.login()
response = client.post('/1/delete')
assert response.headers["Location"] == "/"
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post is None
Выполнение тестов¶
В файл setup.cfg
проекта можно добавить некоторые дополнительные настройки, которые не являются обязательными, но делают запуск тестов с покрытием менее многословным.
[tool:pytest]
testpaths = tests
[coverage:run]
branch = True
source =
flaskr
Чтобы запустить тесты, используйте команду pytest
. Она найдет и запустит все написанные вами тестовые функции.
$ 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 =======================
Если какие-либо тесты не работают, pytest покажет ошибку, которая возникла. Вы можете выполнить pytest -v
, чтобы получить список каждой тестовой функции, а не точки.
Чтобы измерить покрытие кода ваших тестов, используйте команду coverage
для запуска pytest вместо того, чтобы запускать его напрямую.
$ coverage run -m pytest
Вы можете либо просмотреть простой отчет о покрытии в терминале:
$ 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%
Отчет в формате HTML позволяет увидеть, какие строки были пройдены в каждом файле:
$ coverage html
При этом создаются файлы в каталоге htmlcov
. Откройте htmlcov/index.html
в браузере, чтобы увидеть отчет.
Продолжить Развертывание в производство.