курсовая_v1. Курсовая работа Необходимо
Скачать 2.93 Mb.
|
вернуть актуальную информацию об обновленном жителе. Можно было ограничиться возвращением клиенту данных из его же запроса (раз мы возвращаем ответ клиенту, значит, исключений не было и все запросы успешно выполнены). Или — воспользоваться ключевым словом 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 аргументов (обработчик должен выполнить несколько запросов). |