Данные с ошибками, ожидаем HTTP-ответ 400: Bad Request.
•
Дата рождения некорректная (будущее время).
• citizen_id в рамках выгрузки не уникален.
•
Родственная связь указана неверно (есть только от одного жителя к другому, но нет обратной).
•
У жителя указан несуществующий в выгрузке родственник.
•
Родственные связи не уникальны.
Если обработчик отработал успешно и данные были добавлены, необходимо получить добавленных в БД жителей и сравнить их с эталонной выгрузки. Для получения жителей я воспользовался уже протестированным обработчиком
GET
/imports/$import_id/citizens
, а для сравнения — функцией compare_citizen_groups
PATCH /imports/$import_id/citizens/$citizen_id
Валидация данных во многом похожа на описанную в обработчике
POST /imports с небольшими исключениями: есть только один житель и клиент может передать только те поля, которые пожелает
Я решил использовать следующие наборы с некорректными данными, чтобы проверить, что обработчик вернет HTTP-ответ
400: Bad request
:
•
Поле указано, но имеет некорректный тип и/или формат данных
•
Указана некорректная дата рождения (будущее время).
•
Поле relatives содержит несуществующего в выгрузке родственника.
Также необходимо проверить, что обработчик корректно обновляет информацию о жителе и его родственниках.
Для этого создадим выгрузку с тремя жителями, два из которых — родственники, и отправим запрос с новыми значениями всех скалярных полей и новым идентификатором родственника в поле relatives
Чтобы убедиться, что обработчик различает жителей разных выгрузок перед тестом (и, например, не изменит жителей с одинаковыми идентификаторами из другой выгрузки), я создал дополнительную выгрузку с тремя жителями, которые имеют такие же идентификаторы.
Обработчик должен сохранить новые значения скалярных полей, добавить нового указанного родственника и удалить связь со старым, не указанным
родственником. Все изменения родственных связей должны быть двусторонними.
Изменений в других выгрузках быть не должно.
Поскольку такой обработчик может быть подвержен состоянию гонки (это рассматривалось в разделе «Разработка»), я добавил два дополнительных теста
Один воспроизводит проблему с состоянием гонки (расширяет класс обработчика и убирает блокировку),
второй доказывает, что проблема с состоянием гонки не воспроизводится.
GET /imports/$import_id/citizens/birthdays Для тестирования этого обработчика я выбрал следующие наборы данных:
•
Выгрузка, в которой у жителя есть один родственник в одном месяце и два родственника в другом.
•
Выгрузка с одним жителем без родственников. Проверяет, что обработчик не учитывает его при расчетах.
•
Пустая выгрузка. Проверяет, что обработчик не упадет с ошибкой и вернет в ответе корректный словарь с 12 месяцами.
•
Выгрузка с жителем, который сам себе родственник. Проверяет, что житель купит себе подарок в месяц своего рождения.
Обработчик должен возвращать в ответе все месяцы, даже если в эти месяцы нет дней рождений. Чтобы избежать дублирования, я сделал функцию, которой можно передать словарь, чтобы она дополнила его значениями для отсутствующих месяцев.
Чтобы убедиться, что обработчик различает жителей разных выгрузок, я добавил дополнительную выгрузку с двумя родственниками. Если обработчик по ошибке использует их при расчетах, то результаты будут некорректными и обработчик упадет с ошибкой.
GET /imports/$import_id/towns/stat/percentile/age Особенность этого теста в том, что результаты его работы зависят от текущего времени: возраст жителей вычисляется исходя из текущей даты. Чтобы результаты тестирования не менялись с течением времени, текущую дату, даты рождения жителей и ожидаемые результаты необходимо зафиксировать. Это позволит легко воспроизвести любые, даже краевые случаи.
Как лучше зафиксировать дату? В обработчике для вычисления возраста жителей используется PostgreSQL-функция
AGE
, принимающая первым параметром дату, для которой необходимо рассчитать возраст, а вторым — базовую дату
(определена константой
TownAgeStatView.CURRENT_DATE
).
Подменяем базовую дату в обработчике на время тестаfrom unittest.mock
import patch
import
pytz
CURRENT_DATE = datetime(
2020
,
2
,
17
, tzinfo=pytz.utc)
@patch('analyzer.api.handlers.TownAgeStatView.CURRENT_DATE', new=CURRENT_DATE)
async
def
test_get_ages
(...)
:
Для тестирования обработчика я выбрал следующие наборы данных (для всех жителей указывал один город, потому что обработчик агрегирует результаты по городам):
•
Выгрузка с несколькими жителями, у которых завтра день рождения
(возраст — несколько лет и 364 дня). Проверяет, что обработчик использует в расчетах только количество полных лет.
•
Выгрузка с жителем, у которого сегодня день рождения (возраст — ровно несколько лет). Проверяет краевой случай — возраст жителя, у которого сегодня день рождения, не должен рассчитаться как уменьшенный на 1 год.
•
Пустая выгрузка. Обработчик не должен на ней падать.
Эталон для расчета перцентилей — numpy с линейной интерполяцией, и эталонные результаты для тестирования я рассчитал именно им.
Также нужно округлять дробные значения перцентилей до двух знаков после запятой. Если вы использовали в обработчике для округления PostgreSQL, а для расчета эталонных данных — Python, то могли заметить, что округление в
Python 3 и PostgreSQL может давать разные результаты.
Например
# Python 3 round(2.5)
> 2
-- PostgreSQL
SELECT ROUND(2.5)
> 3
Дело в том, что Python использует банковское округление до ближайшего четного
, а PostgreSQL — математическое (half-up). В случае, если расчеты и округление производятся в PostgreSQL, было бы правильным в тестах также использовать математическое округление.
Сначала опишите наборы данных с датами рождения в текстовом формате, но читать тест в таком формате будет неудобно: придется каждый раз вычислять в уме возраст каждого жителя, чтобы вспомнить, что проверяет тот или иной набор данных. Конечно, можно было обойтись комментариями в коде, но я решил пойти чуть дальше и написал функцию age2date
, которая позволяет описать дату
рождения в виде возраста: количества лет и дней.
Например, вот такimport pytz
from analyzer.utils.testing
import generate_citizen
CURRENT_DATE = datetime(
2020
,
2
,
17
, tzinfo=pytz.utc)
defage2date(years: int, days: int = 0, base_date=CURRENT_DATE)
-> str: birth_date = copy(base_date).replace(year=base_date.year - years) birth_date -= timedelta(days=days)
return birth_date.strftime(BIRTH_DATE_FORMAT)
# Сколько лет этому жителю? Посчитать несложно, но если их будет много?
generate_citizen(birth_date=
'17.02.2009'
)
# Жителю ровно 11 лет и у него сегодня день рождения generate_citizen(birth_date=age2date(years=
11
))
Чтобы убедиться, что обработчик различает жителей разных выгрузок, я добавил дополнительную выгрузку с одним жителем из другого города: если обработчик по ошибке использует его, в результатах появится лишний город и тест сломается.
Миграции Код миграций на первый взгляд кажется очевидным и наименее подверженным ошибкам, зачем его тестировать? Это очень опасное заблуждение: самые коварные ошибки миграций могут проявить себя в самый неподходящий момент.
Даже
если они не испортят данные, то могут стать причиной лишнего даунтайма.
Существующая в проекте
initial миграция изменяет структуру базы данных, но не изменяет данные. От каких типовых ошибок можно защититься в подобных миграциях?
•
Метод downgrade не реализован или не удалены все созданные в миграции сущности (особенно это касается пользовательских типов данных, которые создаются автоматически при создании таблицы, я про них уже упоминал).
Это приведет к тому, что миграцию нельзя будет применить два раза
(применить-откатить-применить): при откате не будут удалены все созданные миграцией сущности, при повторном создании миграция пройдет с ошибкой — тип данных уже существует.
•
Cинтаксические ошибки и опечатки.
•
Ошибки в связях миграций (цепочка нарушена).
Большинство этих ошибок обнаружит stairway-тест
. Его идея — применять миграции по одной, последовательно выполняя методы upgrade
, downgrade
, upgrade для каждой миграции. Такой тест достаточно
один раз добавить в проект, он не требует поддержки и будет служить верой и правдой.
А вот если миграция, помимо структуры, изменяла бы данные, то потребовалось бы написать хотя бы один отдельный тест, проверяющий, что данные корректно изменяются в методе upgrade и возвращаются к изначальному состоянию в downgrade
. На всякий случай: проект с примерами тестирования разных миграций
,
Сборка
Конечный артефакт, который мы собираемся разворачивать и
который хотим получить в результате сборки, — Docker-образ. Для сборки необходимо
выбрать базовый образ c Python. Официальный образ python:latest весит 1 ГБ и, если его использовать в качестве базового, образ с приложением будет огромным. Существуют образы на основе ОС Alpine
, размер которых намного меньше. Но с растущим количеством устанавливаемых пакетов размер конечного образа вырастет, и в итоге даже образ, собранный на основе Alpine, будет не таким уж и маленьким. Я выбрал в качестве базового образа snakepacker/python
— он весит немного больше Alpine-образов, но основан на Ubuntu, которая предлагает огромный выбор пакетов и библиотек.
Еще один способ
уменьшить размер образа с приложением — не включать в итоговый образ компилятор, библиотеки и файлы с заголовками для сборки, которые не потребуются для работы приложения.
Для этого можно воспользоваться многоступенчатой сборкой
Docker:
1. С помощью «тяжелого» образа snakepacker/python:all (1 ГБ, в сжатом виде 500 МБ) создаем виртуальное окружение, устанавливаем в него все зависимости и пакет с приложением. Этот образ нужен исключительно для сборки, он может содержать компилятор, все необходимые библиотеки и файлы с заголовками.
2. FROM snakepacker/python:all
as builder
3.
4.
# Создаем виртуальное окружение
5. RUN python3
.8
-m venv /usr/share/python3/app
6.
7.
# Копируем source distribution в
контейнер и устанавливаем его8. COPY dist/ /mnt/dist/
RUN /usr/share/python3/app/bin/pip install /mnt/dist/*
9. Готовое виртуальное окружение копируем в «легкий» образ snakepacker/python:3.8 (100 МБ, в сжатом виде 50 МБ), который содержит только интерпретатор требуемой версии Python.
Важно: в виртуальном окружении используются абсолютные пути, поэтому его необходимо скопировать по тому же адресу, по которому оно было собрано в контейнере-сборщике.
10. FROM snakepacker/python:
3.8
as
api
11.
12.
# Копируем готовое виртуальное окружение из контейнера builder
13. COPY --
from
=builder /usr/share/python3/app /usr/share/python3/app
14.
15.
# Устанавливаем ссылки, чтобы можно было воспользоваться командами
16.
# приложения
17. RUN ln -snf /usr/share/python3/app/bin/analyzer-* /usr/local/bin/
18.
19.
# Устанавливаем выполняемую при запуске контейнера команду по умолчанию
CMD [
"analyzer-api"
]
Чтобы сократить время на сборку образа, зависимые модули приложения можно установить до его установки в виртуальное окружение. Тогда Docker закеширует их и не будет устанавливать заново, если они не менялись.
Dockerfile целиком
############### Образ для сборки виртуального окружения ################
# Основа — «тяжелый» (1 ГБ, в сжатом виде 500 ГБ) образ со всеми необходимыми
# библиотеками для сборки модулей
FROM snakepacker/python:all
as
builder
# Создаем виртуальное окружение и обновляем pip
RUN python3
.8
-m venv /usr/share/python3/app
RUN /usr/share/python3/app/bin/pip install -U pip
# Устанавливаем зависимости отдельно, чтобы закешировать. При последующей сборке
# Docker пропустит этот шаг, если requirements.txt не изменится
COPY requirements.txt /mnt/
RUN /usr/share/python3/app/bin/pip install -Ur /mnt/requirements.txt
# Копируем source distribution в контейнер и устанавливаем его
COPY dist/ /mnt/dist/
RUN /usr/share/python3/app/bin/pip install /mnt/dist/* \
&& /usr/share/python3/app/bin/pip check
########################### Финальный образ ############################
# За основу берем «легкий» (100 МБ, в сжатом виде 50 МБ) образ с Python
FROM snakepacker/python:
3.8
as
api
# Копируем в него готовое виртуальное окружение из контейнера builder
COPY --
from
=builder /usr/share/python3/app /usr/share/python3/app
# Устанавливаем ссылки, чтобы можно было воспользоваться командами
# приложения
RUN ln -snf /usr/share/python3/app/bin/analyzer-* /usr/local/bin/
# Устанавливаем выполняемую при запуске контейнера команду по умолчанию
CMD [
"analyzer-api"
]
Для удобства сборки я добавил команду make upload
, которая собирает Docker- образ и загружает его на hub.docker.com.
CI
Теперь, когда код покрыт тестами и мы умеем собирать Docker-образ, самое время автоматизировать эти процессы. Первое, что приходит в голову:
запускать тесты на создание пул-реквестов, а при добавлении изменений в master-ветку собирать новый Docker-образ и загружать его на
Docker Hub
(или
GitHub
Packages
, если вы не собираетесь распространять образ публично).
Можно решить эту задачу с помощью
GitHub Actions
. Для этого потребовалось создать YAML-файл в папке
.github/workflows и описать в нем workflow (c двумя задачами: test и publish
), которое я назвал
CI
Задача test выполняется при каждом запуске workflow
CI
, с помощью services поднимает контейнер с PostgreSQL, ожидает, когда он станет доступен, и запускает pytest в контейнере snakepacker/python:all
Задача publish выполняется, только если изменения были добавлены в ветку master и если задача test была выполнена успешно. Она собирает source distribution контейнером snakepacker/python:all
, затем собирает и загружает
Docker-образ с помощью docker/build-push-action@v1
Полное описание workflowname: CI
# Workflow должен выполняться при добавлении изменений
# или новом пул-реквесте в master on: push: branches: [ master ] pull_request: branches: [ master ] jobs:
# Тесты должны выполняться при каждом запуске workflow test: runs-on: ubuntu-latest services: postgres: image: docker://postgres ports:
- 5432:5432 env:
POSTGRES_USER: user
POSTGRES_PASSWORD: hackme
POSTGRES_DB: analyzer steps:
- uses: actions/checkout@v2
- name: test uses: docker://snakepacker/python:all env:
CI_ANALYZER_PG_URL: postgresql://user:hackme@postgres/analyzer with: args: /bin/bash -c "pip install -U '.[dev]' && pylama && wait-for- port postgres:5432 && pytest -vv --cov=analyzer --cov-report=term-missing tests"
# Сборка и загрузка Docker-образа с приложением publish:
# Выполняется только если изменения попали в ветку master if: github.event_name == 'push' && github.ref == 'refs/heads/master'
# Требует, чтобы задача test была выполнена успешно needs: test runs-on: ubuntu-latest steps:
- uses: actions/checkout@v2
- name: sdist uses: docker://snakepacker/python:all with: args: make sdist
- name: build-push uses: docker/build-push-action@v1 with: username: ${{ secrets.REGISTRY_LOGIN }} password: ${{ secrets.REGISTRY_TOKEN }} repository: alvassin/backendschool2019 target: api tags: 0.0.1, latest
Теперь при добавлении изменений в master во вкладке Actions на GitHub можно увидеть запуск тестов, сборку и загрузку Docker-образа:
А при создании пул-реквеста в master-ветку в нем также будут отображаться
результаты выполнения задачи test
:
Деплой
Чтобы развернуть приложение на предоставленном сервере, нужно установить
Docker, Docker Compose, запустить контейнеры с приложением и PostgreSQL и применить миграции.
Эти шаги можно автоматизировать с помощью системы управления конфигурациями Ansible. Она написана на Python, не требует специальных агентов (подключается прямо по ssh), использует jinja-шаблоны и позволяет декларативно описывать желаемое состояние в YAML-файлах. Декларативный подход позволяет не задумываться о текущем состоянии системы и действиях, необходимых, чтобы привести систему к желаемому состоянию. Вся эта работа ложится на плечи модулей Ansible.
Ansible позволяет сгруппировать логически связанные задачи в роли и затем переиспользовать. Нам потребуются две роли: docker
(устанавливает и настраивает Docker) и analyzer
(устанавливает и настраивает приложение).
Роль
docker
добавляет в систему репозиторий с Docker, устанавливает и настраивает пакеты docker-ce и docker-compose
Опционально можно наладить автоматическое возобновление работы REST API после перезагрузки сервера. Ubuntu позволяет решить эту задачу силами системы инициализации systemd
. Она управляет юнитами, представляющими собой различные ресурсы (демоны, сокеты, точки монтирования и другие). Чтобы добавить новый юнит в systemd, необходимо описать его конфигурацию в отдельном файле .service и разместить этот файл в одной из специальных папок, например в
/etc/systemd/system
. Затем юнит можно запустить, а также включить для него автозагрузку.
Пакет docker-ce при установке автоматически создаст файл с конфигурацией юнита — необходимо только убедиться, что он запущен и включается при запуске системы. Для Docker Compose файл конфигурации docker-compose@.service будет
создан силами Ansible. Символ
@
в названии указывает systemd, что юнит является шаблоном. Это позволяет запускать сервис docker-compose с параметром — например, с
названием нашего сервиса, который будет подставлен вместо
%i в файле конфигурации юнита:
[Unit]
Description=%i service with docker compose
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/etc/docker/compose/%i
ExecStart=/usr/local/bin/docker-compose up -d --remove-orphans
ExecStop=/usr/local/bin/docker-compose down
[Install]
WantedBy=multi-user.target