Сегодня мы открываем исходный код testsuite — фреймворка для тестирования HTTP-сервисов, который разработан и применяется в Яндекс.Такси. Исходники опубликованы на GitHub под лицензией MIT.
С помощью testsuite удобно тестировать HTTP-сервисы. Он предоставляет готовые механизмы, чтобы:
— Взаимодействовать с сервисом через вызовы его HTTP API.
— Перехватить и обработать HTTP-вызовы, которые сервис отправляет во внешние сервисы.
— Проверить, какие вызовы во внешние сервисы сделаны и в каком порядке.
— Взаимодействовать с базой данных сервиса, чтобы создать предусловие или проверить результат.
Область применения
Бэкенд Яндекс.Такси состоит из сотен микросервисов, постоянно появляются новые. Все высоконагруженные сервисы мы разрабатываем на С++ с использованием собственного фреймворка userver, о нём мы уже рассказывали на Хабре. Менее требовательные к нагрузке сервисы, а также прототипы делаем на Python.
Чтобы убедиться, что сервис хорошо решает свою задачу, предоставляя API другим сервисам и конечному приложению, мы хотим тестировать его как целое, преимущественно по принципу чёрного ящика.
Готовых инструментов для этого нет — вам пришлось бы писать код для настройки тестового окружения, который будет:
— поднимать и наливать базу данных;
— перехватывать и подменять HTTP-запросы;
— запускать в этом окружении тестируемый сервис.
Решать эту задачу, пользуясь фреймворками для unit-тестов, слишком трудно и неправильно, потому что их задача другая: модульное тестирование более мелких структурных единиц — компонентов, классов, функций.
В основе testsuite лежит pytest, стандартный для Python тестовый фреймворк. При этом неважно, на каком языке написан микросервис, который мы тестируем. Сейчас testsuite работает на операционных системах GNU/Linux, macOS.
Хотя testsuite удобен для интеграционных сценариев, то есть взаимодействия нескольких сервисов (а если сервис написан на Python — то и для низкоуровневых), эти случаи мы рассматривать не будем. Далее речь пойдёт только о тестировании отдельно взятого сервиса.
Уровень детализации | Инструмент тестирования |
---|---|
Метод/функция, класс, компонент, библиотека | Стандартные unit-тесты, pytest, Googletest, иногда всё-таки testsuite |
Микросервис | testsuite |
Ансамбль микросервисов (приложение) | Интеграционные тесты testsuite (в этой статье не рассматриваются) |
Принцип действия
Конечная цель — убедиться, что сервис правильно отвечает на HTTP-вызовы, поэтому тестируем через HTTP-вызовы.
Запуск/остановка сервиса — это рутинная операция. Поэтому проверяем:
— что после запуска сервис отвечает по HTTP;
— как ведёт себя сервис, если внешние сервисы временно недоступны.
Testsuite:
— Запускает базу данных (PostgreSQL, MongoDB...).
— Перед каждым тестом наполняет базу тестовыми данными.
— Запускает тестируемый микросервис в отдельном процессе.
— Запускает собственный веб-сервер (mockserver), который имитирует (мокает) для сервиса внешнее окружение.
— Выполняет тесты.
Тесты могут проверять:
— Правильно ли сервис обрабатывает HTTP-запросы.
— Как работает сервис непосредственно в базе данных.
— Наличие/отсутствие/последовательность вызовов во внешние сервисы.
— Внутреннее состояние сервиса с помощью информации, который тот передаёт в Testpoint.
mockserver
Мы тестируем поведение отдельного микросервиса. Вызовы HTTP API внешних сервисов должны быть замоканы. За эту часть работы в testsuite отвечают его собственные плагины mockserver
и mockserver_https
. Mockserver — это HTTP-сервер с настраиваемыми на каждый тест обработчиками запросов и памятью о том, какие запросы обработаны и какие при этом переданы данные.
База данных
Testsuite позволяет тесту напрямую обращаться к базе данных для чтения и записи. С помощью данных можно формировать предусловие теста и проверять результат. Из коробки поддержаны PostgreSQL, MongoDB, Redis.
Как начать пользоваться
Чтобы писать тесты testsuite, разработчик должен знать Python и стандартный фреймворк pytest.
Продемонстрируем использование testsuite пошагово на примере простого чата. Здесь исходные коды приложения и тестов.
Фронтенд chat.html взаимодействует с сервисом chat-backend.
Чтобы продемонстрировать взаимодействие сервисов, chat-backend делегирует хранение сообщений сервису хранилища. Хранилище реализовано двумя способами, chat-storage-mongo и chat-storage-postgres.
chat-backend
Сервис chat-backend — точка входа для запросов с фронтенда. Умеет отправлять и возвращать список сообщений.
Сервис
Покажем для примера обработчик запроса POST /messages/retrieve
:
@routes.post('/messages/retrieve')
async def handle_list(request):
async with aiohttp.ClientSession() as session:
# Получить сообщения из сервиса хранилища
response = await session.post(
storage_service_url + 'messages/retrieve',
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
response_body = await response.json()
# Обратить порядок полученных сообщений, чтобы последние были в конце списка
messages = list(reversed(response_body['messages']))
result = {'messages': messages}
return web.json_response(result)
Тесты
Подготовим инфраструктуру testsuite к запуску сервиса. Укажем, с какими настройками мы хотим запускать сервис.
# Запускаем сервис один раз на сессию.
# Можно запускать и на каждый тест (убрать scope='session'), но это медленно
@pytest.fixture(scope='session')
async def service_daemon(
register_daemon_scope, service_spawner, mockserver_info,
):
python_path = os.getenv('PYTHON3', 'python3')
service_path = pathlib.Path(__file__).parent.parent
async with register_daemon_scope(
name='chat-backend',
spawn=service_spawner(
# Команда запуска сервиса. Первый элемент массива — исполняемый файл,
# далее аргументы командной строки
[
python_path,
str(service_path.joinpath('server.py')),
'--storage-service-url',
# Направим запросы в сервис хранилища в mockserver,
# далее в тестах мы настроим обработку запросов в mockserver по пути /storage
mockserver_info.base_url + 'storage/',
],
# Диагностический URL, отвечает на запросы после успешного запуска
check_url=SERVICE_BASEURL + 'ping',
),
) as scope:
yield scope
Зададим фикстуру клиента, через неё тест отправляет HTTP-запрос в сервис.
@pytest.fixture
async def server_client(
service_daemon, # HTTP-статус ответа == 204
service_client_options,
ensure_daemon_started,
# Зависимость от mockserver нужна, чтобы любой тест завершился с ошибкой,
# если сервис отправил запрос, который мы забыли замокать
mockserver,
):
await ensure_daemon_started(service_daemon)
yield service_client.Client(SERVICE_BASEURL, **service_client_options)
Теперь инфраструктура знает, как запустить chat-backend
и как отправить в него запрос. Этого достаточно, чтобы приступить к написанию тестов.
Обратите внимание, в тестах chat-backend
мы никак не используем сервисы хранилища, ни chat-storage-mongo
, ни chat-storage-postgres
. Чтобы chat-backend
нормально обработал вызовы, мы мокаем API хранилища с помощью mockserver
.
Напишем тест на метод POST messages/send
. Проверим, что:
— запрос обработается штатно;
— при обработке запроса chat-backend
вызывает метод хранилища POST messages/send
.
async def test_messages_send(server_client, mockserver):
# Замокаем с помощью mockserver метод хранилища POST messages/send
@mockserver.handler('/storage/messages/send')
async def handle_send(request):
# Убедимся, что в хранилище отправлено то самое сообщение,
# которое мы отправляем в chat-backend
assert request.json == {
'username': 'Bob',
'text': 'Hello, my name is Bob!',
}
return mockserver.make_response(status=204)
# Отправим запрос в chat-backend
response = await server_client.post(
'messages/send',
json={'username': 'Bob', 'text': 'Hello, my name is Bob!'},
)
# Проверим, что запрос обработан штатно и вернул ожидаемый HTTP-статус
assert response.status == 204
# Проверим, что chat-backend один раз отправил в хранилище запрос POST messages/send
assert handle_send.times_called == 1
Напишем тест на метод POST messages/retrieve
. Проверим, что:
— запрос обработан штатно;
— при обработке запроса chat-backend
вызывает метод хранилища POST /messages/retrieve
;
— chat-backend
«переворачивает» список сообщений, полученный из хранилища, чтобы последние сообщения были в конце списка.
async def test_messages_retrieve(server_client, mockserver):
messages = [
{
'username': 'Bob',
'created': '2020-01-01T12:01:00.000',
'text': 'Hi, my name is Bob!',
},
{
'username': 'Alice',
'created': {'$date': '2020-01-01T12:02:00.000'},
'text': 'Hi Bob!',
},
]
# Замокаем с помощью mockserver метод хранилища POST messages/retrieve
@mockserver.json_handler('/storage/messages/retrieve')
async def handle_retrieve(request):
return {'messages': messages}
# Отправим запрос в chat-backend
response = await server_client.post('messages/retrieve')
# Проверим, что запрос обработан штатно и вернул ожидаемый HTTP-статус
assert response.status == 200
body = response.json()
# Проверим, что в ответе chat-backend порядок сообщений обратен порядку,
# который отдаёт хранилище, чтобы последние сообщения оказались в конце списка
assert body == {'messages': list(reversed(messages))}
# Проверим, что chat-backend один раз отправил в хранилище запрос POST messages/retrieve
assert handle_retrieve.times_called == 1
chat-storage-postgres
Сервис chat-storage-postgres
отвечает за чтение и запись сообщений чата в базу данных PostgreSQL.
Сервис
Вот так мы читаем список сообщений из PostgreSQL в методе POST /messages/retrieve
:
@routes.post('/messages/retrieve')
async def get(request):
async with app['pool'].acquire() as connection:
records = await connection.fetch(
'SELECT created, username, "text" FROM messages '
'ORDER BY created DESC LIMIT 20',
)
messages = [
{
'created': record[0].isoformat(),
'username': record[1],
'text': record[2],
}
for record in records
]
return web.json_response({'messages': messages})
Тесты
Сервис, который мы тестируем, использует базу данных PostgreSQL. Чтобы всё работало, нам достаточно указать testsuite, в какой директории искать схемы таблиц.
@pytest.fixture(scope='session')
def pgsql_local(pgsql_local_create):
# Укажем, в какой директории искать схемы
tests_dir = pathlib.Path(__file__).parent
sqldata_path = tests_dir.joinpath('../schemas/postgresql')
databases = discover.find_databases('chat_storage_postgres', sqldata_path)
return pgsql_local_create(list(databases.values()))
В остальном настройка инфраструктуры conftest.py не отличается от описанного выше сервиса chat-backend
.
Перейдём к тестам.
Напишем тест на метод POST messages/send
. Проверим, что он сохраняет сообщение в базу данных.
async def test_messages_send(server_client, pgsql):
# Отправим запрос POST /messages/send
response = await server_client.post(
'/messages/send', json={'username': 'foo', 'text': 'bar'},
)
# Проверим, что запрос обработан штатно
assert response.status_code == 200
# Проверим, что в теле ответа JSON с идентификатором сохранённого сообщения
data = response.json()
assert 'id' in data
# Найдём сохранённое сообщение в PostgreSQL по идентификатору
cursor = pgsql['chat_messages'].cursor()
cursor.execute(
'SELECT username, text FROM messages WHERE id = %s', (data['id'],),
)
record = cursor.fetchone()
# Проверим, что в сохранённом сообщении те же имя пользователя и текст,
# что были отправлены в HTTP-запросе
assert record == ('foo', 'bar')
Напишем тест на метод POST messages/retrieve
. Проверим, что он возвращает сообщения из базы данных.
Для начала создадим скрипт, который добавит в таблицу нужные нам записи. Testsuite автоматически выполнит скрипт перед тестом.
-- файл chat-storage-postgres/tests/static/test_service/pg_chat_messages.sql
INSERT INTO messages(id, created, username, text) VALUES (DEFAULT, '2020-01-01 00:00:00.0+03', 'foo', 'hello, world!');
INSERT INTO messages(id, created, username, text) VALUES (DEFAULT, '2020-01-01 00:00:01.0+03', 'bar', 'happy ny');
# файл chat-storage-postgres/tests/test_service.py
async def test_messages_retrieve(server_client, pgsql):
# Перед выполнением этого теста testsuite запишет в базу данные из
# скрипта pg_chat_messages.sql
response = await server_client.post('/messages/retrieve', json={})
assert response.json() == {
'messages': [
{
'created': '2019-12-31T21:00:01+00:00',
'text': 'happy ny',
'username': 'bar',
},
{
'created': '2019-12-31T21:00:00+00:00',
'text': 'hello, world!',
'username': 'foo',
},
],
}
Запуск
Запускать примеры легче всего в докер-контейнере. Для этого нужно, чтобы на машине были установлены docker и docker-compose.
Все примеры запускаются из директории docs/examples
Запустить чат
# с хранилищем MongoDB
docs/examples$ make run-chat-mongo
# с хранилищем PostgreSQL
docs/examples$ make run-chat-postgres
После запуска в консоль будет выведен URL, по которому можно открыть чат в браузере:
chat-postgres_1 | ======== Running on http://0.0.0.0:8081 ========
chat-postgres_1 | (Press CTRL+C to quit)
Запустить тесты
# Выполнить тесты всех примеров
docs/examples$ make docker-runtests
# Выполнить тесты отдельного примера
docs/examples$ make docker-runtests-mockserver-example
docs/examples$ make docker-runtests-mongo-example
docs/examples$ make docker-runtests-postgres-example
Документация
Подробная документация testsuite доступна по ссылке.
Инструкция по настройке и запуску примеров.
Если есть вопросы github.com/yandex/yandex-taxi-testsuite/issues — оставьте комментарий.
Автор: Идальго Диас Николай