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