Сегодня мы открываем исходный код 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 — оставьте комментарий.