Хабрахабр

Яндекс открывает фреймворк Testsuite

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

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

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

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

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

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

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

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

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

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

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

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

Конечная цель — убедиться, что сервис правильно отвечает на 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.fixtureasync 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.sqlINSERT 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.pyasync 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

Запустить чат

# с хранилищем MongoDBdocs/examples$ make run-chat-mongo # с хранилищем PostgreSQLdocs/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-exampledocs/examples$ make docker-runtests-mongo-exampledocs/examples$ make docker-runtests-postgres-example

Документация

Подробная документация testsuite доступна по ссылке.

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

Если есть вопросы github.com/yandex/yandex-taxi-testsuite/issues — оставьте комментарий.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»