d42 в действии: лучшие практики подготовки данных
Привет, меня зовут Андякина Ольга, я QA-инженер в компании 2ГИС, тестирую сервис по бронированию отелей Отелло. В этой статье расскажу про плюсы и минусы разных подходов к генерации данных и поделюсь хорошими практиками подготовки данных на примере пакета библиотек d42 и нашего проекта. В первую очередь, наш опыт будет полезен тем, кто занимается автоматизацией тестирования фронтенда.
Автоматизированное тестирование сложно представить без использования тестовых данных. Например, при интеграционном тестировании нужно записывать данные в базу и валидировать полученные данные, в нагрузочном — выполнять запрос с уникальными параметрами. Также данные могут потребоваться при фронтенд- и e2e-тестировании, чтобы наполнить блок текстом или ввести ФИО в форму. Есть различные подходы к получению тестовых данных для автотестов, и генерация данных — один из самых удобных и гибких подходов.
Подходы к генерации данных
Я выделяю три основных подхода к генерации данных:
генерация статичных данных,
генерация случайных данных,
и генерация контролируемых данных (или контролируемо-случайных).
Чтобы сравнить эти подходы, я выбрала четыре, на мой взгляд, самых важных, критерия оценки работы с данными:
скорость добавления данных;
простота поддержки;
подверженность эффекту пестицида;
вероятность ложных падений.
Давайте рассмотрим каждый из подходов в разрезе выбранных четырёх критериев.
Статичные данные
Это один из самых простых и распространённых подходов к работе с данными в автотестах. В этом случае готовые данные лежат в репозитории в формате JSON, XML или других текстовых файлов. Несмотря на простоту внедрения и использования, этот подход может привести к избыточности файлов, усложнить управление ресурсами и поддержку автотестов.
{
"meta": {
"code": 200,
"description": "OK"
},
"payload": {
"pages": {
"item_count": 9,
"position": {
"page": 1,
"size": 20
}
},
"items": [
{
"id": "5630027815194913",
"name": "Kaiserhof",
"star_rating": 4,
"address": {
...
Скорость добавления статичных данных зависит от объёма тестирования: чем больше автотестов, тем больше различных данных они требуют. Но, как правило, значительных затруднений на этом этапе не возникает.
Поддержка статичных данных может стать проблемой при изменениях контрактов на проекте, так как это потребует обновить все файлы, использующие конкретную структуру данных. Например, если было удалено или переименовано одно поле в эндпоинте, который отдаёт данные с информацией об отеле, то нужно будет исправить заранее заготовленные тестовые данные для всех автотестов, использующих информацию об отеле (а у нас, например, это несколько тысяч автотестов).
Повторное использование одних и тех же наборов данных при каждом новом прогоне тестов может привести к нежелательному эффекту пестицида. И избежать этого не удастся, когда данные захардкожены.
Но у такого подхода, конечно, есть и один значительный плюс — отсутствие ложных срабатываний. Тесты почти наверняка не будут флакать из-за данных. Ведь при подготовке статичных данных всегда подбираются такие значения, которые будут идеально подходить под конкретный кейс.
Рандомные данные
Второй подход — использование случайных или рандомных данных, позволяет определить модель данных (набор полей и тип их содержимого) и генерировать их случайным образом для каждого нового прогона тестов. Однако, такой метод зачастую порождает очень объёмные данные, которые могут неожиданным образом сломать приложение (особенно заметно это будет на фронтенде).
Ниже — пример кода, в котором используется подход генерации случайных данных. Заданы ключи и формат содержимого для каждого поля в JSON.
{
"accommodation_type": {
"code": str,
"label": str
},
"address": {
"distance_to_center": int,
"city": str,
"name": str,
"building_id": str,
"point": {
"lat": float,
"lon": float
},
},
"id": str,
...
На основании этой модели данных мы получаем случайно сгенерированный на основе модели данных JSON-файл. Например, как на скриншоте ниже.
{
"accommodation_type": {
"code": "xxOKxGolLHBaZiLu",
"label": "fMkvsOVlGXQDyM"
},
"address": {
"distance_to_center": 3510,
"city": "6_7_-4 -y-G O7 _ _3M7--h--9JUW-_ 3- WX_R--DV7_r_o3--X2_ 5_ v LDY--X_9 _4 6g _g -fg - a41 g_5r_-___i",
"name": "4 1_-x5c-F8_6__u_d-_YU--y 6-O_6_ -oB_o_G B_ 1k96s 565_1_1KH___",
"building_id": "c1v6kUIa6k2PFxTnNI48GKY-bnL",
"point": {
"lat": 2.404909160820557e+18,
"lon": 2.174814387471491e+18
},
},
"id": "3451745674928687255922115314056",
...
Генерация данных в таком случае происходит быстро, так как модель описывается только один раз и может быть переиспользована в других контекстах. А при изменении контрактов достаточно будет исправить только модель данных, а не множество статичных файлов. Однако такой подход часто приводит к ложным падениям из-за слишком большого разнообразия генерируемых данных, из-за этого растут затраты на поддержку автотестов. Зато такой подход помогает уменьшить уровень воздействия эффекта пестицида на автотесты.
Контролируемые данные
Третий подход к генерации данных предполагает генерацию рандомных данных с учётом определенных ограничений, соответствующих используемым на проекте моделям данных. Это компромисс между двумя первыми подходами, при котором возможно почти полностью сохранить их преимущества и нивелировать недостатки.
![b7b4d9166b0341dbbc7996961c17593f.jpg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/b7b/4d9/166/b7b4d9166b0341dbbc7996961c17593f.jpg)
При таком подходе добавление новых моделей данных происходит так же быстро, как и при генерации случайных данных. Он предотвращает появление флаки тестов, если модели данных описаны корректно, что позволяет избежать нестабильности тестов, связанной с данными, а также обеспечивает быструю адаптацию к изменениям контрактов. Кроме того генерация нового набора данных для каждого прогона тестов помогает уменьшить влияние эффект пестицида.
Таблицу с итоговой оценкой каждого из подходов можно представить так:
Пакет библиотек d42
На проекте Отелло мы используем генерацию контролируемых данных для наших автотестов на фронтенд, для этого используем d42.
d42 — это пакет библиотек на языке Python, позволяющий описывать, генерировать и валидировать данные. Он может быть интегрирован в PyTest или фреймворк Vedro, а также может использоваться с другими фреймворками или независимо от них.
Устанавливается он так:
# PyTest или другие
$ pip3 install d42
# Vedro
$ pip3 install d42
$ pip3 install vedro-valera-validator
Описание моделей данных
Для описания моделей данных, которые на языке d42 называются схемами, используется библиотека district42. d42 поддерживает множество различных типов данных, условно разделённых на 3 категории:
Scalar types, скалярные типы данных — стандартные типы данных для Python (булевые значения, целые числа, числа с точкой, строки и т.д.). Для таких данных предусмотрены различные операции над ними, например, ограничение минимального или максимального значения для числа, длины и размера для строки.
Полный список поддерживаемых типов можно посмотреть по ссылке.
Рассмотрим пример объявления схем определённого типа данных в District 42.
from d42 import schema
from string import ascii_letters
sch1 = schema.int.min(0) \\ целое число, значение которого будет от нуля включительно и больше
sch2 = schema.str.len(3, ...) \\ строка, у которой ограничена длина, она может быть от трех символов и больше
sch3 = schema.str.alphabet(ascii_letters) \\ строка, которая содержит только буквы
sch4 = schema.str.contains("banana") \\ строка с содержанием подстроки
sch5 = schema.str.regex(r"[0-9]{4}$") \\ строка с регулярным выражением будет содержать 4 рандомные цифры
2. Container Types, контейнерные типы данных — составные типы, содержащие в себе один или несколько объектов любых типов данных (скалярных и/или контейнерных).
Например, к контейнерным типам данных относятся привычные нам dict и list. Их также можно использовать привычным образом:
задавать типы содержимого,
указывать ключи для словарей,
ограничивать набор элементов списка.
Также к контейнерным типам данных относится schema.any. Schema.any — это схема, которая может содержать в себе любой тип данных. Если нужно, то можно ограничить допустимые типы данных для schema.any, например, строками и целыми числами. Сделать это можно двумя способами, как в примере ниже.
from d42 import schema
sch1 = schema.any(schema.str, schema.int)
sch2 = schema.str | schema.int
Похожим образом можно использовать привычный питоновский словарь в schema.dict. Например, описанная ниже схема ожидает словарь с обязательными ключами id и name, который также может содержать и другие ключи (обозначено многоточием).
from d42 import schema
sch3 = schema.dict({
"id": schema.int.min(1),
"name": schema.str.len(1, 30),
...: ...
})
Также мы можем объявлять и списки. Ниже мы используем schema.list, которая обязательно должна содержать ровно два элемента, один из которых будет типа int, другой — string.
from d42 import schema
sch4 = schema.list([schema.int, schema.str])
А это объявление списка, у которого неограниченное количество элементов типа int.
from d42 import schema
sch5 = schema.list(schema.int)
Больше информации о контейнерных типах данных можно найти по ссылке.
3. Custom Types, кастомные типы данных, определяются пользователем исходя из специфичных для проекта потребностей. Этот вариант подходит для тех случаев, если в d42 отсутствует необходимый тип данных, часто применяемый в проекте.
Для создания нового типа данных необходимо:
объявить тип,
написать генератор — правило, по которому будет генерироваться значение данного типа,
написать валидатор, который будет проверять тестовые данные на соответствие с этим типом.
Подробную инструкцию по созданию кастомного типа данных с примерами можно найти на сайте d42.
Генерация данных
Следующий этап после описания моделей данных — это их генерация. В d42 доступна библиотека blahblah, которая генерирует фейковые данные в соответствии со схемами district42.
Пример 1 — рандомные данные
В Отелло есть страница с избранными отелями, на которой расположены карточки с основной информацией об отелях. Попробуем получить данные для тестов на эту страничку с помощью генерации рандомных данных с помощью JSON-схемы.
В JSON-схеме есть следующие поля:
тип размещения («accommodation type») — строка;
название отеля («name») — строка;
город размещения («city») — строка;
рейтинг («rating») — число;
количество отзывов («count») — число.
{
"type": "object",
"properties": {
"accommodation_type": { "type": "string" },
"name": { "type": "string" },
"city": { "type": "string" },
"reviews": {
"type": "object",
"properties": {
"rating": { "type": "number" },
"count": { "type": "number" }
}}}}
При генерации случайных данных по этой JSON-схеме получится следующее:
Тип размещения, название отеля и город не просто не помещаются в одной строке, а даже выходят за пределы контейнера, что может привести к неожиданным сайд-эффектам (например, растянуть контейнер за пределы экрана или перекрыть интерактивный элемент так, что с ним будет невозможно взаимодействовать). Также возникает неожиданная ситуация с рейтингом: его значения ожидаются фронтендом в диапазоне от 1 до 5. Однако случайная генерация чисел может привести к отрицательным значениям или к огромным положительным числам, которые не соответствуют желаемой градации в пределах этого диапазона.
Модель данных, описанная с помощью схемы d42, выглядит уже более компактной и информативной:
FavoritesItemsSchema = schema.dict({
'accommodation_type': schema.str.alphabet(string.ascii_letters).len(1, 50),
'reviews': schema.dict({
'rating': schema.float.min(1.0).max(5.0),
'count': schema.int
})
'name': schema.str.regex(r"[a-zA-Z-][a-zA-Z -]{0,99}"),
'city': schema.str.regex(r"[a-zA-Z-][a-zA-Z -]{0,99}")
})
Тип размещения — строка с регулярным выражением, содержащим только буквы, длина от 1 до 50 (с запасом). Рейтинг можем ограничить значениями от 1 до 5. Название отеля и город будут строками с регулярными выражениями, включающими буквы, пробелы и дефисы (самые распространённые символы в названиях отелей), с ограничением длины от 1 до 100 символов.
Чтобы сгенерировать контролируемые данные с помощью d42 в соответствии с заданной схемой, используем функцию fake, в которую передаём нужную схему в качестве аргумента.
from d42 import schema, fake
FavoritesItemsSchema = schema.dict({
...
})
favorites = fake(FavoritesItemsSchema)
По заданным условиям получается следующая картина:
На скрине можно увидеть рандомно сгенерированные данные (местами довольно странные, как, например, в названии отеля), но уже чуть более приближенные к реальности.
Некоторые ограничения из примера могут показаться излишними, и это нормально, ведь с d42 можно установить приемлемый уровень ограничения под любой проект. Например, если есть необходимость проверять не только позитивные, но и негативные кейсы для рейтинга, то его можно ограничить не отрезком [1,5], а захватить отрицательные значения и положительные значения больше, чем 5, например, [-1,6].
Ещё для для работы с JSON-схемами существует библиотека SchemaMaximal, которая является дополнением к d42. Она умеет конвертировать JSON Schema в d42 Schema и обратно.
Пример 2 — статичные данные
Ещё пример — карточка бронирования, где присутствуют поля: статус бронирования, ID бронирования и информация о гостях.
Генерация статичных данных в JSON выглядела бы примерно так:
информация о дате создания, которая не отображается на UI;
ID бронирования, который виден пользователю;
некоторая информация о гостях;
статус бронирования.
{
'created_at': '24.11.2023',
'id': '3065507',
'guests': {...},
'status': 'cancelled'
}
Наша цель — протестировать UI при разных статусах бронирования, от которых зависит отображение бронирования и некоторые функциональные возможности (например, подтверждённое бронирование можно отменить).
Для проверки различных статусов, таких как «done», «confirmed», «cancelled», «verify» и других, потребуется создать отдельный JSON. Например, у нас в проекте — более десяти различных статусов бронирования, а также есть другие поля, значения которых может влиять на функциональность и отображение данных. Перебор всех сочетаний таких полей (даже с использованием попарного тестирования) потребует создания множества отдельных файлов с данными для каждой проверки.
А схема с использованием d42 выглядит следующим образом:
BookingSchema = schema.dict({
'created_at': schema.str,
'id': schema.str.regex(r"[a-zA-Z0-9_-]{1,100}"),
'guests': schema.list(GuestsSchema).len(1, 10),
'status': schema.str('done') |
schema.str('confirmed') |
schema.str('verify')
})
Для поля «created_at», содержащего информацию о дате создания, используется строковый формат, поскольку это значение не отображается на UI, а просто передается на бэкенд (не добавляем лишних ограничений). Для поля «id» мы придерживаемся строгих контрактов с бэкендом, ограничивая его буквами, цифрами, подчеркиваниями и дефисами, с максимальной длиной в 100 символов. Для поля, содержащего информацию о гостях, ограничиваем количество экземпляров до 10, чтобы избежать перегрузки пользовательского интерфейса. Это предотвратит ситуацию, когда сгенерировано, например, 300 объектов с информацией о гостях, вызывая бесконечную прокрутку на UI. Наконец, для поля, обозначающего статус бронирования, нужно перечислить все доступные значения через оператор «или», чтобы был выбран один конкретный элемент из списка.
При генерации контролируемых данных получается разнообразие статусов (и других данных тоже). Например, при статусе «подтверждено» (confirmed), могут появляться дополнительные секции, которые мы также можем проверить:
Если же есть необходимость проверить конкретный, а не случайный статус бронирования, то можно воспользоваться библиотекой revolt, также вшитой в d42. Она позволяет заменить значения для d42-схем прямо во время генерации. Для этого мы можем просто использовать знак % и указать, какое поле мы хотим переопределить и на какое значение.
# test.py file
self.booking = fake(BookingSchema % {'status': 'confirmed'})
Конечно, данные можно переопределить и после генерации:
# test.py file
self.booking = fake(BookingSchema)
self.booking['status'] = 'confirmed'
Но я не рекомендую так делать, потому что:
Данные не будут провалидированы по схеме. Если вставить данные некорректного типа, например, число вместо строки, то никакой ошибки не будет. А если попытаться переопределить строку на число во время генерации через d42, то будет ошибка, сообщающая о том, что такое действие совершить невозможно, поскольку валидируемое значение не соответствует модели данных.
Скорее всего схема не соответствует реальным контрактам. Если такое переприсвоение после генерации делается осознанно, то это значит, что модель данных либо не соответствует реальности, либо не учитывает потребности автотестов.
Также иногда модель данных может содержать необязательные поля, которые могут присутствовать или отсутствовать в зависимости от ситуации. Например, при заполнении формы пользователь может не указать гражданство или пол, так как эти данные опциональны. Такие поля в схеме можно помечать как optional, чтобы они не генерировались по умолчанию.
UserInfoSchema = schema.dict({
'name': schema.str.regex(r"[A-Za-z]{1,128}"),
optional('citizenship'): schema.str("RUS"),
optional('sex'): schema.str("Male") |
schema.str("Female"),
})
Но если возникнет необходимость использовать эти поля в тестах, то во время генерации данных можно пометить их как required. В таком случае, эти поля станут обязательными и будут сгенерированы. В коде это выглядит вот так:
# test.py file
self.user =
fake(make_required(UserInfoSchema['citizenship']['sex']))
Валидация данных
Заключительный этап работы с данными — валидация сгенерированных данных. В автотестах часто приходится работать с данными, например, полученными с бэкенда, и нам необходимо убедиться, что они соответствуют нашим моделям данных и принятым на проекте контрактам.
Для этого в пакете d42 можно есть библиотека valera, которая валидирует данные на соответствие district42 схемам.
Пример
Ниже представлена UserSchema с ключами «id» и «username», для которых установлены правила генерации данных для каждого контекстного поля.
from d42 import schema
UserSchema = schema.dict({
'id': schema.int.min(1),
'username': schema.str.len(1, 8)})
А также объект данных с ключами «id» и «username», содержащими определенные значения. «id» равен 0, а «username» — пустая строка.
UserSchema = schema.dict({
'id': schema.int.min(1),
'username': schema.str.len(1, 8)})
TestData = {
'id': 0,
'username': ''}
Чтобы проверить соответствие объекта TestData схеме User Schema мы можем просто сравнить схему с тестовыми данными привычным для python способом:
assert UserSchema == TestData
В таком случае упадут две ошибки, о несоответствии объекта схеме. Выглядеть это будет примерно так:
UserSchema = schema.dict({
'id': schema.int.min(1),
'username': schema.str.len(1, 8)})
assert UserSchema == TestData
# valera.ValidationException:
# - Value at _['id'] must be greater than or equal to 1, but 0 given
# - Value at _['username'] must have at least 1 element, but it has 0 elements
Первая ошибка сообщает о том, что поле «id» должно содержать значения больше или равные 1, но у нас 0. Аналогично, для поля «username» ошибка указывает на то, что оно должно содержать хотя бы один элемент, но у нас пустая строка, то есть ноль элементов. Текст ошибки однозначно указывает на проблему.
Преимущества использования d42
В Отелло мы активно используем d42, потому что этот пакет библиотек даёт нам существенное количество преимуществ, которыми мы активно пользуемся.
Читаемость. Схемы d42 более компактны и читаемы по сравнению с JSON-схемами. Это также облегчает введение новичков в проект, они быстро смогут понять, какие на проекте существуют модели данных.
Документируемость (self-documentation). Позволяет всегда иметь под рукой единый источник знаний о контрактах на проекте.
Кастомизация. Кроме возможности создания пользовательских типов данных, можно переиспользовать существующие схемы, комбинировать и декомпозировать их, переопределять поля и выбирать необходимый именно вашему проекту уровень ограничений.
Поддерживаемость. d42 — это активно развивающийся open-source проект со своим коммьюнити, доступный для российского рынка.
Минимизация эффекта пестицида. Кроме того, генерация нового набора случайных данных для каждого запуска тестов, позволяет уменьшить влияние эффекта пестицида на наши автотесты.
Использование подхода генерации контролируемых данных и пакета библиотек d42 могут качественно улучшить опыт работы с автотестами, сделать их более стабильными, уменьшить трудозатраты на добавление новых тестов и упростить их поддержку.
Но помните, что все ограничения должны помогать сделать ваши автотесты более стабильными и читаемыми. А излишние ограничения могут вызвать эффект пестицида и усложнить поддержку тестов.
Если остались вопросы, на всё готова ответить в комментариях! Откликается наш подход — го к нам в команду. А также обязательно заглядывайте в наш телеграм-канал, посвященный тестированию в 2ГИС.