Как быстро и безболезненно закрыть регресс в API из связки PyTest + JSON Schema

Протестировать позитивные сценарии использования API можно легко и быстро, используя JSON схему. В этой статье мы поговорим о полезных приемах, хитростях, которые можно применить для ускорения процесса, и об источниках данных для такого тестирования. Излагать буду “для самых маленьких” — если хотите быстро подтянуть JSON схемы в свой проект и готовых онлайн-генераторов вам мало, но вы не планируете погрязнуть в чтении документации, добро пожаловать.

Примеры из этой статьи я выложил в Gitlab (готовились они под конференцию PiterPy, поэтому и название проекта соответствующее): https://gitlab.com/bp3_niva/piterpy-2023-sample

Всем привет! В узких кругах я широко известен как Дядя Вова. В этом году исполнилось 17 лет, как я работаю в тестировании, и 7 из них я в Максилекте — занимаюсь автоматизацией тестирования. Текущая моя роль — лид, но в душе я остался экспертом по бэкенд тестированию.

Зачем все это нужно?

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

Начну с небольшого отступления — как раз “для самых маленьких”. Что такое smoke-тестирование?

Согласно легенде, smoke-тестирование пошло из разработки электроники. Собранный электроприбор быстро включают и выключают, чтобы проверить, пошел ли из него дым. Если дым не пошел — smoke-тест пройден.

Просто вся электроника работает на магическом дыму. Если дым вышел — она больше не может работать.

В разработке smoke-тесты позволяют понять, что основные фичи решения хоть в каком-то объеме работают. Это частный (очень лайтовый) случай регресса. Smoke-тест не говорит о том, что конкретно сломалось, а демонстрирует существование проблемы. При этом если тест проходит (зеленый), можно говорить об определенном уровне качества продукта — значит при внесении изменений мы ничего не сломали.

Я занимаюсь бэкенд тестированием, т.е. API — это мое все. JSON-схему я применяю непосредственно при проверке соответствия каждого ответа API ожидаемому результату. Но спектр возможных применения JSON-схемы этим не ограничивается. Фактически, любой JSON можно валидировать таким образом, откуда бы он не пришел — это могут быть сообщения из Kafka / Rabbit или даже поля базы. Зачастую написать схему намного быстрее, чем тянуть откуда-то данные для сравнения.

К сожалению, онлайн-генераторы схем, которыми пестрит интернет, делают спорные вещи. Поэтому я хочу рассказать, как быстро подготовить JSON-схему своими руками, не используя сторонние инструменты. Очень люблю Python и PyTest — так что свой рассказ построю на них.

Рассказывать буду на максимально упрощенных примерах. Их исходники вы можете найти на GitLab: https://gitlab.com/bp3_niva/piterpy-2023-sample

Переключаясь между ветками, вы найдете все упомянутые разделы.

О примере в деталях

В реальной жизни в ходе тестирования мы взаимодействуем с неким API, который и отвечает с помощью JSON. Но для описания в рамках этой статьи нам не нужно все, что у сервера под капотом. Нужен только JSON, который он присылает. Поэтому мы и будем с ним работать — как со строкой или с файлом.

Предположим, в JSON содержится информация о неком пользователе — с идентификатором, ФИО, полом, датой рождения, телефоном и т.п. И пусть Result — это поле, которое говорит о том, что карточка пользователя была корректно обработана.

answer = {
  "id": 1,
  "name": "Иван",
  "patronymic": "Петрович",
  "surname": "Белкин",
  "sex": "M",
  "birth_date": "1999-06-06",
  "phone": "+79991234567",
  "passport": None,
  "result": "ok",
  "height": 1.76,
  "citizenship": True
}

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

Возьмем драфт примитивной схемы:

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#"
}

И возьмем тест:

from jsonschema import validate
from src.schema import schema_str
from src.test_json import answer

def test_this():
	validate(answer, schema_str)

Надо отметить, что validate в данном случае работает не со строками JSON и схемы. Он скастован именно к dictionary из Python. Поэтому в JSON выше мы видим None и True (с большой буквы), хотя фактически в ответе API будет null и true (с маленькой буквы).

В таком виде тест работает без ошибок, потому что схема пока пуста.

Пример 1. Проверка типов

В ответе API в JSON могут быть различные примитивные типы — boolean, integer, number (немного непривычно, что так здесь назван float, но учитывайте, что между Python и JSON все-таки есть разница).

В нашей схеме мы можем сказать, что ID — целое число (integer), ФИО, пол, дата рождения, телефон, а также result — строка (string), паспорт — null (в терминах JSON), рост — не целочисленный тип (number), а гражданство — boolean.

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer"},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string"},
        "birth_date": {"type": "string"},
        "phone": {"type": "string"},
        "passport": {"type": "null"},
        "result": {"type": "string"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

Здесь мы видим, что вся схема имеет “type”: “object”. К этому вернемся чуть позже.

Тест на такой схеме работает без ошибок. Но если, например взять в нашем ответе API в кавычки значение роста (чтобы оно также стало строкой), тест упадет с ошибкой — “1.76” не является типом number. И так можно провалидировать любое поле.

Как правило, JSON схема реагирует на первую проблему и не сообщает об остальных (если в JSON таких проблем несколько). О следующих проблемах приходится узнавать через последовательную валидацию. На практике в большинстве случаев, исправляя первую проблему, вы сами обнаружите и остальные. А если нет — итерации в любом случае их подсветят.

Далее поговорим про более расширенную валидацию.

Пример 2. Проверка значений

Нам важно, чтобы Result всегда был Ok. Это можно прописать в схеме:

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer"},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string"},
        "birth_date": {"type": "string"},
        "phone": {"type": "string"},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

Если при этом от API придет ответ “OOK”, тест упадет с ошибкой.

Аналогично можно задать варианты ответа через перечисление enum. Это легко продемонстрировать на примере пола — “М” или ”Ж”:

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer"},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string", "enum": ["М", "Ж"]},
        "birth_date": {"type": "string"},
        "phone": {"type": "string"},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

До введения этой валидации пол мог быть любым, а сейчас тест пройдет без ошибок, только если пол М или Ж.

Пример 3. Минимум и максимум

Зададим границы для ID — пусть он не может быть равен нулю (т.е. зададим ему минимальное значение, равное 1).

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer", "minimum": 1},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string", "enum": ["М", "Ж"]},
        "birth_date": {"type": "string"},
        "phone": {"type": "string"},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

Если в ответе API придет ID=0, тест упадет с ошибкой.

С максимальными значениями можно работать аналогично.

В схеме можно также задавать exclusiveMinimum и exclusiveMaximum. Это вариации minimum и maximum, соответственно, которые не включают указанные значения, т.е. работают как больше / меньше (а не “больше или равно” / “меньше или равно”).

Пример 4. Ограничение длины

Еще одна удобная штука — ограничение длины. Мы можем указать, что телефонный номер должен иметь 12 символов (считая “+”).

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer", "minimum": 1},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string", "enum": ["М", "Ж"]},
        "birth_date": {"type": "string"},
        "phone": {"type": "string", "minLength": 12, "maxLength": 12},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

А еще можно задать паттерн с помощью регулярного выражения. Допустим, для даты можно указать следующие условия:

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"properties": {
        "id": {"type": "integer", "minimum": 1},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string", "enum": ["М", "Ж"]},
        "birth_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"},
        "phone": {"type": "string", "minLength": 12, "maxLength": 12},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
    }
}

Если при этом в ответе от API в результате какой-то ошибки придет 16 месяц, тест упадет.

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

Пример 5. Список полей

На самом деле JSON — это объект с определенными полями, и схема позволяет проверить наличие или отсутствие этих полей.

Предположим, в ответе мы потеряли поле с датой. В текущем виде объект останется валидным. Чтобы задать обязательные поля, используется настройка required. В ней мы перечисляем, какие именно поля должны присутствовать у валидного объекта:

schema_str = {
	"$schema": "http://json-schema.org/draft-07/schema#",
	"type": "object",
	"required": {
        "id",
        "name",
        "patronymic",
        "surname",
        "sex",
        "birth_date",
        "phone",
        "passport",
        "result",
        "height",
        "citizenship"
    }
	"properties": {
        "id": {"type": "integer", "minimum": 1},
        "name": {"type": "string"},
        "patronymic": {"type": "string"},
        "surname": {"type": "string"},
        "sex": {"type": "string", "enum": ["М", "Ж"]},
        "birth_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"},
        "phone": {"type": "string", "minLength": 12, "maxLength": 12},
        "passport": {"type": "null"},
        "result": {"type": "string", "const": "ok"},
        "height": {"type": "number"},
        "citizenship": {"type": "boolean"}
        }
    "additionalProperties": false
}

Еще одна интересная настройка — additional properties, которая отвечает за то, могут ли у объекта появляться новые поля. По умолчанию она true — это означает, что дополнительные поля допустимы. Если значение этого свойства исправить на false, JSON-схема дополнительные поля не пропустит.

Любопытно, что на текущем проекте по просьбе разработчиков additional properties установлено в true — здесь я при изменении списка свойств актуализирую тесты в рабочем порядке после того, как мне поставят задачу (true обеспечивает прохождение их билдов, пока задача в работе). Но я сталкивался и с противоположными историями. На одном из проектов это свойство было установлено в false, поскольку разработчикам так больше нравилось. Они хотели, чтобы при изменении свойств тесты становились красными. Так что отчасти это разговор о вкусовщине. Но чтобы не путаться, я рекомендую указывать additional properties явно, даже если оно соответствует дефолтному значению.

Пример 6. Массивы

Предположим, у нас есть функция, которая возвращает список сущностей. Тестовый объект будет элементом списка. JSON-схема позволяет работать с минимальным и максимальным количеством item-ов в списке (minItems и maxItems).

Вы вряд ли ожидаете, что список вернет 0 объектов, поэтому minItems можно установить на 1. А maxItems позволяет валидировать дефолтные значения количества объектов на странице, если например тестируется API с пагинацией. Обычно списковые API по дефолту отдают 20 item-ов, которые на фронтенде попадают на одну страницу. Когда вы нажимаете кнопку следующей страницы, API выдает еще 20 элементов. Таким образом, maxItems можно установить на 20 — и в этом случае вы будете уверены, что пагинация работает корректно (что разработчик, пришедший с другого проекта, не поправил это значение на более привычное для него 10 или, скажем, 50).

Вернемся к примеру. Здесь теперь задан тип array, внутри которого — item-ы, о которых мы говорили ранее. Minitems я задал 1, потому что ожидается, что массив не должен быть пустым. А чтобы пояснить, как работают другие свойства, добавил поле driver_license и задал категорию B:

"$schema": "http://json-schema.org/draft-07/schema#",
    "type": "array",
    "minItems": 1,
    "items": {
        "type": "object",
        "required": [
            "id",
            "name",
            "patronymic",
            "surname",
            "sex",
            "birth_date",
            "phone",
            "passport",
            "result",
            "height",
            "citizenship"
        ],
        "properties": {
            "id": {"type": "integer", "minimum": 1},
            "name": {"type": "string"},
            "patronymic": {"type": "string"},
            "surname": {"type": "string"},
            "sex": {"type": "string", "enum": ["М", "Ж"]},
            "birth_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"},
            "phone": {"type": "string", "minLength": 12, "maxLength": 12},
            "passport": {"type": "null"},
            "result": {"type": "string", "const": "ok"},
            "height": {"type": "number", "exclusiveMaximum": 3},
            "citizenship": {"type": "boolean"},
            "driver_license": {
                "type": "array",
                "items": {"type": "string", "enum": ["A", "B", "C", "D", "E"]},
                "uniqueItems": true
            }
        },
        "additionalProperties": false
    }

Обратите внимание, что новое поле driver_license не указано в списке обязательных, т.е. все объекты, которые мы упоминали до этого (и в которых не было этого поля) останутся валидными.

Здесь я указал, что это массив, элементы которого могут быть строками из списка — «A», «B», «C», «D», «E» — причем, обязательно уникальными (uniqueItems). Это значит, что тест упадет, если мы укажем категорию Q или если случайно введем BB.

Пример 7. Альтернативные значения

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

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

answer = [
    {
        "id": 1,
        "name": "Иван",
        "patronymic": "Петрович",
        "surname": "Белкин",
        "sex": "М",
        "birth_date": "1999-06-06",
        "phone": "+79991234567",
        "passport": None,
        "result": "ok",
        "height": 1.76,
        "citizenship": True,
        "driver_license": ["B"]
    },
    {
        "id": 2,
        "name": "Программист",
        "patronymic": None,
        "surname": "Мамкин",
        "sex": "М",
        "birth_date": "2002-04-10",
        "phone": "+79991232367",
        "passport": None,
        "result": "ok",
        "height": 1.82,
        "citizenship": False,
        "driver_license": []
    }
]

Если отдать такой список онлайн-генератору, он создаст схему по первому встретившемуся типу. Так в surname — попадет тип string. Обычно в онлайн-генераторах есть свои валидаторы. И данный список проверку не пройдет, потому что во втором элементе списка значение отчества — null. Получаем абсурдную ситуацию — генератор не может валидировать схему, которую сам же сделал.

Создавая схему самостоятельно, тип можно задать через список [«string», «null»]:

"$schema": "http://json-schema.org/draft-07/schema#",
    "type": "array",
    "minItems": 1,
    "items": {
        "type": "object",
        "required": [
            "id",
            "name",
            "patronymic",
            "surname",
            "sex",
            "birth_date",
            "phone",
            "passport",
            "result",
            "height",
            "citizenship"
        ],
        "properties": {
            "id": {"type": "integer", "minimum": 1},
            "name": {"type": "string"},
            "patronymic": {"type": ["string", "null"]},
            "surname": {"type": "string"},
            "sex": {"type": "string", "enum": ["М", "Ж"]},
            "birth_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"},
            "phone": {"type": "string", "minLength": 12, "maxLength": 12},
            "passport": {"type": "null"},
            "result": {"type": "string", "const": "ok"},
            "height": {"type": "number", "exclusiveMaximum": 3},
            "citizenship": {"type": "boolean"},
            "driver_license": {
                "type": "array",
                "items": {"type": "string", "enum": ["A", "B", "C", "D", "E"]},
                "uniqueItems": true
            }
        },
        "additionalProperties": false
    }

В этом случае список пройдет проверку.

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

Еще одна альтернатива, о которой хотел поговорить в этом примере, — ID. Предположим, ID в ответе может быть как числовым, так и строчным вида ID002. Т.е. оно либо принимает значение integer с минимальной 1, либо является строкой с определенным паттерном. Эти условия можно задать через any of, указав все возможные варианты:

"$schema": "http://json-schema.org/draft-07/schema#",
    "type": "array",
    "minItems": 1,
    "items": {
        "type": "object",
        "required": [
            "id",
            "name",
            "patronymic",
            "surname",
            "sex",
            "birth_date",
            "phone",
            "passport",
            "result",
            "height",
            "citizenship"
        ],
        "properties": {
            "id": {"anyOf": [{"type": "integer", "minimum": 1}, 
{"type": "string", "pattern": "^ID\\d{3}$"}]},
            "name": {"type": "string"},
            "patronymic": {"type": ["string", "null"]},
            "surname": {"type": "string"},
            "sex": {"type": "string", "enum": ["М", "Ж"]},
            "birth_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"},
            "phone": {"type": "string", "minLength": 12, "maxLength": 12},
            "passport": {"type": "null"},
            "result": {"type": "string", "const": "ok"},
            "height": {"type": "number", "exclusiveMaximum": 3},
            "citizenship": {"type": "boolean"},
            "driver_license": {
                "type": "array",
                "items": {"type": "string", "enum": ["A", "B", "C", "D", "E"]},
                "uniqueItems": true
            }
        },
        "additionalProperties": false
    }

Пример 8. Ссылки на определения

Допустим в ответе API приходит несколько дат — помимо даты рождения у пользователя есть еще какая-нибудь дата активации. Я придерживаюсь принципа, что если ты что-то пишешь два раза, значит при этом делаешь что-то не так.

Для проверки даты выше мы использовали довольно сложное регулярное выражение: ^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$. Этот паттерн можно вынести в отдельное место, чтобы переиспользовать везде, где требуется. Для этого нужно создать в схеме раздел defs:

    "$defs": {
        "formatted_date": {"type": "string", "pattern": 
"^(19[789]|20[012])\\d-(0\\d|1[0-2])-([0-2]\\d|3[01])$"}
    },

Далее в схеме везде, где приходит дата, мы используем ссылку на formatted_date:

       "properties": {
            "id": {"anyOf": [{"type": "integer", "minimum": 1}, 
{"type": "string", "pattern": "^ID\\d{3}$"}]},
            "name": {"type": "string"},
            "patronymic": {"type": ["string", "null"]},
            "surname": {"type": "string"},
            "sex": {"type": "string", "enum": ["М", "Ж"]},
            "birth_date": {"$ref": "#/$defs/formatted_date"},
            "activation_date": {"$ref": "#/$defs/formatted_date"},
            "phone": {"type": "string", "minLength": 12, "maxLength": 12},
            "passport": {"type": "null"},
            "result": {"type": "string", "const": "ok"},
            "height": {"type": "number", "exclusiveMaximum": 3},
            "citizenship": {"type": "boolean"},
            "driver_license": {
                "type": "array",
                "items": {"type": "string", "enum": ["A", "B", "C", "D", "E"]},
                "uniqueItems": true
            }
        },

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

Но при этом рекомендую помнить о том, что JSON-схема всегда показывает только первую проблему. Это значит, что по падению теста будет непонятно, что именно испортилось — ответ API или, допустим, регулярное выражение (т.е. неверен формат всех дат).

Заключение

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

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

Автор статьи: Владимир Васяев, Максилект.

Статья написана по мотивам выступления специалиста на конференции PiterPy 2023.

Наши статьи по теме:

Все статьи

Связаться с нами

Мы свяжемся с вами в течение 24 часов.