Как быстро и безболезненно закрыть регресс в 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.

P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на нашу страницу в VK или на Telegram-канал, чтобы узнавать обо всех публикациях и других новостях компании Maxilect.


ссылка на оригинал статьи https://habr.com/ru/articles/778700/


Комментарии

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

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