Когда мы работаем с API-схемами, обычно существует несколько моделей, и они синхронизируются на разных уровнях. Обычно есть база данных, код и схема. И всё это нужно держать между собой в синхроне, чтобы они нормально друг с другом взаимодействовали.

Я расскажу об обычных проблемах, с которыми люди сталкиваются при использовании API-схем. Как можно использовать API-схемы для описания property-based-тестов, и чем здесь может помочь Schemathesis. И покажу на практике, как его можно интегрировать в  существующий проект.

Проблемы использования API-схем

Ручная синхронизация

Пример простой таблички с букингами:

Здесь есть три поля, какой-то Python-класс и фрагмент схемы Open API, которая это как-то отображает. В принципе, здесь уже есть ошибка, и мы ее сейчас разберем.

Как обычно решается синхронизация? Очень часто это — ручная синхронизация в том или ином виде. Кроме этого, часто используются генераторы схемы из кода — FastAPI, apispec, Django. Когда модели определены, они синхронизируются в базу, и тогда из этого можно уже генерировать API схему.  Есть еще обратный подход, когда из схемы мы генерируем логику, валидацию и прочее (Connexion).

Но в том или ином виде все равно везде присутствует ручная синхронизация, потому что где-то у нас не хватает функционала, где-то что-то не поддерживается, где-то нужно что-то расширить. Это порождает и очень большую вероятность ошибки, и высокие затраты на поддержку.

Недостаточное тестирование

При этом часто код тестируется недостаточно. Обычно пишут больше example-based-тестов: такой-то вход, ожидаем такой-то выход. Еще используются различные инструменты вроде Dredd, когда в схеме указываются примеры и прогоняются на валидность, практически не прибегая к генерации данных.

Еще можно параметризовать существующие example-based-тесты, чтобы понизить стоимость поддержки. Но это всё еще довольно дорого, занимает много времени и не всегда эффективно. Если мы пишем тесты руками, то сфера покрытия ограничивается только нашим воображением. Что означает — не всегда можно заметить крайние случаи и указать достаточное количество примеров.

Property-based тестирование

С другой стороны, существует альтернативный подход — property-based тестирование.  Здесь мы вместо того, чтобы указать конкретный вход, говорим, что эта функция принимает данные такого-то типа и должны выполняться такие-то свойства.

Какие это свойства? В целом, нам нужно, чтобы само приложение отвечало быстро, а все примеры в схемах были актуальными.  А для API-схем нам важно, чтобы поведение приложения соответствовало описанной схеме. Это наша основная цель — чтобы всё работало, как описано. При этом любые входные данные, как корректные, так и некорректные, не должны вызывать внутренних ошибок. Особенно это важно, когда у вас есть on call и ошибка в Sentry вызывает звонок на ваш мобильник через Pagerduty. 

На мой взгляд, API-схемы — отличный источник таких свойств.

Schemathesis

Я разрабатываю Schemathesis, который позволяет при наличии только API-схемы, причем необязательно валидной на 100%, генерировать property-based-тесты и запускать их. Schemathesis поддерживает Open API 2 & 3, а также GraphQL. 

С его помощью можно сгенерировать все описанные в схеме запросы а так же проверить все примеры из схем. Даже если у вас не хватает примера для какого-то поля, он его сгенерирует. И, в отличие от большинства подобных инструментов, Schemathesis не только пригоден для юнит-тестирования, но и поддерживает интеграционное тестирование. 

Например, у вас есть заказы. Тогда мы создаем заказ, берем ID, смотрим, что API отдает корректный ответ, обновляем заказ, удаляем заказ. Можно создавать целые цепочки API запросов.

Он доступен как Python-библиотека, так и в виде command line утилиты. Сейчас в процессе разработки Web-сервис (альфа релиз будет через пару недель), чтобы все это было в один клик.

Schemathesis: демо

Видео показа демо можно посмотреть здесь, а сама демка — по этой ссылке.

Возьмем простое приложение, состоящее буквально из трех эндпойнтов. 

openapi.yaml
openapi: 3.0.0
info:
  title: Booking API
  description: A flights booking system.
  version: 1.0.0
servers:
  - url: http://127.0.0.1:5000/api
paths:
  /bookings/{booking_id}:
    parameters:
      - description: Booking ID to retrieve
        in: path
        name: booking_id
        required: true
        schema:
          format: int32
          type: integer
    get:
      summary: Get a booking by ID
      operationId: app.views.get_booking_by_id
      responses:
        "200":
          description: OK
  /bookings/:
    post:
      summary: Create a new booking
      operationId: app.views.create_booking
      responses:
        "201":
          description: OK
          links:
            GetBookingById:
              operationId: app.views.get_booking_by_id
              parameters:
                booking_id: '$response.body#/id'
      requestBody:
        $ref: '#/components/requestBodies/Booking'
    get:
      summary: Get bookings
      operationId: app.views.get_bookings
      parameters:
        - description: Number of bookings to return
          in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 2147483647
      responses:
        "200":
          description: OK
components:
  requestBodies:
    Booking:
      required: true
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Booking'
  schemas:
    Booking:
      properties:
        id:
          type: integer
        name:
          type: string
        is_active:
          type: boolean
      type: object

У меня в Docker оно запущено на порту 5000. При помощи небольшого количества полей мы можем создавать букинги, получать список букингов, и получать букинг по ID.  И у нас есть три поля, integer, string и boolean. Я покажу на примере нескольких простых тестов, как это можно всё интегрировать. 

Создаем маленький тестовый файл  и импортируем Schemathesis. В нем будут обычные Python-тесты, очень близкие к тому, что мы привыкли видеть в Pytest и Hypothesis.

import schemathesis
schema = schemathesis.from_uri(
    "http://127.0.0.1:5000/api/openapi.json"
)

Загружаем схему. Она живет на порту 5000 по пути /api/openapi.json. Схему можно грузить из разных мест: по ссылке или просто передать в словарик, из файла, с какого-то пути или WSGI приложение передать и адрес в нем. Можно из pytest фикстуры, если у нас уже есть существующий набор тестов.

...
@schema.parametrize()
def test_app(case):
    ...

Обыкновенный тест может выглядеть так. Мы на манер pytest.mark.parametrize указываем декоратор и пишем тест. Все аргументы, что нужно указать — это существующие pytest fixtures, которые вы хотите использовать. Обязательно нужно указать case в качестве аргумента. Грубо говоря, это test case для того, чтобы отправить его вашему приложению.

Тест целиком
@schema.parametrize(
    operation_id="app.views.create_booking"
)
def test_app(case):
    response = case.call()
    case.validate_response(response)

Мы не будем тестировать сразу все эндпойнты, а протестируем только создание букинга. Его можно выбрать по operationId. Обычно тест — это отправка запроса, получение ответа и какая-то валидацию. Schemathesis может провести 5 встроенных проверок:

  1. Что это правильный код ответа из указанных в схеме;

  2. Что content-type у него корректный;

  3. Что структура тела ответа соответствует схеме;

  4. Что все необходимые заголовки присутствуют в ответе;

  5. Что это не пятисотка, что у нас ничего не упало.

Этот делается довольно просто, и, если его запустить через pytest test, то сразу всё упадет. 

Видим, что вьюха упала с KeyError. Такие ситуации встречаются, потому что id может отсутствовать, согласно нашей схеме. Мы его не указали, как обязательный. Давайте это сделаем, и все остальные поля тоже, потому что они сделаны таким же образом.

Указываем обязательные поля
schemas:
    Booking:
      properties:
        id:
          type: integer
        name:
          type: string
        is_active:
          type: boolean
      type: object
      required: ["id", "name", "is_active"]

Теперь перезапустим приложение и посмотрим, что происходит.

ОК, другая ошибка. Теперь мы, оказывается, не учли, что букинг с таким id может существовать. Оставим за скобками, что его лучше генерировать на бэкенде, чтобы он здесь вообще не участвовал. Сделаем это для иллюстрации, такое может случаться любым полем.

Теперь сделаем простой хак — поймаем это исключение из asyncpg, и в этой ситуации вернем 409 — скажем, что такой букинг уже существует.

Простой хак
async def create_booking(
    request: web.Request, 
    body
) -> web.Response:
    try:
        booking = await db.create_booking(
            request.app["db"],
            booking_id=body["id"],
            name=body["name"],
            is_active=body["is_active"],
        )
        return web.json_response(booking.asdict(), status=201)
    except asyncpg.UniqueViolationError:
        return web.json_response(
            {"message": "Booking already exists"}, 
            status=409
        )

ОК. Надеюсь, что сейчас все заработает.

Но нет, я забыл указать 409 в схеме. Schemathesis очень любит напоминать о каких-то пропущенных кусочках. Добавляем код ответа в схему и снова запускаем. Уже больше тестов генерится, но теперь у нас что-то другое произошло. 

Здесь id вытащен напоказ и никак не ограничен. Наша база просто имеет свои ограничения, а мы этот тип никак не указали. Для примера возьмем числовое поле. Можно его ограничить. Конечно, в продакшен так лучше не делать, это для иллюстрации.

Ограничиваем числовое поле
schemas:
 Booking:
   properties:
     id:
       type: integer
       minimum: 1
       maximum: 2147483647

Хорошо, сейчас у нас все вроде должно быть отлично. 

По умолчанию Schemathesis генерит 100 тестов. Он повторяет поведение Hypothesis. Мы можем это поменять при помощи, например, settings-декоратора, который существует в Hypothesis. У нас это классический на самом деле тест, мы можем указать, что max_examples=1000. Тысяча — это верхний лимит, потому что если ваш API, например, принимает boolean и у него всего 2 значения, он сгенерирует 2 значения, тысячу там неоткуда брать.

from hypothesis import settings
...
@schema.parametrize(...)
@settings(max_examples=1000)
def test_app(case):
    ...

Альтернативный способ — можно у себя в conftest.py указать профиль, если вы, например, запускаете это на CI, у вас много тестов и вы не хотите везде дублировать конфигурацию. Например, регистрируем профиль под названием CI и там будет 1000 examples. 

from hypothesis import settings
settings.register_profile("CI", max_examples=1000)

Потом можно указать опцию --hypothesis-profile=CI при запуске pytest, чтобы запускать больше тестов. Чем больше тестов, тем больше вероятность того, что мы найдем какую-то ошибку.

В этот раз PostgreSQL не любит null-байты — хорошо, давайте это поправим, сделаем какую-то простую валидацию, опять же игрушечную. Если у нас null-байт в теле (в name), то вернем 400-ю, чтобы все было валидно.

async def create_booking(request: web.Request, body) -> web.Response:
    if "\x00" in body["name"]:
        return web.json_response({"message": "Invalid name"}, status=400)
    ...

Запускаем, но опять мелькают пятисотки. Hypothesis иногда работает довольно медленно из-за того, что ему нужно минимизировать ошибку. Он нашел какую-то проблему и пытается ее сжать до минимальной ее версии. В данной ситуации он нашел, что такая строка в name будет вызывать какое-то исключение.

Falsifying example: test_app(
    case=Case(body={'id': 1, 'is_active': False, 'name': '0000000000000000000000000000000'}),
)

В нашем случае это — лимит на 30 символов в базе, мы просто это добавим в схему. Теперь все должно быть хорошо.

Добавляем лимит
schemas:
 Booking:
   properties:
    #  ...
    name:
      type: string
      maxLength: 30

Часто кроме простых строк и простых типов мы используем типы специального назначения. Например, мы хотим хранить номера карточек, у которых есть своя семантика. Чтобы это отобразить в схеме, можно указать поле format.

Давайте придумаем какой-нибудь нелепый формат, например, fullname — что мы хотим всегда получать имя-пробел-фамилия. 

Новый формат
schemas:
 Booking:
   properties:
    #  ...
    name:
      type: string
      format: fullname
      maxLength: 30

Для проверки этого поля этого я написал маленький валидатор. Он буквально делит входные данные пополам по пробелу. Если не получается поделить на две части, это некорректное имя, а если получается, то имя и фамилия должны быть не пустыми.

Валидируем
def is_valid_name(name: str) -> bool:
    try:
        first, last = name.split(" ")
        return bool(first and last)
    except ValueError:
        return False
...
async def create_booking(
    request: web.Request, 
    body
) -> web.Response:
    if "\x00" in body["name"] or not is_valid_name(body["name"]):
        return web.json_response(
            {"message": "Invalid name"}, 
            status=400
        )
    ...

Если мы попробуем это сгенерировать данные такого формата, то у нас это не получится. По умолчанию, Schemathesis будет генерировать просто строки произвольного формата длиной 30 (это максимум). Но он ничего не знает о том, что мы ожидаем имя из двух частей, разделенных пробелом. Чтобы это сделать, мы должны сказать ему об этом. 

Чтобы генерировать строку из двух частей, мы можем это сделать при помощи Hypothesis, и потом скормить это Schemathesis. Чтобы последний знал, что ему надо генерировать, когда он встречает формат fullname.

Пишем стратегию Hypothesis
# conftest.py
from hypothesis import strategies as st
@st.composite
def fullname(draw):
    first = draw(st.sampled_from(["john", "jane"]))
    last = draw(st.just("doe"))
    return f"{first} {last}"

В данной ситуации нам понадобится composite — это тип довольно гибкой Hypothesis-стратегии, которая позволяет просто писать функцию и в ней создавать какое-то значение. Для простоты сделаем следующее: возьмем предопределенные имена, например, John и Jane, и также фамилию, но уже один вариант. В качестве результата вернем имя и фамилию, разделенные пробелом.

# conftest.py
import schemathesis
...
schemathesis.register_string_format("fullname", fullname())

Теперь можно через schemathesis.register_string_format зарегистрировать формат fullname. Он должен в точности повторять то, что у нас написано в названии формата в схеме, и передать в него эту вызванную функцию fullname, чтобы она стала стратегией Hypothesis. В выхлопе приложения у нас 409-я, мы прошли валидацию.

Кроме форматов для строк мы можем менять любые части, которые отправляются на сервер — заголовки, query, body.

Попробуем поменять какие-нибудь части запроса. Schemathesis имеет систему хуков, похожую на Pytest. Также нужно использовать декоратор и функцию с определенным именем. Если в данной ситуации мы хотим поменять id в сгенерированных данных, нам нужен хук before_generate_body, который принимает контекст и существующую стратегию, а вернуть должен только модифицированную стратегию. Здесь можно сделать практически что угодно, например, фильтровать, добавлять какие-то варианты. Например, попробуем стратегию, в которой все id будут больше 10 000.

# conftest.py
@schemathesis.hooks.register
def before_generate_body(context, strategy):
    return strategy.filter(lambda x: x.get("id", 10001) > 10000)

Добавляем лямбду, которая принимает сгенерированное значение. У нас есть словарь, в нем поле id. Устанавливаем условие, что id больше 10 000, и проверим, что тест это получает в нужном виде. И да, он не падает, все замечательно.

@schema.parametrize(...)
@settings(max_examples=1000)
def test_app(case):
    assert case.body["id"] > 10000
    ...

Кроме этого можно модифицировать наш case, как нам угодно. Case — это просто хранилище данных. Там есть body, query и все прочее. Обычно, когда мы работаем с каким-то API, там есть авторизация. Нам нужен какой-то заголовок. Попробуем сделать фикстуру, которая возвращает токен.

@pytest.fixture
def token():
    return "spam"

Например, можно внутри этой фикстуры залогиниться где-то, сходить в базу, и вообще что угодно сделать. 

Это обычный тест. Мы просто добавляем заголовки. Они пустые в нашем case, потому что мы ничего там не генерируем, в схеме ничего такого нет. Можно просто указать авторизацию, что это Bearer с таким-то токеном.

@schema.parametrize(...)
def test_app(case, token):
    case.headers = {"Authorization": f"Bearer {token}"}
    ...

Теперь данные будут отправляться с этим заголовком. В принципе, их можно модифицировать как угодно, добавлять какие-то данные из базы — всё, что вашему приложению нужно.

Теперь переключимся на Command Line. Кроме обычных тестов на Python, можно использовать Command Line entrypoint, который вообще не требует никакого кода. Мы просто запускаем Schemathesis и указываем адрес нашей схемы:

schemathesis run http://127.0.0.1:5000/api/openapi.json

Он проходит по всем эндпойнтам. Выберем, например эндпойнт get_bookings. Schemathesis умеет искать по подстроке, поэтому нам хватит только get_bookings.

Хорошо, тесты бегут. Но что-то они бегут медленно, мне это не нравится. Сейчас это локальная машина, у меня в базе не так много данных. А в реальной ситуации они будут забираться намного медленнее. Я добавил небольшую задержку — тысячную секунды на каждую строчку из базы. Если у вас данные небольшого размера, то такой трюк может вам помочь увидеть замедление. Сейчас это заняло 18 с.

Попробуем написать свою маленькую проверку через декоратор, что наше приложение функционирует достаточно быстро вне зависимости от того, какие данные мы в него отослали. Применим декоратор register_check к новой функции not_so_slow принимающей ответ приложения и тот же case, который у нас был в тесте.

@schemathesis.register_check
def not_so_slow(response, case):
    assert response.elapsed < timedelta(milliseconds=100), "Response is slow!"

Теперь давайте решим, что response.elapsed будет меньше какой-то time delta, например, 100 мс в этой ситуации. И если что, мы получим сообщение «Response is slow!».

Теперь, чтобы это всё подхватилось нашим Command Line инструментом, надо добавить опцию --pre-run=test.hooks перед run. Значением должен быть импортируемый путь к модулю, в котором вы определяете всю кастомизацию, которая вам нужна — где живут ваши string-форматы, кастомные стратегии, фильтрации, чеки. Всё это здесь.

schemathesis --pre-run=test.hooks run http://127.0.0.1:5000/api/openapi.json

Кроме этого Schemathesis умеет направлять генерацию данных в какую-то сторону в зависимости от ваших предпочтений. Например, по умолчанию в Schemathesis встроен один вариант такого поведения — он может генерировать данные, которые с какой-то более высокой вероятностью будут вызывать более долгие ответы. Вы можете определить свой вариант, но по умолчанию у нас есть только время ответа. Для того чтобы использовался not_so_slow его нужно указать явно.

schemathesis --pre-run=test.hooks run http://127.0.0.1:5000/api/openapi.json --target=response_time -c not_so_slow

Запускаем и видим, что тесты идут все еще медленно. Schemathesis пытается минимизировать значения лимита. Он нащупал, что в районе 900 уже начинает замедляться, и 280 мс — это долго. Эта штука неустойчивая — где-то отвечает медленнее, где-то быстрее, но примерно граница находится в этом районе.

Что можно сделать? Можно сказать, что просить будем не больше 200 букингов из API, это и так сильно много. Давайте попробуем. И теперь тесты бегут быстро. 

Таким образом можно искать части API, которые подвержены denial-of-service атакам. Например, у вас могут генериться данные, которые будут грузить приложение или выполняться какие-то слишком тяжёлые операции. Найти довольно легко, просто зарегистрировав такой чек, а данные он сгенерит сам. Можно максимизировать не время, а, например, размер ответа.

Покажу, как можно это сделать. В функции можно посчитать длину context.response.content, и вернуть её как float. Так мы максимизируем размер ответа. Эффект лучше виден на большом количестве тестов, потому что Hypothesis нужно больше данных, чтобы направить генерацию. Тем не менее это работает, общее поведение повторяемо.

# conftest.py
@schemathesis.register_target
def big_response(context):
    return float(len(context.response.content))

и

schemathesis --pre-run=test.hooks run http://127.0.0.1:5000/api/openapi.json --target=big_response -c not_so_slow

Кроме этого можно в наших CLI тестах указать количество воркеров на которые будут распараллелены тесты. Тогда всё может пойти быстрее, хотя и зависит от ситуации. Также можно указать WSGI приложение, ASGI приложение. Все работает из коробки.

schemathesis --pre-run=test.hooks run http://127.0.0.1:5000/api/openapi.json -w 4

Переключимся на интеграционное тестирование. По умолчанию большинство инструментов сосредотачиваются на тестирование одного эндпойнта в изоляции. В Schemathesis можно генерировать последовательности запросов и тестировать сразу поведение целиком в рамках системы. В данный момент он поддерживает только один способ — через Open API Links. 

Что такое Open API Links? Он позволяет связать выхлоп одного эндпойнта с другим, и указать — как именно. Посмотрим на примере ответ 201 при создании букинга. Берем произвольное имя, например, GetBookingById, и указываем operationId, то есть какие операции мы связываем. Нам надо указать все параметры, которые указаны в эндпойнте в ключе parameters. У нас он всего один — это booking_id, его и указываем. Здесь Open API использует так называемый runtime expression, где можно при помощи специального синтаксиса достать данные из ответа или из запроса, который вы отсылали.

API Links
/bookings/:
 post:
   summary: Create a new booking
   operationId: app.views.create_booking
   responses:
     "201":
       description: OK
       links:
         GetBookingById:
           operationId: app.views.get_booking_by_id
           parameters:
             booking_id: '$response.body#/id'

Возьмем тело ответа. Здесь у нас JSON-пойнтер, и нам надо просто взять поле id. Перезагрузим приложение, чтобы линки были видны в схеме. Запустим вместе с параметром --stateful=links. Хотя в данный момент это единственный вариант, но в процессе разработки автоматическое распознавание линков такого рода. Ребята из Red Hat это реализовали. Они взяли Schemathesis, расширили его и прикрутили  inference для этих связей. И теперь он автоматически определяет с какой-то вероятностью линки между разными эндпойнтами и генерит их в нужные запросы без указания Open API links из схемы. Это скоро будет в Schemathesis, но пока только линки.

Чтобы не было выхлопа от других, надо указать только один operationId (create_booking). Смотрим, что получилось.

Schemathesis пытается создать букинги, и для всех 201 ответов делает дополнительные запросы на GET /bookings/{booking_id}  с таким id. Понятное дело, они не все 201, есть 409, но, тем не менее видим, что их достаточно.

Если у вас есть какие-то линки отсюда куда-то еще, то он пройдет по ним. И будет ходить рекурсивно, пока не закончит работу по лимиту. Лимит по умолчанию — 5 уровней. То есть можно тестировать самые разнообразные пути. Сначала создали букинг, потом его обновили, удалили и так далее в разных последовательностях. Это довольно удобно.

Но это Schemathesis CLI, который в целом имеет больше фич на данный момент, и не все вещи сделаны еще для обычных Python тестов. В основном это связано с Pytest — некоторые вещи там требуют большей работы, чем написать для CLI с нуля.

Попробуем написать какую-то альтернативу. У нас все-таки есть обычные стратегии Hypothesis, и можно воспользоваться stateful-тестированием, которое в него встроено, или написать руками. Все тесты в Hypothesis используют декоратор given. Он получает стратегии, а потом данные полученные из этих стратегий используются как аргументы тестовой функции.

Сейчас у нас всего два шага, сначала создадим букинг. Схема позволяет взять эндпойнт POST /bookings/ и преобразовать его в стратегию. То же самое сделаем для второго шага, назовем его get.

@given(
    create=schema["/bookings/"]["POST"].as_strategy(),
    get=schema["/bookings/{booking_id}"]["GET"].as_strategy(),
)
def test_stateful(create, get):
    ...

Нам осталось только эмулировать логику, которую мы описали в линках. Берем create, get и делаем запросы, валидируя при этом ответы. Первый ответ — это создание букинга. Если наш response — 201, то мы должны достать ID из тела и добавить его в path_parameters для GET. 

@given(
    create=schema["/bookings/"]["POST"].as_strategy(),
    get=schema["/bookings/{booking_id}"]["GET"].as_strategy(),
)
def test_stateful(create, get):
    response = create.call_and_validate()
    if response.status_code == 201:
        get.path_parameters["booking_id"] = response.json()["id"]
        get.call_and_validate()

Давайте его запустим. Пока первый пробежит, посмотрим, что во втором. Он прошел и конечно, здесь много повторных 409. Мы сделали запрос на создание букинга, потом пришел 201, мы взяли ID и отправили его сюда, получили 200 ответ. Всё отлично.

Таким образом можно запускать такого рода тесты, дополнительно используя инструменты из Hypothesis, которые позволяют это расширить. Всё довольно гибко.

В более новых версиях Schemathesis доступны тесты на основе конечных автоматов из Hypothesis, которые обладают дополнительными возможностями, о которых можно прочитать в документации вот тут.

Schemathesis: что дальше?

В Schemathesis мы уже реализовали негативные тесты и добавили улучшенную поддержку GraphOL.

Расскажу об аспектах, когда Schemathesis работает не очень хорошо, и его ограничениях, которые сейчас в работе.

Schemathesis as a Service. Мы работаем над тем, чтобы всё было в один клик. Просто вводим адрес схемы и всё замечательно тестируется, не надо писать никакого кода.

Решить проблему с производительностью при генерации рекурсивных данных. Некоторые схемы при достаточной сложности и достаточном размере могут вызвать реальные проблемы с производительностью при генерации тестов. Над этим сейчас мы с Заком (core-разработчик из Hypothesis) работаем. Тогда можно будет полноценно генерировать рекурсивные данные любой сложности.

Поддержка API Blueprint. Многие используют этот стандарт, и мы его тоже  планируем поддерживать.

Coverage-guided генерация данных. Достаточно часто бывают проблемы с качеством данных. Даже если мы передаем корректные ID, не всегда получается пройти достаточно глубоко в код приложения. Для этого можно прикрутить coverage-guided генерацию. У меня было несколько экспериментов, работает довольно успешно. Пока это еще далеко не production-ready, но будет.

Заключение, или зачем я создал Schemathesis

Во-первых, я хотел сделать ошибки более явными. В демо вы видели нулевые байты, ограничения на длину, некорректные данные для базы. Конечно, это может решаться сгенерированной валидацией, схемой, чем-то еще. Но все эти подходы могут сбоить. Я видел много ситуаций, когда ошибка, из-за которой я просыпался ночью от PagerDuty и которую мы в итоге пофиксили, была найдена Schemathesis’ом за 3 секунды. Хотел бы я видеть это раньше.

Во-вторых,  я хочу снизить затраты на тестирование, чтобы не было большого количества example-based тестов, чтобы они не занимали много времени у разработчиков и тестировщиков. Чтобы всё было как можно проще. Источник для нужных нам свойств уже есть — это API схемы.

В третьих, я хочу дополнить существующие тесты. Я не позиционирую Schemathesis как то, что заменит example-based тесты. Нет, он их дополняет. Как вы видели, итерироваться получается неплохо. Также он умеет как Dredd проверять примеры, которые есть в схеме, но он не будет ругаться, если что-то отсутствует.

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

Дайте знать, что вы думаете об инструменте в комментах или на канале в gitter и записывайтесь на альфа тестирование Schemathesis as a Service. Спасибо!

Moscow Python Conf++ 2021 состоится 27-28 сентября в Москве. Доклады можно посмотреть здесь. Билеты можно купить здесь.

Приходите, будет интересно!

Комментарии (16)


  1. vagon333
    08.09.2021 06:34
    +2

    Замечательная идея.
    Благодарю, что нашли время оформить и поделиться.

    Schemathesis as a Service. Мы работаем над тем, чтобы всё было в один клик. Просто вводим адрес схемы и всё замечательно тестируется, не надо писать никакого кода.

    Первая реакция по ходу чтения - сделать тестер/валидатор доступным как сервис.
    Подписался.
    Сервис планируется сделать коммерческим или будут другие опции?
    Например, я бы заплатил и использовал в своих API.


    1. Stranger6667 Автор
      08.09.2021 13:14

      Спасибо! Рад, что вам понравилась идея! :)

      Сервис планируется сделать коммерческим или будут другие опции?Например, я бы заплатил и использовал в своих API.

      Бесплатная опция обязательно будет :) В том числе и для Open Source проектов.


  1. ivanych
    09.09.2021 23:27

    Скажите прямо - Schemathesis лучше, чем Dredd? :) Хочу прикрутить что-то для тестов API:)


    1. Stranger6667 Автор
      09.09.2021 23:56

      Скажите прямо - Schemathesis лучше, чем Dredd? :)

      На мой предвзятый взгляд - да. С точки зрения поиска дефектов Schemathesis дает намного больше возможностей чем Dredd. В основном за счет использования Property-Based тестирования и генерации данных даже если примеров в схеме нет. Так же Schemathesis полноценно поддерживает Open API 3 (в Dredd поддержка эксперементальная). Конечно, Dredd умеет какие-то вещи которые Schemathesis не умеет. Например поддержка API Blueprint и хуков на разных языках. В FAQ можно почитать немного более развернутое сравнение.

      Если говорить о сравнении эффективности на реальных проектах, то можно посмотреть эксперименты одной из статей для ICSE 2022. Сама статья должна быть опубликована в декабре. Эксперименты включают тестирование различных приложений с Open API схемами через Dredd, Schemathesis и еще несколько проектов. Они измеряют покрытие кода в приложениях, ошибки в коде и т.д. Например в одном из экспериментов Schemathesis покрыл 65% кода (в строчках) бэкенда, а Dredd 38%. В некоторых примерах разница меньше, но в целом, в этих экспериментах Schemathesis показал больший или примерно такой же процент покрытия чем Dredd, даже учитывая тот факт, что использовалась довольно старая версия Schemathesis в которой еще не было негативного тестирования.


      1. ivanych
        10.09.2021 00:05
        +1

        Спасибо. Недоделанная поддержка v3 в Дреде напрягает, а Blueprint мне как-раз не требуется. Буду прикручивать Схематезис.


  1. ivanych
    20.09.2021 23:20
    +1

    Пробую. Сразу же при запуске ругается на синтаксис спеки:

    Failed validating 'oneOf' in schema['properties']['components']['properties']['schemas']['patternProperties']['^[a-zA-Z0-9\\.\\-_]+$']:
        {'oneOf': [{'$ref': '#/definitions/Schema'},
                   {'$ref': '#/definitions/Reference'}]}

    ну и рядом копирует содержимое схемы, на которую ругается.

    Проблема в том, что из этого сообщения абсолютно невозможно понять, чем он недоволен.

    А вот как на эту же спеку ругается validator.swagger.io:

    messages:
    - "attribute components.schemas.Clients.titile is unexpected"

    И тут сразу понятно, что я опечатался и вместо "title" написал "titile".

    Вопрос: это недоработка в Схематезисе и когда-нибудь будет сделано лучше? Или это фича и надо привыкнуть?


    1. ivanych
      20.09.2021 23:34

      P.S. Хотя по чести сказать, validator.swagger.io тоже не очень внятно пишет. Лучше всех пишет editor.swagger.io:

      Structural error at components.schemas.Clients.properties.payment_state
      should NOT have additional properties
      additionalProperty: titile

      Вот тут вообще великолепно — называет схему (Clients), поле в этой схеме (payment_state) и конкретное поломанное свойство поля (titile).


      1. Stranger6667 Автор
        21.09.2021 00:07

        Спасибо за тексты ошибок. Скорее всего там под капотом тоже используется валидация JSON Schema, только отдается конкретная ошибка из leaf узла где есть additionalProperties: false. В то время когда в jsonschema отдается ошибка из узла самого близкого к корню (т.е. oneOf). Скорее всего это можно будет решить легче чем я думал изначально, просто выбрав другую ошибку из дерева которое возвращает jsonschema :)


  1. Stranger6667 Автор
    20.09.2021 23:51

    В данный момент Schemathesis использует jsonschema для валидации схем и выводит ошибку оттуда напрямую. Я однозначно хочу это улучшить - для этого есть отдельный issue (довольно долго уже висит). Вполне вероятно что будет проще написать валидацию напрямую чем использовать ошибки из jsonschema (которые особенно неприятно разбирать из-за того древовидной структуры ошибок которую эта библиотека отдаёт).

    Я постараюсь вернуться к этой проблеме в ближайшее время.

    Отдельно хочу сказать что эту валидацию можно отключить (--validate-schema=false), если нужно запустить тесты без учета валидности схемы.

    EDIT: Ответ на комментарий выше


  1. ivanych
    22.09.2021 20:22
    +1

    Пробую. Это божественно:) нашёл уже кучу косяков, которые глазами не видел:)

    Спасибо:)


    1. Stranger6667 Автор
      22.09.2021 20:32

      Рад слышать! :) Вам спасибо за новый issue и комменты выше - буду благодарен за предложения и любую обратную связь :)


  1. conopus
    18.10.2021 16:26

    @Stranger6667 Пробую использовать ваш инструмент и время от времени получаю ошибку Hypothesis при чтении создаваемой в тесте сущности:

    Falsified on the first call but did not on a subsequent one

    Насколько я понял, это присходит, когда Hypothesis делает проверку при двух последовательно идущих одинковых запросах. Он ожидает, что ответ будет одинаковым (проверка идемпотентности), но в теле ответа меняется идентификатор сущности и он считает это ошибкой. Я понимаю, что вопрос не совсем по адресу, но может быть вы подскажете как сделать эту проверку корректно? Или как отключить проверку идемпотентности для этого конкретного теста.


    1. Stranger6667 Автор
      18.10.2021 17:09

      В целом такое происходит из-за того что когда Hypothesis сталкивается с падением теста, он пытается удостовериться что ошибка надежно воспроизводится. Если у него не получается, то он выводит это сообщение об ошибке.

      Насколько я понял, это присходит, когда Hypothesis делает проверку при двух последовательно идущих одинковых запросах. Он ожидает, что ответ будет одинаковым (проверка идемпотентности), но в теле ответа меняется идентификатор сущности и он считает это ошибкой

      С учетом встроенных проверок, различающиеся идентификаторы не должны вызывать проблему если они оба соответствуют схеме. Но я думаю, что в целом у вас верное понимание ситуации (особенно об идемпотентности)!

      Чаще всего такое поведение связано с тем что внутреннее состояние тестируемого сервиса отличается в начале каждого "теста" (это может быть и последовательность запросов, если рассматривать stateful тестирование). Например, если имена сущностей в сервисе должны быть уникальные, то:

      • Запрос на создание сущности с именем Test, пришел 201й ответ несоответствующий схеме, но в сервисе сущность успешно создалась. Hypothesis зарегистрировал ошибку.

      • Hypothesis пытается воспроизвести эту ошибку отправляя такой же запрос как в предыдущем пункте. Получает 409 который уже соответствует схеме - предыдущая ошибка не воспроизводится

       но может быть вы подскажете как сделать эту проверку корректно?

      БОльшая часть такого рода проблем решается очисткой состояния приложения перед каждым тестом. В CLI это можно сделать при помощи хука before_call, в Python тестах через использования контекстного менеджера в теле теста (как рекомендуется в документации Hypothesis).

      В зависимости от сложности тестируемого сервиса, добавление логики для очистки состояния может сильно замедлить тесты, но результаты будет легче воспроизвести.

      Или как отключить проверку идемпотентности для этого конкретного теста

      В данный момент, это логику напрямую не отключить, но я планирую сделать такую возможность (возможно даже включить это по-умолчанию)

      Надеюсь, что мой комментарий поможет вам :)


      1. conopus
        18.10.2021 20:46
        +1

        Да, дело было в несвоевременной очистке данных. Дмитрий, благодарю и за комментарий и, главное, за сам инструмент. Мне кажется у него прекрасные перспективы.
        Я инженер QA, но надеюсь, что не только в своих тестах буду использовать его, но и наших разработчиков сумею приохотить.


  1. conopus
    07.12.2021 15:40

    @Stranger6667 В документации Schemathesis есть раздел Examples in API schemas в котором написано, что тэг example поддерживается. Т. е. позитивные тесты должны формироваться на основе примеров из схемы. Но у меня они почему-то не используются, хотя в схеме заданы. В результате из схемы автоматически генерируются только негативные тесты, а позитивыне кейсы мне приходится указывать явно в тестах. Пробовал задавать примеры в конкрентом свойстве модели в схеме, но результат тот же. Тесты пишу в pytest. Для "включения" примеров из схемы в тест-кейсы нужно как-то указать это явно? Не могли бы вы показать это на примере?


    1. conopus
      07.12.2021 19:25

      Возможно это важно: схема использует OpenAPI v. 2.0