Главная страница
Навигация по странице:

  • GET /imports/$import_id/citizens/birthdays

  • GET /imports/$import_id/towns/stat/percentile/age

  • Создаем базу данных фикстурой для каждого теста import os import uuid import pytest from sqlalchemy import

  • Создаем фикстурой объект конфигурации Alembic from types import SimpleNamespace import pytest from analyzer.utils.pg import

  • GET /imports/$import_id/citizens

  • Как параметризовать тест import pytest from analyzer.utils.testing import

  • Сравниваем жителей from typing import Iterable, Mapping def normalize_citizen

  • POST /imports

  • курсовая_v1. Курсовая работа Необходимо


    Скачать 2.93 Mb.
    НазваниеКурсовая работа Необходимо
    Дата30.03.2022
    Размер2.93 Mb.
    Формат файлаpdf
    Имя файлакурсовая_v1.pdf
    ТипКурсовая
    #429949
    страница4 из 6
    1   2   3   4   5   6
    вернуть актуальную информацию об обновленном
    жителе. Можно было ограничиться возвращением клиенту данных из его же запроса (раз мы возвращаем ответ клиенту, значит, исключений не было и все запросы успешно выполнены). Или — воспользоваться ключевым словом
    RETURNING в запросах, изменяющих БД, и сформировать ответ из полученных результатов. Но оба этих подхода не позволили бы увидеть и протестировать случай с гонкой состояний.
    К сервису не предъявлялись требования по высокой нагрузке, поэтому можно запрашивать все данные о жителе заново и возвращать клиенту честный результат из БД.
    GET /imports/$import_id/citizens/birthdays
    Обработчик вычисляет число подарков, которое приобретет каждый житель выгрузки своим родственникам (первого порядка). Число сгруппировано по месяцам для выгрузки с указанным import_id
    . В случае обращения к несуществующей выгрузке необходимо вернуть HTTP-ответ
    404: Not Found
    Есть два варианта реализации:
    1. Получить данные для жителей с родственниками из базы, а на стороне
    Python агрегировать данные по месяцам и сгенерировать списки для тех месяцев, для которых нет данных в БД.
    2. Cоставить json-запрос в базу и дописать для отсутствующих месяцев заглушки.
    Я остановился на первом варианте — визуально он выглядит более понятным и поддерживаемым. Число дней рождений в определенном месяце можно получить, сделав
    JOIN
    из таблицы с родственными связями (
    relations.citizen_id
    — житель, для которого мы считаем дни рождения родственников) в таблицу citizens
    (содержит дату рождения, из которой требуется получить
    месяц).
    Значения месяцев не должны содержать ведущих нулей. Месяц, получаемый из поля birth_date c помощью функции date_part
    , может содержать ведущий ноль.
    Чтобы убрать его, я выполнил cast к integer в SQL-запросе.
    Несмотря на то, что в обработчике требуется выполнить два запроса (проверить существование выгрузки и получить информации о днях рождения и подарках), транзакция не требуется.
    По умолчанию PostgreSQL использует режим READ COMMITTED, при котором в текущей транзакции видны все новые (добавляемые другими транзакциями) и существующие (изменяемые другими транзакциями) записи после их успешного завершения.
    Например, если в момент получения данных будет добавлена новая выгрузка — она никак не повлияет на существующие. Если в момент получения данных будет выполнен запрос на изменение жителя — то либо данные еще не будут видны
    (если транзакция, меняющая данные, не завершилась), либо транзакция полностью завершится и станут видны сразу все изменения. Целостность получаемых из базы данных не нарушится.
    GET /imports/$import_id/towns/stat/percentile/age
    Обработчик вычисляет 50-й, 75-й и 99-й перцентили возрастов (полных лет) жителей по городам в выборке с указанным import_id. В случае обращения к несуществующей выгрузке необходимо вернуть HTTP-ответ
    404: Not Found
    Несмотря на то, что в обработчике выполняется два запроса (проверка на существование выгрузки и получение списка жителей), использовать
    транзакцию необязательно.
    Есть два варианта реализации:
    1. Получить из БД возраста жителей, сгруппированные по городам, а затем на стороне Python вычислить перцентили с помощью numpy (который в задании указан как эталонный) и округлить до двух знаков после запятой.
    2. Сделать всю работу на стороне PostgreSQL: функция percentile_cont вычисляет перцентиль с линейной интерполяцией, затем округляем полученные значения до двух знаков после запятой в рамках одного SQL- запроса, а numpy используем для тестирования.
    Второй вариант требует передавать меньше данных между приложением и
    PostgreSQL, но у него есть не очень очевидный подводный камень: в PostgreSQL округление математическое, (
    SELECT ROUND(2.5)
    вернет 3), а в Python — бухгалтерское, к ближайшему целому (
    round(2.5)
    вернет 2).
    Чтобы тестировать обработчик, реализация должна быть одинаковой и в
    PostgreSQL, и в Python (реализовать функцию с математическим округлением в
    Python выглядит проще). Стоит отметить, что при вычислении перцентилей numpy
    и PostgreSQL могут возвращать немного отличающиеся числа, но с учетом округления эта разница будет незаметна.
    Тестирование
    Что нужно проверить в этом приложении? Во-первых, что обработчики отвечают требованиям и выполняют требуемую работу в окружении, максимально близком к боевому. Во-вторых, что миграции, которые изменяют состояние базы данных, работают без ошибок. В-третьих, есть ряд вспомогательных функций, которые тоже было бы правильно покрыть тестами.
    Я решил воспользоваться фреймворком pytest из-за его гибкости и простоты в использовании. Он предлагает мощный механизм подготовки окружения для тестов — фикстуры
    , то есть функции с декоратором pytest.mark.fixture
    , названия которых можно указать параметром в тесте. Если pytest обнаружит в аннотации теста параметр с названием фикстуры, он выполнит эту фикстуру и передаст результат в значении этого параметра. А если фикстура является генератором, то параметр теста примет значение, возвращаемое yield
    , и после окончания теста выполнится вторая часть фикстуры, которая может очистить ресурсы или закрыть соединения.
    Для большинства тестов нам потребуется база данных PostgreSQL. Чтобы изолировать тесты друг от друга, можно перед выполнением каждого теста создавать отдельную базу данных, а после выполнения — удалять ее.
    Создаем базу данных фикстурой для каждого теста
    import
    os
    import
    uuid
    import
    pytest
    from
    sqlalchemy
    import
    create_engine
    from
    sqlalchemy_utils
    import
    create_database, drop_database
    from
    yarl
    import
    URL
    from
    analyzer.utils.pg
    import
    DEFAULT_PG_URL
    PG_URL = os.getenv(
    'CI_ANALYZER_PG_URL'
    , DEFAULT_PG_URL)
    @pytest.fixture
    def
    postgres
    ()
    : tmp_name =
    '.'
    .join([uuid.uuid4().hex,
    'pytest'
    ]) tmp_url = str(URL(PG_URL).with_path(tmp_name)) create_database(tmp_url)
    try
    :
    # Это значение будет иметь параметр postgres в функции-тесте
    yield
    tmp_url
    finally
    : drop_database(tmp_url)
    def
    test_db
    (postgres)
    :
    """

    Пример теста, использующего PostgreSQL
    """
    engine = create_engine(postgres)
    assert
    engine.execute(
    'SELECT 1'
    ).scalar() ==
    1
    engine.dispose()
    C этой задачей здорово справился модуль sqlalchemy_utils
    , учитывающий особенности разных баз данных и драйверов. Например, PostgreSQL не разрешает выполнение
    CREATE DATABASE
    в блоке транзакции. При создании
    БД sqlalchemy_utils переводит psycopg2
    (который обычно выполняет все запросы в транзакции) в режим autocommit.
    Другая важная особенность: если к PostgreSQL подключен хотя бы один клиент — базу данных нельзя удалить, а sqlalchemy_utils отключает всех клиентов перед удалением базы. БД будет успешно удалена, даже если зависнет какой-нибудь тест, имеющий активные подключения к ней.
    PostgreSQL потребуется нам в разных состояниях: для тестирования миграций необходима чистая база данных, в то время как обработчики требуют, чтобы все миграции были применены. Изменять состояние базы данных можно программно с помощью команд Alembic, для их вызова требуется объект конфигурации
    Alembic.
    Создаем фикстурой объект конфигурации Alembic
    from
    types
    import
    SimpleNamespace
    import
    pytest
    from
    analyzer.utils.pg
    import
    make_alembic_config
    @pytest.fixture()
    def
    alembic_config
    (postgres)
    : cmd_options = SimpleNamespace(config=
    'alembic.ini'
    , name=
    'alembic'
    , pg_url=postgres, raiseerr=
    False
    , x=
    None
    )
    return
    make_alembic_config(cmd_options)
    Обратите внимание, что у фикстуры alembic_config есть параметр postgres
    — pytest позволяет не только указывать зависимость теста от фикстур, но и зависимости между фикстурами.
    Этот механизм позволяет гибко разделять логику и писать очень краткий и переиспользуемый код.
    Обработчики
    Для тестирования обработчиков требуется база данных с созданными таблицами и типами данных. Чтобы применить миграции, необходимо программно вызвать команду upgrade Alembic. Для ее вызова потребуется объект с конфигурацией
    Alembic, который мы уже определили фикстурой alembic_config
    . База данных с миграциями выглядит как вполне самостоятельная сущность, и ее можно представить в виде фикстуры:

    from
    alembic.command
    import
    upgrade
    @pytest.fixture
    async
    def
    migrated_postgres
    (alembic_config, postgres)
    : upgrade(alembic_config,
    'head'
    )
    # Возвращаем DSN базы данных, которая была смигрирована
    return
    postgres
    Когда миграций в проекте становится много, их применение для каждого теста может занимать слишком много времени. Чтобы ускорить процесс, можно один раз создать базу данных с миграциями и затем использовать ее в качестве шаблона
    Помимо базы данных для тестирования обработчиков, потребуется запущенное приложение, а также клиент, настроенный на работу с этим приложением. Чтобы приложение было легко тестировать, я вынес его создание в функцию create_app
    , которая принимает параметры для запуска: базу данных, порт для REST API и другие.
    Аргументы для запуска приложения можно также представить в виде отдельной фикстуры. Для их создания потребуется определить свободный порт для запуска тестируемого приложения и адрес до смигрированной временной базы данных.
    Для определения свободного порта я воспользовался фикстурой aiomisc_unused_port из пакета aiomisc.
    Стандартная фикстура aiohttp_unused_port тоже вполне бы подошла, но она возвращает функцию для определения свободых портов, в то время как aiomisc_unused_port возвращает сразу номер порта. Для нашего приложения требуется определить только один свободный порт, поэтому я решил не писать лишнюю строчку кода с вызовом aiohttp_unused_port
    @pytest.fixture
    def
    arguments
    (aiomisc_unused_port, migrated_postgres)
    :
    return
    parser.parse_args(
    [
    '--log-level=debug'
    ,
    '--api-address=127.0.0.1'
    , f'--api-port=
    {aiomisc_unused_port}
    '
    , f'--pg-url=
    {migrated_postgres}
    '
    ]
    )
    Все тесты с обработчиками подразумевают запросы к REST API, работа напрямую с приложением aiohttp не требуется. Поэтому я сделал одну фикстуру, которая запускает приложение и с помощью фабрики aiohttp_client создает и возвращает подключенный к приложению стандартный тестовый клиент aiohttp.test_utils.TestClient

    from
    analyzer.api.app
    import
    create_app
    @pytest.fixture
    async
    def
    api_client
    (aiohttp_client, arguments)
    : app = create_app(arguments) client =
    await
    aiohttp_client(app, server_kwargs={
    'port'
    : arguments.api_port
    })
    try
    :
    yield
    client
    finally
    :
    await
    client.close()
    Теперь, если в параметрах теста указать фикстуру api_client
    , произойдет следующее:
    1. Фикстура postgres создаст базу данных (зависимость для migrated_postgres
    ).
    2. Фикстура alembic_config создаст объект конфигурации Alembic, подключенный к временной базе данных (зависимость для migrated_postgres
    ).
    3. Фикстура migrated_postgres применит миграции (зависимость для arguments
    ).
    4. Фикстура aiomisc_unused_port обнаружит свободный порт (зависимость для arguments
    ).
    5. Фикстура arguments создаст аргументы для запуска (зависимость для api_client
    ).
    6. Фикстура api_client создаст и запустит приложение и вернет клиента для выполнения запросов.
    7. Выполнится тест.
    8. Фикстура api_client отключит клиента и остановит приложение.
    9. Фикстура postgres удалит базу данных.
    Фикстуры позволяют избежать дублирования кода, но помимо подготовки окружения в тестах есть еще одно потенциальное место, в котором будет очень много одинакового кода — запросы к приложению.
    Во-первых, сделав запрос, мы ожидаем получить определенный HTTP-статус. Во- вторых, если статус совпадает с ожидаемым, то перед работой с данными необходимо убедиться, что они имеют правильный формат. Здесь легко ошибиться и написать обработчик, который делает правильные вычисления и возвращает правильный результат, но не проходит автоматическую
    валидацию из-за неправильного формата ответа (например, забыть обернуть ответ в словарь с ключом data
    ). Все эти проверки можно было бы сделать в одном месте.

    В модуле analyzer.testing я подготовил для каждого обработчика функцию- помощник, которая проверяет статус HTTP, а также формат ответа с помощью
    Marshmallow.
    GET /imports/$import_id/citizens
    Я решил начать с обработчика, возвращающего жителей, потому что он очень полезен для проверки результатов работы других обработчиков, изменяющих состояние базы данных.
    Я намеренно не использовал код, добавляющий данные в базу из обработчика
    POST /imports
    , хотя вынести его в отдельную функцию несложно. Код обработчиков имеет свойство меняться, а если в коде, добавляющем в базу, будет какая-либо ошибка, есть вероятность, что тест перестанет работать как задумано и неявно для разработчиков перестанет показывать ошибки.
    Для этого теста я определил следующие наборы данных для тестирования:

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

    Выгрузка с одним жителем без родственников. Проверяет, что поле relatives
    — пустой список (из-за
    LEFT JOIN
    в SQL-запросе список родственников может быть равен
    [None]
    ).

    Выгрузка с жителем, который сам себе родственник.

    Пустая выгрузка. Проверяет, что обработчик разрешает добавить пустую выгрузку и не падает с ошибкой.
    Чтобы запустить один и тот же тест отдельно на каждой выгрузке, я воспользовался еще одним очень мощным механизмом pytest
    — параметризацией
    . Этот механизм позволяет обернуть функцию-тест в декоратор pytest.mark.parametrize и описать в нем, какие параметры должна принимать функция-тест для каждого отдельного тестируемого случая.
    Как параметризовать тест
    import
    pytest
    from
    analyzer.utils.testing
    import
    generate_citizen datasets = [
    # Житель с несколькими родственниками
    [ generate_citizen(citizen_id=
    1
    , relatives=[
    2
    ,
    3
    ]), generate_citizen(citizen_id=
    2
    , relatives=[
    1
    ]), generate_citizen(citizen_id=
    3
    , relatives=[
    1
    ])
    ],
    # Житель без родственников
    [ generate_citizen(relatives=[])
    ],

    # Выгрузка с жителем, который сам себе родственник
    [ generate_citizen(citizen_id=
    1
    , name=
    'Джейн'
    , gender=
    'male'
    , birth_date=
    '17.02.2020'
    , relatives=[
    1
    ])
    ],
    # Пустая выгрузка
    [],
    ]
    @pytest.mark.parametrize('dataset', datasets)
    async
    def
    test_get_citizens
    (api_client, dataset)
    :
    """
    Этот тест будет вызван 4 раза, отдельно для каждого датасета """
    Итак, тест добавит выгрузку в базу данных, затем с помощью запроса к обработчику получит информацию о жителях и сравнит эталонную выгрузку с полученной. Но как сравнить жителей?
    Каждый житель состоит из скалярных полей и поля relatives
    — списка идентификаторов родственников. Список в Python — упорядоченный тип, и при сравнении порядок элементов каждого списка имеет значение, но при сравнении списков с родственниками порядок не должен иметь значение.
    Если привести relatives к множеству перед сравнением, то при сравнении не получится обнаружить ситуацию, когда у одного из жителей в поле relatives есть дубли. Если отсортировать список с идентификаторами родственников, это позволит обойти проблему разного порядка идентификаторов родственников, но при этом обнаружить дубли.
    При сравнении двух списков с жителями можно столкнуться с похожей проблемой: технически, порядок жителей в выгрузке не важен, но важно обнаружить, если в одной выгрузке будет два жителя с одинаковыми идентификаторами, а в другой нет. Так что помимо упорядочивания списка с родственниками relatives для каждого жителя необходимо упорядочить жителей в каждой выгрузке.
    Так как задача сравнения жителей возникнет еще не раз, я реализовал две функции: одну для сравнения двух жителей, а вторую для сравнения двух списков с жителями:
    Сравниваем жителей
    from
    typing
    import
    Iterable, Mapping
    def
    normalize_citizen
    (citizen)
    :
    """
    Возвращает жителя с упорядоченным списком родственников """
    return
    {**citizen,
    'relatives'
    : sorted(citizen[
    'relatives'
    ])}
    def
    compare_citizens
    (left: Mapping, right: Mapping)
    -> bool:
    """

    Сравнивает двух жителей """
    return
    normalize_citizen(left) == normalize_citizen(right)
    def
    compare_citizen_groups
    (left: Iterable, right: Iterable)
    -> bool:
    """
    Упорядочивает списки с родственниками для каждого жителя, списки с жителями и сравнивает их """
    left = [normalize_citizen(citizen)
    for
    citizen
    in
    left] left.sort(key=
    lambda
    citizen: citizen[
    'citizen_id'
    ]) right = [normalize_citizen(citizen)
    for
    citizen
    in
    right] right.sort(key=
    lambda
    citizen: citizen[
    'citizen_id'
    ])
    return
    left == right
    Чтобы убедиться, что этот обработчик не возвращает жителей других выгрузок, я решил перед каждым тестом добавлять дополнительную выгрузку с одним жителем.
    POST /imports
    Я определил следующие наборы данных для тестирования обработчика:

    Корректные данные, ожидается успешное добавление в БД.

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

    Житель с родственниками (более сложный, обычный).
    Проверяет, что обработчик корректно сохраняет данные и о жителе и его родственных связях.

    Житель сам себе родственник.
    Про этот случай было много вопросов, поэтому в шутку решил добавить и его. :)

    Выгрузка с максимального размера
    Проверяет, что aiohttp позволяет загружать такие объемы данных и что при большом количестве данных в PostgreSQL не отправляется больше 32 767 аргументов (обработчик должен выполнить несколько запросов).


    Пустая выгрузка
    Обработчик должен учитывать такой случай и не падать, пытаясь выполнить пустой insert в таблицу с жителями.

    1   2   3   4   5   6


    написать администратору сайта