• en
  • Language: ru
  • Documentation version: 1.1.x

Тестирование приложений Flask

**Что-то, что не проверено, то сломано*.

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

Flask предоставляет способ тестирования вашего приложения, раскрывая тест Werkzeug Client и обрабатывая контекстные локали для вас. Затем вы можете использовать его в своем любимом решении для тестирования.

В этой документации мы будем использовать пакет pytest в качестве базового фреймворка для наших тестов. Вы можете установить его с помощью pip, например, так:

$ pip install pytest

Приложение

Во-первых, нам нужно приложение для тестирования; мы будем использовать приложение из Учебник. Если у вас еще нет этого приложения, получите исходный код из the examples.

Скелет тестирования

Мы начнем с добавления каталога tests в корень приложения. Затем создадим Python-файл для хранения наших тестов (test_flaskr.py). Если мы отформатируем имя файла как test_*.py, он будет автоматически обнаруживаться pytest.

Далее мы создаем pytest fixture под названием client(), который настраивает приложение для тестирования и инициализирует новую базу данных:

import os
import tempfile

import pytest

from flaskr import flaskr


@pytest.fixture
def client():
    db_fd, flaskr.app.config['DATABASE'] = tempfile.mkstemp()
    flaskr.app.config['TESTING'] = True

    with flaskr.app.test_client() as client:
        with flaskr.app.app_context():
            flaskr.init_db()
        yield client

    os.close(db_fd)
    os.unlink(flaskr.app.config['DATABASE'])

Это клиентское приспособление будет вызываться каждым отдельным тестом. Он предоставляет нам простой интерфейс к приложению, где мы можем вызывать тестовые запросы к приложению. Клиент также будет отслеживать cookies для нас.

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

Поскольку SQLite3 основан на файловой системе, мы можем легко использовать модуль tempfile для создания временной базы данных и ее инициализации. Функция mkstemp() делает для нас две вещи: возвращает низкоуровневый файловый хэндл и случайное имя файла, которое мы используем в качестве имени базы данных. Нам остается только сохранить db_fd, чтобы мы могли использовать функцию os.close() для закрытия файла.

Чтобы удалить базу данных после теста, приспособление закрывает файл и удаляет его из файловой системы.

Если мы запустим тестовый пакет, то увидим следующий результат:

$ pytest

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 0 items

=========== no tests ran in 0.07 seconds ============

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

Первое испытание

Теперь пришло время начать тестирование функциональности приложения. Проверим, что приложение показывает «No entries here so far», если мы обращаемся к корню приложения (/). Для этого добавим новую тестовую функцию в test_flaskr.py, вот так:

def test_empty_db(client):
    """Start with a blank database."""

    rv = client.get('/')
    assert b'No entries here so far' in rv.data

Обратите внимание, что наши тестовые функции начинаются со слова test; this allows pytest, чтобы автоматически идентифицировать функцию как тест для запуска.

Используя client.get, мы можем отправить HTTP GET запрос приложению с заданным путем. Возвращаемым значением будет объект response_class. Теперь мы можем использовать атрибут data для проверки возвращаемого значения (в виде строки) от приложения. В этом случае мы убедимся, что 'No entries here so far' является частью вывода.

Запустите его снова, и вы должны увидеть один пройденный тест:

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 1 items

tests/test_flaskr.py::test_empty_db PASSED

============= 1 passed in 0.10 seconds ==============

Вход и выход

Большая часть функциональности нашего приложения доступна только для административного пользователя, поэтому нам нужен способ входа и выхода из приложения для нашего тестового клиента. Для этого мы отправляем несколько запросов на страницы входа и выхода с необходимыми данными формы (имя пользователя и пароль). А поскольку страницы входа и выхода перенаправляют, мы говорим клиенту follow_redirects.

Добавьте следующие две функции в ваш файл test_flaskr.py:

def login(client, username, password):
    return client.post('/login', data=dict(
        username=username,
        password=password
    ), follow_redirects=True)


def logout(client):
    return client.get('/logout', follow_redirects=True)

Теперь мы можем легко проверить, что вход в систему и выход из нее работает и что он не работает при недействительных учетных данных. Добавьте эту новую тестовую функцию:

def test_login_logout(client):
    """Make sure login and logout works."""

    rv = login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'])
    assert b'You were logged in' in rv.data

    rv = logout(client)
    assert b'You were logged out' in rv.data

    rv = login(client, flaskr.app.config['USERNAME'] + 'x', flaskr.app.config['PASSWORD'])
    assert b'Invalid username' in rv.data

    rv = login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'] + 'x')
    assert b'Invalid password' in rv.data

Тест добавления сообщений

Мы также должны проверить, что добавление сообщений работает. Добавьте новую тестовую функцию следующим образом:

def test_messages(client):
    """Test that messages work."""

    login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'])
    rv = client.post('/add', data=dict(
        title='<Hello>',
        text='<strong>HTML</strong> allowed here'
    ), follow_redirects=True)
    assert b'No entries here so far' not in rv.data
    assert b'&lt;Hello&gt;' in rv.data
    assert b'<strong>HTML</strong> allowed here' in rv.data

Здесь мы проверяем, что HTML разрешен в тексте, но не в заголовке, что и предполагалось.

Запуск этой программы должен дать нам три пройденных теста:

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 3 items

tests/test_flaskr.py::test_empty_db PASSED
tests/test_flaskr.py::test_login_logout PASSED
tests/test_flaskr.py::test_messages PASSED

============= 3 passed in 0.23 seconds ==============

Другие приемы тестирования

Помимо использования тестового клиента, как показано выше, существует также метод test_request_context(), который можно использовать в сочетании с оператором with для временной активации контекста запроса. С его помощью вы можете получить доступ к объектам request, g и session, как в функциях представления. Вот полный пример, демонстрирующий этот подход:

import flask

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    assert flask.request.path == '/'
    assert flask.request.args['name'] == 'Peter'

Все остальные объекты, связанные с контекстом, могут быть использованы таким же образом.

Если вы хотите протестировать свое приложение с различными конфигурациями и, похоже, нет хорошего способа сделать это, рассмотрите возможность перехода на фабрики приложений (см. Заводы по производству приложений).

Обратите внимание, что если вы используете контекст тестового запроса, функции before_request() и after_request() не вызываются автоматически. Однако функции teardown_request() действительно выполняются, когда контекст тестового запроса выходит из блока with. Если вы хотите, чтобы функции before_request() также вызывались, вам необходимо вызвать preprocess_request() самостоятельно:

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    app.preprocess_request()
    ...

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

Если вы хотите вызвать функции after_request(), вам нужно обратиться к process_response(), которая, однако, требует, чтобы вы передали ей объект ответа:

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    resp = Response('...')
    resp = app.process_response(resp)
    ...

В целом, это менее полезно, поскольку в этот момент вы можете напрямую начать использовать тестовый клиент.

Подделка ресурсов и контекста

Добавлено в версии 0.10.

Очень распространенным шаблоном является хранение информации об авторизации пользователя и соединениях с базой данных в контексте приложения или объекте flask.g. Общий шаблон для этого - поместить объект туда при первом использовании и затем удалить его при разрушении. Представьте, например, такой код для получения текущего пользователя:

def get_user():
    user = getattr(g, 'user', None)
    if user is None:
        user = fetch_current_user_from_database()
        g.user = user
    return user

Для теста было бы неплохо переопределить этого пользователя извне без необходимости изменения кода. Этого можно добиться, подключив сигнал flask.appcontext_pushed:

from contextlib import contextmanager
from flask import appcontext_pushed, g

@contextmanager
def user_set(app, user):
    def handler(sender, **kwargs):
        g.user = user
    with appcontext_pushed.connected_to(handler, app):
        yield

А затем использовать его:

from flask import json, jsonify

@app.route('/users/me')
def users_me():
    return jsonify(username=g.user.username)

with user_set(app, my_user):
    with app.test_client() as c:
        resp = c.get('/users/me')
        data = json.loads(resp.data)
        self.assert_equal(data['username'], my_user.username)

Сохраняя контекст

Добавлено в версии 0.4.

Иногда полезно запустить обычный запрос, но при этом сохранить контекст на некоторое время, чтобы можно было провести дополнительную интроспекцию. Во Flask 0.4 это возможно с помощью блока test_client() с with:

app = flask.Flask(__name__)

with app.test_client() as c:
    rv = c.get('/?tequila=42')
    assert request.args['tequila'] == '42'

Если бы вы использовали только test_client() без блока with, блок assert завершился бы с ошибкой, поскольку request больше недоступен (потому что вы пытаетесь использовать его вне фактического запроса).

Доступ и изменение сеансов

Добавлено в версии 0.8.

Иногда бывает очень полезно получить доступ или изменить сеансы из тестового клиента. Как правило, для этого есть два способа. Если вы просто хотите убедиться, что в сессии определенные ключи установлены в определенные значения, вы можете просто сохранить контекст и получить доступ к flask.session:

with app.test_client() as c:
    rv = c.get('/')
    assert flask.session['foo'] == 42

Однако это не дает возможности модифицировать сессию или получить доступ к ней до того, как был выполнен запрос. Начиная с версии Flask 0.8 мы предоставляем так называемую «транзакцию сессии», которая имитирует соответствующие вызовы для открытия сессии в контексте тестового клиента и ее изменения. В конце транзакции сессия сохраняется и готова к использованию тестовым клиентом. Это работает независимо от используемого бэкенда сессии:

with app.test_client() as c:
    with c.session_transaction() as sess:
        sess['a_key'] = 'a value'

    # once this is reached the session was stored and ready to be used by the client
    c.get(...)

Обратите внимание, что в этом случае вы должны использовать объект sess вместо прокси flask.session. Однако сам объект будет предоставлять тот же интерфейс.

Тестирование JSON API

Добавлено в версии 1.0.

Flask имеет отличную поддержку JSON и является популярным выбором для создания JSON API. Делать запросы с данными JSON и просматривать данные JSON в ответах очень удобно:

from flask import request, jsonify

@app.route('/api/auth')
def auth():
    json_data = request.get_json()
    email = json_data['email']
    password = json_data['password']
    return jsonify(token=generate_token(email, password))

with app.test_client() as c:
    rv = c.post('/api/auth', json={
        'email': 'flask@example.com', 'password': 'secret'
    })
    json_data = rv.get_json()
    assert verify_token(email, json_data['token'])

Передача аргумента json в методах тестового клиента устанавливает данные запроса в JSON-сериализованный объект и устанавливает тип содержимого application/json. Вы можете получить данные JSON из запроса или ответа с помощью get_json.

Тестирование команд CLI

Click поставляется с utilities for testing вашими командами CLI. CliRunner выполняет команды изолированно и фиксирует вывод в объекте Result.

Flask предоставляет test_cli_runner() для создания FlaskCliRunner, который автоматически передает приложение Flask в CLI. Используйте его метод invoke() для вызова команд так же, как они были бы вызваны из командной строки.

import click

@app.cli.command('hello')
@click.option('--name', default='World')
def hello_command(name):
    click.echo(f'Hello, {name}!')

def test_hello():
    runner = app.test_cli_runner()

    # invoke the command directly
    result = runner.invoke(hello_command, ['--name', 'Flask'])
    assert 'Hello, Flask' in result.output

    # or by name
    result = runner.invoke(args=['hello'])
    assert 'World' in result.output

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

Если вы хотите проверить, как ваша команда разбирает параметры, не запуская команду, используйте ее метод make_context(). Это полезно для тестирования сложных правил валидации и пользовательских типов.

def upper(ctx, param, value):
    if value is not None:
        return value.upper()

@app.cli.command('hello')
@click.option('--name', default='World', callback=upper)
def hello_command(name):
    click.echo(f'Hello, {name}!')

def test_hello_params():
    context = hello_command.make_context('hello', ['--name', 'flask'])
    assert context.params['name'] == 'FLASK'