×Закрыть

Об автоматической генерации тестовых данных, или Зона комфорта для ваших тестов

Представьте себе ситуацию: однажды, придя на работу и проверив результаты ночного прогона автотестов, вы видите абсолютно красный отчёт. Копнув глубже, вы находите причину — заботливо созданные вами тестовые данные бесследно исчезли вместе с остальным содержимым базы данных. Разработчикам накануне срочно потребовалось очистить старые данные на тестовом окружении, но вас эта новость обошла стороной.

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

С чего начать

Итак, мы решили реализовать автоматическую генерацию данных для своих автотестов. С чего начать?

Чтобы не изобретать велосипед, лучше выбрать оптимальный подход и вооружиться существующими наработками. Какими именно? Вряд ли в сети вы найдете некий «Бесплатный-Супер-Инструмент», который совершенно случайно уже кто-то написал для вас и, приложив его к проекту аки подорожник, вы решите все свои вопросы. Здесь мы обратимся к существующим шаблонам проектирования. Они помогут нам грамотно реализовать свой модуль генерации данных, который будет легко внедрить в тестовый фреймворк. Но прежде чем обратиться к вопросу паттернов, стоит ознакомиться с основными понятиями.

  • Данные. Опираясь на объектно-реляционную модель, мы подразумеваем, что данные — это набор объектов и связей между ними.
  • Объекты, в свою очередь, — это некоторые сущности, которые обладают набором полей разного типа и могут иметь некоторые ограничения.
  • Связи определяют зависимости между объектами и также могут накладывать ограничения на манипуляцию с ними (например, когда создание потомка без указания его предка невозможно).
  • Представление данных — описание структуры объектов, их полей и связей. Могут быть составлены в разных форматах: XML, JSON, YAML и т. д. Основываясь на модели представления, мы и будем генерировать наши данные.

Строй и компонуй

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

Именно здесь мы прибегаем к помощи паттернов проектирования, а именно к паттернам Builder и Composite.

  • Builder pattern — порождающий шаблон проектирования. Предоставляет интерфейс для создания составного (сложного) объекта, отделяет логику конструирования объекта от его представления.
  • Composite pattern — структурный шаблон проектирования, объединяющий объекты в древовидную структуру для представления иерархии от частного к целому. Предоставляет клиентам возможность обратиться как к одному объекту, так и сразу к группе объектов через единый интерфейс.

Говоря простыми словами, используя Builder шаблон, мы реализуем генерацию, «строительство» сложного объекта, а Composite предоставит нам интерфейс для манипуляции с элементами дерева объектов.

Как это работает

Допустим нам нужно сгенерировать следующий набор объектов и связей:

  • Маркет — корневой объект, от которого наследуются все остальные. Маркетов может быть много, они никак не пересекаются между собой. Содержит такие поля, как market_id, name, country, is_active.
  • Компания — может существовать только в одном маркете. При этом маркет может содержать в себе много компаний, названия которых должны быть уникальны в рамках маркета. Содержит поля company_id, market_id, name.
  • Канал — может существовать только в одной компании, при этом компания может содержать в себе много каналов. Названия каналов должны быть уникальны. Также есть возможность включить опцию публикации постов и в текущий канал, и любой другой в текущем маркете. Содержит поля channel_id, company_id, state, is_secret, secret_phrase, can_share, share_to, main_image, name.
  • Пост — публикуется в канале, при этом канал может содержать в себе много постов. Заголовок поста необязательно должен быть уникален. Содержит поля post_id, channel_id, title, description, state, main_image.

Снизу представлен граф модели данных, где вершина 0 — маркет, в котором находятся две компании 1 и 2. В компании 1 опубликовано два канала — 10 и 4 с постами внутри каналов (5-9). В канале 10 включена опция возможности публикации постов в канал (4). Компания 2 не содержит в себе каналов.

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

Таким образом, если нам, например, нужно создать новый канал во второй компании, мы вызовем что-то похожее на это:

builder_instance.build_channel(data_model, name=’News’, company_id=company_id)

Тогда наш builder_instance подготовит модель данных для нового канала, предварительно заполнив все нужные поля, загрузит на бекенд картинку, получит её id и вставит в main_image поле, укажет, к какой компании прилинковать канал, и отправит запрос на создание сущности. Нас не должны заботить все эти детали реализации. Главное, что на выходе мы получим channel_id/channel_name, сгенерированного на основе описанной нами модели данных.

Схема сборки связки: Маркет — Компания — Канал — Пост внутри builder-класса

Теперь настало время выделить роль компоновщика и его функции.

Допустим мы можем создать новый тестовый канал одной строкой. Но что делать, когда нам нужно создать не один, а, например, сорок каналов и всем установить параметр is_published = True?

Как раз чтобы не вызывать один метод сорок раз, нам и нужен компоновщик. Используя уже описанную выше модель, предположим, что теперь нам нужно опубликовать все посты в канале с определённым channel_id:

composite_instance.publish_posts(channel_id)

Компоновщик возьмет канал с указанным id из списка, созданного билдером, и в цикле, пройдя по списку постов в данном канале, на каждом вызовет: builder_instance.publish.post(post_id)

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

Кому-то может показаться излишним добавление еще одного уровня абстракции в лице компоновщика, ведь всю логику можно реализовать напрямую в билдере. Однако, если вы планируете в перспективе использовать строителя в качестве reusable компонента, к примеру, для написания на его основе API или DB тестов, то отсутствие дополнительной прослойки создаст вам ненужные препятствия в будущем.

В схемах, представленных выше, присутствует еще один компонент — Data Access library. Этот слой представляет собой уровень, который непосредственно отвечает за коммуникацию с приложением — создание и сохранение данных, например, посредством web API или напрямую в MS SQL. В моём частном случае генерация данных реализована через web API приложения, а в качестве библиотеки я использовал Python Requests — удобный модуль, работающий на основе urllib3 и позволяющий собрать запрос и получить ответ буквально в пару строк. Ознакомиться с возможностями Requests можно здесь. Также может так случиться, что вам нужно генерировать рандомные значения полей в красивом виде. Здесь нам поможет библиотека Faker, изначально написанная для PHP, но впоследствии портированная на Python и Java. Faker умеет генерировать рандомные имена, фамилии, адреса, email-адреса, номера телефонов, просто слова и т. д. Больше прочитать об этой библиотеке можно здесь, а если вы используете Java — загляните сюда.

Преимущества и недостатки подхода

Преимущества:

  • независимость тестов;
  • экономия времени;
  • удобство поддержки;
  • возможность повторного использования.

Недостатки:

  • удобство поддержки сильно зависит от реализации.

Выводы

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

LinkedIn

18 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Автор, выражаю респект вашему решению проблемы генерации данных. У нас на проекте используется практически идентичный подход: integration test suite использует самописный data definition framework для создания в БД всего необходимого. Вижу, другие комментаторы недоумевают, зачем такие «сложности» и на полном серьёзе предлагают использовать SQL-скрипт или эталонную базу. Не хочу вступать с ними в срач и что-то доказывать и вам не рекомендую. Раз у вас родилось такое решение — значит, команда «созрела» и прошла этапы решений «на коленке» типа SQL-скрипта.

Подход ущербный, работать для чего-либо серьезного (а не просто наполнить пользовательский интерфейс случайными данными) не будет. Даже если не говорить о том, что опущено разделение на управляющие, справочные и операционные данные. «Генерация» рано или поздно упрется в тот факт, что «на проде все не так». Любые игры с графами и прочая — это попытки построить альтернативный путь реальному использованию системы. В принципе, самый реалистичный способ генерации операционных данных — это прогон end-to-end автотестов, но и он требует такого числа вспомогательных шагов (как минимум, манипуляций с датами), что в миллион раз проще создать/иметь референсную тестовую копию базы, которую беречь-любить-накатывать

Подход ущербный

При желании любое тестирование (в т.ч. и мануальное) можно назвать ущербным, потому что системы сложные, всех случаев никогда не предусмотришь и на production всегда может произойти ситуация, о которой никто не знал.

Но несмотря на это, тестирование имеет смысл. Даже при невозможности достижения 100% покрытия, какую-то часть функционала они покрывают.

То же самое можно сказать и в данном случае — тестирование со сгенерированными данными.

Из практики — у нас API почти полностью покрыта integration-tests, для которых на ходу создаются данные, примерно так-же, как это описано в статье.
Да, случаются баги, которых нет в этих тестах. Но это происходит редко. Зато ежедневно, внося очередную правку в API можно быстро проверить и быть уверенным, что основне кейсы работают.

создать/иметь референсную тестовую копию базы, которую беречь-любить-накатывать

У вас есть опыт поддержания референсной копии БД сложной системы в течении года и больше? Где хотя-бы пару десятков моделей в доменной области, со сложной иерархией, связями многие ко многим и все такое.

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

Интересно, как кто-то решает эту проблему?

И со временем, под каждое новое изменение в структуре данных приходилось делать трудоемкое обновление референсной базы (или фиксить текущую или заново создавать новую).

Чуваки сирёзно ну хватит маразма вы просто генерите ту базу об которой говорите каждый раз заново чисто технически её тупо хранить в той самой базе и тупо добавлять обновлять ровно та же д история только кода либо меньше либо он вообще «вручную одноразовый» либо его вообще нет в вашем случае ровно та же ж база просто хранится в коде всё.

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

Каким подходом, взять базу отскрамбленную базу с прода, стейджинга или UAT и использовать ее для функционального тестирования? А в чем сложность?
Далее, о каком комбинаторном взрыве и покрытии речь? Если для вашего конкретного теста нужно наличие/отсутствие в системе конкретных данных — это необходимо явно указывать в тесте (и проверять и устанавливать в процессе тестирования), а не неявно надеяться, что вы можете иметь базу «со всем нужным и без ненужного».

И со временем, под каждое новое изменение в структуре данных приходилось делать трудоемкое обновление референсной базы (или фиксить текущую или заново создавать новую).

Не совсем понял. «Каждое новое изменение» предназначенное для релиза в прод должно содержать адекватные ролаут скрипты, которые решают указанные проблемы. Иначе как вы релизить-то собираетесь? Делать изменения в схеме на пустой базе, а потом допиливать «чтоб выкатилось и не сломало ничего» — дурной тон.

У вас есть опыт поддержания референсной копии БД сложной системы в течении года и больше? Где хотя-бы пару десятков моделей в доменной области, со сложной иерархией, связями многие ко многим и все такое.

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

В Scala check уже всё это придумано, и довольно давно.
github.com/...​b/master/doc/UserGuide.md

А в hackage.haskell.org/package/QuickCheck и того раньше.
Вот ещё до кучи: github.com/mcandre/rubycheck (хотя в рубях не поспеешь за трендом того, какая библиотека менее мертва).

А почему просто не использовать SQL DML скрипты сразу после инсталляции новой версии?

У меня тоже возник этот вопрос первым. При этом еще можно поддерживать версионность

Не всегда этот подход применим, т.к. на проде запросто могут быть сенситивные данные, не предназначенные для глаз девелоперов. А если генерировать SQL не по данным продакшена — то упираемся в несоответствие распределения этих данных. Если допустима обфускация сенситивные данных — то это выход (если нет данных, зависящих от инстанса). И ещё много подобных нюансов. На более-менее крупной системе задача становиться достаточно нетривиальной (не по технической части, а с точки зрения безопасности чувствительных данных)

если нет данных, зависящих от инстанса

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

Вы тестируете на продакшн сервере да еще и на актуальной базе? Или я что-то не так понял?

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

шота я не шарю ... build_channel має 3 параметра, а якби там треба було заповнити 15 параметрів ? може варто шось от таке схоже заюзати — github.com/benas/random-beans ?

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

А что мешало сделать пару бинов и проставить пару геттеров сеттеров?

Подписаться на комментарии