Тестовое покрытие

Написание модульных тестов для вашего приложения позволяет вам проверить, что написанный вами код работает так, как вы ожидаете. Flask предоставляет тестовый клиент, который имитирует запросы к приложению и возвращает ответные данные.

Вы должны тестировать как можно больше кода. Код в функциях выполняется только при вызове функции, а код в ветвях, таких как блоки if, выполняется только при выполнении условия. Вы хотите убедиться, что каждая функция тестируется с данными, которые охватывают все ветви.

Чем ближе к 100% покрытию, тем больше уверенности в том, что внесение изменений не приведет к неожиданному изменению другого поведения. Однако 100% покрытие не гарантирует, что в вашем приложении нет ошибок. В частности, оно не проверяет, как пользователь взаимодействует с приложением в браузере. Несмотря на это, покрытие тестами является важным инструментом, который необходимо использовать во время разработки.

Примечание

Это вводится в учебник с опозданием, но в ваших будущих проектах вы должны тестировать по мере разработки.

Вы будете использовать pytest и coverage для тестирования и измерения вашего кода. Установите их оба:

$ pip install pytest coverage

Установка и приспособления

Тестовый код находится в каталоге tests. Этот каталог находится рядом с пакетом flaskr, а не внутри него. Файл tests/conftest.py содержит функции настройки, называемые fixtures, которые будет использовать каждый тест. Тесты находятся в модулях Python, которые начинаются с test_, и каждая функция теста в этих модулях также начинается с test_.

Для каждого теста будет создаваться новый временный файл базы данных и заполняться некоторыми данными, которые будут использоваться в тестах. Напишите SQL-файл для вставки этих данных.

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');

Приспособление app будет вызывать фабрику и передавать test_config для конфигурации приложения и базы данных для тестирования вместо использования локальной конфигурации разработки.

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()

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 не передается, то должна существовать некоторая конфигурация по умолчанию, в противном случае конфигурация должна быть переопределена.

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!'

Вы добавили маршрут hello в качестве примера при написании фабрики в начале руководства. Он возвращает «Hello, World!», поэтому тест проверяет соответствие данных ответа.

База данных

В контексте приложения get_db должен возвращать одно и то же соединение при каждом вызове. После завершения контекста соединение должно быть закрыто.

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)

Команда init-db должна вызвать функцию init_db и вывести сообщение.

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

В этом тесте используется приспособление Pytest monkeypatch для замены функции init_db функцией, которая фиксирует, что она была вызвана. Приспособление runner, которое вы написали выше, используется для вызова команды init-db по имени.

Аутентификация

Для большинства представлений необходимо, чтобы пользователь вошел в систему. Самый простой способ сделать это в тестах - сделать POST запрос к представлению login с помощью клиента. Вместо того чтобы писать это каждый раз, вы можете написать класс с методами для этого и использовать фикстуру для передачи ему клиента для каждого теста.

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)

С помощью приспособления auth вы можете вызвать auth.login() в тесте, чтобы войти в систему как пользователь test, который был вставлен как часть тестовых данных в приспособлении app.

Представление register должно успешно отображаться на GET. На POST с валидными данными формы оно должно перенаправлять на URL входа, а данные пользователя должны быть в базе данных. Недействительные данные должны отображать сообщения об ошибках.

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 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, установленный после входа в систему.

tests/test_auth.py
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 после выхода из системы.

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

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

Блог

Все представления блога используют приспособление auth, которое вы написали ранее. Вызов auth.login() и последующие запросы от клиента будут выполняться от имени пользователя test.

Представление index должно отображать информацию о посте, который был добавлен с помощью тестовых данных. При входе в систему в качестве автора должна быть ссылка для редактирования сообщения.

Вы также можете протестировать еще несколько вариантов поведения при аутентификации во время тестирования представления index. Если вы не вошли в систему, на каждой странице отображаются ссылки для входа в систему или регистрации. Когда вы вошли в систему, есть ссылка для выхода.

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

Пользователь должен войти в систему для доступа к представлениям create, update и delete. Для доступа к update и delete пользователь должен быть автором сообщения, иначе возвращается статус 403 Forbidden. Если post с заданным id не существует, update и delete должны возвращать 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"] == "/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 должен изменить существующие данные. Обе страницы должны показывать сообщение об ошибке, если данные недействительны.

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

Представление delete должно перенаправить на индексный URL, а пост больше не должен существовать в базе данных.

tests/test_blog.py
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 проекта можно добавить некоторые дополнительные настройки, которые не являются обязательными, но делают запуск тестов с покрытием менее многословным.

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 в браузере, чтобы увидеть отчет.

Продолжить Развертывание в производство.

Вернуться на верх