НОВОСТИ Яндекс открывает фреймворк Testsuite

BDFINFO2.0
Оффлайн
Регистрация
14.05.16
Сообщения
11.398
Реакции
501
Репутация
0
7ycjrlfwig5ljtuiaypfuayyibc.png


Сегодня мы открываем исходный код testsuite — фреймворка для тестирования HTTP-сервисов, который разработан и применяется в Яндекс.Такси. Исходники опубликованы на под лицензией MIT.

С помощью testsuite удобно тестировать HTTP-сервисы. Он предоставляет готовые механизмы, чтобы:

— Взаимодействовать с сервисом через вызовы его HTTP API.
— Перехватить и обработать HTTP-вызовы, которые сервис отправляет во внешние сервисы.
— Проверить, какие вызовы во внешние сервисы сделаны и в каком порядке.
— Взаимодействовать с базой данных сервиса, чтобы создать предусловие или проверить результат.



Область применения


Бэкенд Яндекс.Такси состоит из сотен микросервисов, постоянно появляются новые. Все высоконагруженные сервисы мы разрабатываем на С++ с использованием собственного фреймворка userver, о нём мы уже . Менее требовательные к нагрузке сервисы, а также прототипы делаем на Python.

Чтобы убедиться, что сервис хорошо решает свою задачу, предоставляя API другим сервисам и конечному приложению, мы хотим тестировать его как целое, преимущественно по принципу чёрного ящика.

Готовых инструментов для этого нет — вам пришлось бы писать код для настройки тестового окружения, который будет:

— поднимать и наливать базу данных;
— перехватывать и подменять HTTP-запросы;
— запускать в этом окружении тестируемый сервис.

Решать эту задачу, пользуясь фреймворками для unit-тестов, слишком трудно и неправильно, потому что их задача другая: модульное тестирование более мелких структурных единиц — компонентов, классов, функций.

В основе testsuite лежит , стандартный для Python тестовый фреймворк. При этом неважно, на каком языке написан микросервис, который мы тестируем. Сейчас testsuite работает на операционных системах GNU/Linux, macOS.

Хотя testsuite удобен для интеграционных сценариев, то есть взаимодействия нескольких сервисов (а если сервис написан на Python — то и для низкоуровневых), эти случаи мы рассматривать не будем. Далее речь пойдёт только о тестировании отдельно взятого сервиса.

Уровень детализацииИнструмент тестирования

Метод/функция, класс, компонент, библиотека

Стандартные unit-тесты, , , иногда всё-таки testsuite

Микросервис

testsuite

Ансамбль микросервисов (приложение)

Интеграционные тесты testsuite (в этой статье не рассматриваются)

Принцип действия


Конечная цель — убедиться, что сервис правильно отвечает на HTTP-вызовы, поэтому тестируем через HTTP-вызовы.

Запуск/остановка сервиса — это рутинная операция. Поэтому проверяем:

— что после запуска сервис отвечает по HTTP;
— как ведёт себя сервис, если внешние сервисы временно недоступны.

asfhyyekxad2idetfx6eg6_mf04.png


Testsuite:

— Запускает базу данных (PostgreSQL, MongoDB...).
— Перед каждым тестом наполняет базу тестовыми данными.
— Запускает тестируемый микросервис в отдельном процессе.
— Запускает собственный веб-сервер (mockserver), который имитирует (мокает) для сервиса внешнее окружение.
— Выполняет тесты.

Тесты могут проверять:

— Правильно ли сервис обрабатывает HTTP-запросы.
— Как работает сервис непосредственно в базе данных.
— Наличие/отсутствие/последовательность вызовов во внешние сервисы.
— Внутреннее состояние сервиса с помощью информации, который тот передаёт в Testpoint.

mockserver


Мы тестируем поведение отдельного микросервиса. Вызовы HTTP API внешних сервисов должны быть замоканы. За эту часть работы в testsuite отвечают его собственные плагины mockserver и mockserver_https. Mockserver — это HTTP-сервер с настраиваемыми на каждый тест обработчиками запросов и памятью о том, какие запросы обработаны и какие при этом переданы данные.

База данных


Testsuite позволяет тесту напрямую обращаться к базе данных для чтения и записи. С помощью данных можно формировать предусловие теста и проверять результат. Из коробки поддержаны PostgreSQL, MongoDB, Redis.

Как начать пользоваться


Чтобы писать тесты testsuite, разработчик должен знать Python и стандартный фреймворк .

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

ij5nsiwj-ztlizpalprnxhxhdzy.png


Фронтенд взаимодействует с сервисом .

Чтобы продемонстрировать взаимодействие сервисов, chat-backend делегирует хранение сообщений сервису хранилища. Хранилище реализовано двумя способами, и .

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()))


В остальном настройка инфраструктуры не отличается от описанного выше сервиса 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 ========
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 доступна .

по настройке и запуску примеров.

Если есть вопросы — оставьте комментарий.
 
Сверху Снизу