Тестирование в Яндексе: ObjectBuilders для описания и генерации синтетических тестовых данных

в 13:55, , рубрики: Без рубрики

Привет! Меня зовут Денис Чернилевский. В Яндексе я руковожу группой автоматизации тестирования системы медийной рекламы. В процессе своей работы в Яндексе и на предыдущих местах мне довелось руководить командами 10+ человек, налаживать процессы и придумывать подходы автоматизации тестирования различных систем. И так уж вышло, что в каждом из этих проектов приходилось задумываться о подготовке тестовых данных. По итогам довольно долгой рефлексии был придуман подход, который позволяет в общем виде решить эту задачу и применять его в разных проектах. Помимо того, что я буду говорить о нём на Тестовой среде, решил рассказать подробности и здесь.

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

image

Эта статья основана на опыте решения задачи подготовки сложных наборов синтетических тестовых данных в процессе автоматизации тестирования системы медийной рекламы Яндекса. Конечно, мы не первые, кто сталкивается с такой задачей, поэтому для начала проанализировали существующие подходы и решения. В результате решением стала библиотека ObjectBuilders (на Python), которую можно применить в проектах, где необходимо создавать иерархически связанные наборы данных. Она позволяет задавать их связи, параметры и свойства. А также дает несколько бонусов в качестве побочных эффектов.

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

Задача: необходимо протестировать систему медийной рекламы

Итак «что делать, если для теста мне необходимо создать в системе 30 (50, 100 ...) объектов, связанных между собой любыми из отношений многие ко многим, многие к одному, один ко многим, один к одному, и имеющих 10 (20, 40 ...) свойств каждый?»

Здесь сразу оговорюсь: речь идет именно о налаживании функционального blackbox тестирования — без unit тестов или нагрузки.

Итак, как будем её решать. Окей, надо так надо. Что у нас за система? Ага, база данных с данными о рекламных кампаниях, back-end для проведения рекламного конкурса и подсчета статистики, front-end принимающий http запросы и отдающий ответ в неком формате.

Построим инфраструктуру, стенды, наладим CI и будем писать тесты, скажем, на Python и всё будет хорошо! Раз Python, значит для тестов выберем PyTest: удобно, красиво, отчеты в JUnit формате и легкое расширение функционала в виде плагинов. Всё, можно писать тесты! Реально, конечно же, на всё вышеописанное ушла пара человеко-лет, но мы сейчас не об этом.

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

Прошел день неделя в попытках заставить систему показать хотя бы один баннер. Оказывается, чтобы создать в системе одну рекламную кампанию и показать хотя бы один баннер, необходимо забить в базу 30 объектов с 15 параметрами каждый, да еще так, чтобы они были между собой правильно связаны и чтобы разные тестовые данные не влияли друг на друга. Ну, и еще желательно, чтобы тест можно было несколько раз подряд запускать!

Но ведь нам же в каждом тесте необходимы разные рекламные кампании, да еще и несколько, да еще и с разными настройками.

image

И тут кажется, что мы попали. Но делать что-то надо.

Подумав, мы составили следующий список требований на процедуру создания и сами тестовые данные:

  1. Созданные данные должны обеспечивать корректную работу системы.
  2. При изменении логики связей или параметров объектов (например, при разработке новой фичи) должна быть возможность легко и быстро починить ВСЕ тесты, использующие эти объекты, — т.е. необходима легкая поддержка тестов в будущем.
  3. Данные одного теста не должны влиять на поведение другого теста.
  4. Должна быть возможность перезапускать тест несколько раз. Следовательно, данные должны либо каждый раз приводится в изначальное состояние, либо генериться заново.
  5. По описанию этих данных должна быть возможность понять, чем именно они отличаются от всех остальных случаев и почему именно такие настройки нужны для этого теста

Варианты решений

Брать существующие данные с продакшн системы нельзя.

  • Они могут меняться, тест будет работать непредсказуемо; сложно заметить эти изменения (тест продолжит работать, но будет проверять не то что нужно).
  • Они могут исчезнуть — тест совсем перестанет работать.
  • Один тест может поменять данные используемые другим тестом
  • Вообще не понятно что и как используется каждым тестом
  • Единственным плюсом этого решения является отсутствие затрат на собственно подготовку данных.

Вариант заранее руками подготовить базу с данными для каждого конкретного теста тоже не подходит.
Сложно: слишком много параметров и объектов

  • Можно легко ошибиться при создании данных, а ошибку потом будет сложно найти.
  • Сложно понять, какие именно параметры в конкретном наборе данных влияют на поведение теста (сложно разбираться с тем, что и почему проверяет тест)
  • Нельзя будет перезапускать тест — он может поменять параметры своего изначального набора данных, придется перезаливать данные при каждом запуске
  • При изменении логики тестируемого продукта, придется руками изменять каждый затронутый изменениями объект по нескольку раз, так как он может встречаться в разных тестах
  • По сравнению с вариантом 1 это решение имеет один плюс: можно готовить независимые данные, так чтобы тесты не влияли друг на друга.

От этих вариантов мы отказались сразу, так как они нарушают большинство наших требований. Хорошая мысль использовать паттерн Builder. Рассмотрим его плюсы и минусы подробнее.

Builder pattern, или почему мы все-таки его не использовали

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

Из примеров на Вики видно, что этот подход удовлетворяем всем нашим требованиям:

  • Обеспечивают ли созданные данные корректную работу системы? Да, правильно описывая логику конфигурации объектов в строителе можно сделать так, чтобы любые созданные объекты имели правильные связи и параметры
  • Возможно ли при изменении логики связей или параметров объектов легко и быстро починить ВСЕ тесты, использующие эти объекты? Да, за счет инкапсуляции логики создания объектов в строителе, мы можем менять эту логику только в одном месте, то есть выделяем ее в отдельный слой.
  • Данные одного теста не влияют на поведение других тестов? Да, так как с помощью строителя в начале каждого теста мы генерим свой набор объектов, а не используем какие-то существующие.
  • Есть возможность перезапускать тест несколько раз? Да, внутри строителя можно реализовать логику создания уникальных объектов, например, каждый раз давая им уникальные имена или ID.
  • Есть возможность по описанию этих данных понять, чем именно они отличаются от всех остальных случаев и почему именно такие настройки нужны для этого теста? Ммм, почти. Создание каких-то объектов будет выглядеть как вызов набора методов с использованием строителя. При правильном их именовании можно будет интуитивно понимать, что именно они настраивают.

Отлично!

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

Казалось бы, все хорошо, НО у этих паттернов есть недостаток: для каждого типа объектов необходимо реализовывать свой Builder и возможно свой Composite в виде отдельных классов. Так же необходимо описать и методы, позволяющие модифицировать объекты, которые мы собираемся строить, их связи и свойства.

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

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

Немного теории и описание подхода

Как было описано выше, наша тестируемая система оперирует наборами связанных между собой объектов с некоторым набором параметров (так работает большинство систем). Эти объекты и их связи можно представить в виде графа или дерева (вершины — объекты, ребра — связи между объектами). Собственно нашей задачей становится соорудить граф объектов и их параметров для каждого конкретного теста.

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

Более того, в своем тесте вы должны всегда задавать значения всех параметров, которые влияют на его поведение, на все остальные параметры нам все равно – главное, чтобы работало. Если это не так, и есть еще влияющие параметры, см. предыдущий пункт — надо их задать! Влияющих параметров будет много, если ваш тест проверяет много функциональности сразу, и мало, если вы проверяете конкретный кусочек функциональности.
Все это значит, что мы можем заранее зафиксировать некий наиболее часто используемый шаблон графа и параметров, и только немного его «подкручивать» (патчить) в каждом тесте.

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

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

Упрощенный пример — фабрика автомобилей

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

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

Будем подавать на вход различные конфигурации автомобилей. Каждая конфигурация состоит из следующих деталей: шасси (легковое, вездеходное), двигатель (тип: дизель или бензин и объем), колеса (кол-во колес, диаметр, литые или штампованные), кузов (седан, купе, кабриолет, вездеход), коробка передач (ручная, автомат). В идеале нам необходимо проверить все возможные правильные комбинации (кроме запрещенных по ТЗ).
Понятно, что завод может выпускать разные автомобили, но какая-то встречается чаще других. Исходя из общей логики и рыночных требований, логично будет предположить, что чаще всего у нас будут выпускаться автомобили с 4мя колесами, в кузове седан, на 15" штампованных дисках, с бензиновым двигателем 1.6 и ручной коробкой передач.

По-крайней мере, 4 колеса и кузов седан скорее всего будет у 90% из них! Это и будет наша базовая конфигурация. Остальные параметры выбираются по такому же принципу.

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

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

Реализация и использование библиотеки ObjectBuilders

Наш инструмент работает на 2х уровнях:

  1. Constructs — предоставляет инструмент для настройки связей между объектами и генерации самих объектов.
  2. Modifiers — предоставляет инструмент для описания и применения патчей (модификаторов) на граф объектов.

Модификаторы в свою очередь делятся на 2 типа:

  1. InstanceModifiers — модификаторы объектов.
  2. ConstructModifiers — модификаторы Construct'ов.

Например.

class Bar:
    bar = 1


class Foo:
    baz = 10
    bars = Collection(Bar)


my_foo = Builder(Foo).withA(NumberOf(Foo.bars, 5)).build()

Здесь конструкция bars = Collection(Bar) — это уровень 1. Говорим о том, что в классе Foo может содержаться N объектов Bar. К этим объектам можно будет получить доступ посредством my_foo.bars[i]. А конструкция NumberOf(Foo.bars, 5) — уровень 2. Хотим получить объект Foo с 5 объектами Bar внутри.

По умолчанию будет создан граф из 2х объектов: Foo и вложенная коллекция объектов Bar, состоящая из одного элемента.
NumberOf(Foo.bars, 5) — это как раз тот самый модификатор, который можно применить к графу, чтобы в объект Foo было вложено 5 объектов Bar.

Вернемся к нашей фабрике автомобилей.

Объектная модель

Для начала нам необходимо описать классы объектов и их свойства, которыми оперирует наша фабрика.

CHASSIS_LIGHT = 0  # легковое 
CHASSIS_HEAVY = 1  # внедорожное

ENGINE_PETROL = 0  # бензиновый
ENGINE_DIESEL = 1  # дизельный

WHEEL_STAMPED = 0  # штампованный
WHEEL_ALLOY = 1  # литой

BODY_SEDAN = 0  # седан
BODY_COUPE = 1  # купе
BODY_CABRIO = 2  # кабриолет
BODY_HEAVY = 3  # вездеход

TRANSMISSION_MANUAL = 0  # ручная
TRANSMISSION_AUTO = 1  # автоматическая

#Шасси
class Chassis:
  type = CHASSIS_LIGHT

#Двигатель
class Engine:
  type = ENGINE_PETROL
  volume = 1.6

#Колесо
class Wheel:
  radius = 15
  type = WHEEL_STAMPED

#Кузов
class Body:
  type = BODY_SEDAN
  number = ??? # Мы заранее не знаем номер кузова. Хотим генерить его случайным образом в момент сборки автомобиля.

#Коробка передач
class Transmission:
  type = TRANSMISSION_MANUAL

#Спойлер
#Этот класс сделан лишь для лучшей иллюстрации дальше. Главное - спойлер может быть/не быть установлен на автомобиль.
class Spoiler:
  foo = None

Отлично!

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

Например.

chassis = Chassis()
wheels = [Wheel() for _ in range(4)]
engine = Engine()
body = Body()
...
chassis.wheels = wheels
chassis.engine = engine
engine.chassis = chassis
chassis.body = body
body.number = random()
...

Для небольших графов — это легко. В случае, когда в графе несколько десятков объектов и много параметров — становится очень накладно. Для облегчения такой задачи в ObjectBuilders предусмотрены Constructs.

Constructs

Выше уже был короткий пример Foo-Bar, в котором использовалась конструкция Collection().
В нашей библиотеке есть несколько разных типов конструкций, для разных нужд.

class Collection(typeToBuild, number=1)

Коллекция объектов типа typeToBuild. После вызова Builder.build() эта конструкция превращается в list объектов типа typeToBuild. По умолчанию кол-во объектов = 1

class Unique(typeToBuild)

После вызова Builder.build() превращается в уникальный объект типа typeToBuild. Уникальный в том смысле, что даже если в нашем графе уже где-то есть объект типа typeToBuild, мы все равно сгенерим новый.

class Reused(typeToBuild, local=False, keys=[])

В противоположность конструкции Unique, если в графе уже есть объект типа typeToBuild, то он и будет использован, если нет, то будет создан новый объект. При этом с помощью keys параметра можно указать, какие поля класса typeToBuild должны совпадать, чтобы объекты считались одинаковыми.

class Maybe(construct)

Эта конструкция говорит о том, что связь с другим объектом (и сам этот объект соответственно) может присутствовать, а может и нет. По умолчанию при вызове Builder.build() конвертируется в None. Включается модификатором Enabled(), о котором будет написано ниже.

class Random(start=1, end=100500, pattern=None)

Эта конструкция на этапе build() конвертируется либо в случайный int от start до end, либо, если указан паттерн, в строку. Паттерн должен содержать в себе один маркер %d, на место которого будет подставлено случайное число от start до end.

class Uplink()

Позволяет настроить связь объектов в обе стороны, например: foo.bar.foo = foo.

Попробуем переписать нашу модель с использованием Constructs, чтобы в ней присутствовала информация о связи объектов друг с другом.

#Шасси
class Chassis:
  type = CHASSIS_LIGHT
  **engine = Unique(Engine)**
  **body = Unique(Body)**
  **wheels = Collection(Wheel, number=4)**
  **transmission = Reused(Transmission)**

#Двигатель
class Engine:
  type = ENGINE_PETROL
  volume = 1.6
  **transmission = Reused(Transmission)**

#Колесо
class Wheel:
  radius = 15
  type = WHEEL_STAMPED
  **transmission = Reused(Transmission)**

#Кузов
class Body:
  type = BODY_SEDAN
  number = **Random()** # Мы заранее не знаем номер кузова. Хотим генерить его случайным образом в момент сборки автомобиля.
  spoiler = **Maybe(Unique(Spoiler))** # Спойлер можно установить как опцию. По-умолчанию отсутствует.

#Коробка передач
class Transmission:
  type = TRANSMISSION_MANUAL

#Спойлер
class Spoiler:
  foo = None

В итоге мы описали, что:

  • К шасси крепятся: двигатель, кузов, колеса, трансмиссия.
  • Колес крепится несколько — 4 (по-умолчанию, но можно будет это потом динамически менять).
  • Шасси, двигатель и колеса крепят к себе трансмиссию. Причем одну и ту же. Об этом говорит Reused конструкция.
  • К кузову может крепиться спойлер (как опция, по-умолчанию отсутствует).
  • Номер кузова будет сгенерирован во время сборки автомобиля.
  • Все остальные параметры заданы по-умолчанию (но их тоже потом можно будет динамически задать).

Что теперь с этим всем делать? Теперь мы можем за один вызов получить полноценный автомобиль! Правда в базовой комплектации.

car = Builder(Chassis).build()

Тип объекта car — Chassis, поэтому:

>>>car.engine.volume
1.6
>>>car.wheels[0].radius
15
>>>car.body.spoiler
None

Ну и так далее.

То есть помимо, собственно, объекта типа Chassis, будут созданы все объекты связанные с объектом типа Chassis: Engine, Wheel x 4, Body, Transmission. Объект Spoiler создан не будет, так-как по умолчанию Construct Maybe() не создает объекта, а конвертируется в None.

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

В текущей реализации из вершины Chassis можно добраться до всех вершин, из вершины Engine только до Transmission, а из вершины Body только до Spoiler.

То есть например:

>>>engine = Builder(Engine).build()
>>>engine.trasmission.type == TRANSMISSION_MANUAL
True
>>>engine.transmission.engine
AttributeError: Transmission instance has no attribute 'engine'

При этом будут созданы только эти два объекта.

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

#Шасси
class Chassis:
  type = CHASSIS_LIGHT
  engine = Unique(Engine)
  body = Unique(Body)
  wheels = Collection(Wheel, number=4)
  transmission = Reused(Transmission)

#Двигатель
class Engine:
  type = ENGINE_PETROL
  volume = 1.6
  transmission = Reused(Transmission)
  **chassis = Uplink()**

**Engine.chassis.linksTo(Chassis, Chassis.engine)**

#Колесо
class Wheel:
  radius = 15
  type = WHEEL_STAMPED
  transmission = Reused(Transmission)
  **chassis = Uplink()**

**Wheel.chassis.linksTo(Chassis, Chassis.wheels)**

#Кузов
class Body:
  type = BODY_SEDAN
  number = Random() # Мы заранее не знаем номер кузова. Хотим генерить его случайным образом в момент сборки автомобиля.
  spoiler = Maybe(Unique(Spoiler)) # Спойлер можно установить как опцию. По-умолчанию отсутствует.
  **chassis = Uplink()**

**Body.chassis.linksTo(Chassis, Chassis.body)**

#Коробка передач
class Transmission:
  type = TRANSMISSION_MANUAL
  **chassis = Uplink()**
  **engine = Uplink()**

**Transmission.chassis.linksTo(Chassis, Chassis.transmission)**
**Transmission.engine.linksTo(Engine, Engine.transmission)**

#Спойлер
class Spoiler:
  foo = None
  **body = Uplink()**

**Spoiler.body.linksTo(Body, Body.spoiler)**

Теперь при вызове Builder(typeToBuild).build() мы сможем собрать автомобиль начиная с любой детали! При этом:

>>>engine = Builder(Engine).build()
>>>engine.transmission.chassis.engine == engine
True
>>>engine.transmission.chassis.wheels[0].transmission.engine == engine
True

Билдер, проходя по связям (в том числе и Uplink'ам), будет создавать все необходимые объекты, что упрощает использование модели.
Например, если вы используете объект типа Engine, вам удобнее использовать именно Builder(Engine), но при этом будут созданы и связаны друг с другом все необходимые объекты.

Итак, мы посмотрели как можно удобно описывать объектную модель нашей тестируемой системы, построив с помощью нее целый автомобиль. Но этот автомобиль все еще в базовой комплектации.

Модификаторы

Для модификации базовой конфигурации графа наших объектов в библиотеке ObjectBuilders предусмотрено несколько типов модификаторов. Как уже упоминалось выше, модификаторы делятся на два типа: InstanceModifier и ConstructModifier.

  • InstanceModifier позволяет изменять поля объектов, которые представлены значениями, а также выполнять какие-то действия над готовыми объектами.
  • ConstructModifier позволяет изменять параметры объектов Constructs, которые присвоены каким-то полям наших классов.

Рассмотрим список всех модификаторов и их возможности (ниже будет на примерах продемонстрирована их работа).

class InstanceModifier(classToRunOn)

Собственно первый и единственный (пока) InstanceModifier level модификатор. Позволяет менять значения полей объектов или выполнять над ними какие-то действия. При вызове Builder.build() будет выполнятся над каждым из объектов типа classToRunOn в нашем графе объектов.

Имеет два метода.

  • def thatSets(self, **kwargs) — позволяет изменять значения полей объектов. В качестве аргументов передаются пары key=value, где key — название поля в объекте типа classToRunOn, которому надо присвоить значение value.
  • def thatDoes(self, action) — позволяет выполнить некое действие над объектами типа classToRunOn. action — метод, принимающий в качестве аргумента объект типа classToRunOn.

class Enabled(what)

ConstructModifier level модификатор. Позволяет перевести Maybe конструкцию в активное состояние, чтобы при вызове Builder.build() она начала создавать объект. what — Maybe конструкция. Например Body.spoiler в нашем примере про автомобиль.

class Given(construct, value)

ConstructModifier level модификатор. Позволяет на этапе создания графа заменить любой Construct (what) на конкретный объект или значение (value).

class HavingIn(what, *instances)

ConstructModifier level модификатор. Применяется на Collection конструкциях (what), позволяя добавить в них конкретные объекты (*instances).
Либо, если один из элементов *instances является int, то размер коллекции увеличится на значение этого int. При этом кол-во генерируемых Collection конструкцией объектов уменьшится на кол-во явно добавленных нами объектов.

class NumberOf(what, amount)

ConstructModifier level модификатор. Применяется на Collection конструкциях (what), позволяя изменить их размер на amount.

class OneOf(what, *modifiers)

ConstructModifier level модификатор. Применяется на Collection конструкциях (what), позволяя применить набор модификаторов *modifiers на одном из объектов коллекции. Собственно, на текущий момент — это весь набор доступных модификаторов. Они позволяют удобно описывать почти любые модификации базовой конфигурации нашего графа.

Модификаторы применяются к нашему графу объектов на этапе сборки посредством вызова метода Builder.withA(*modifiers).

Рассмотрим работу модификаторов на примерах.

Пример 1. Хотим автомобиль со спойлером!

spoiler_option = Enabled(Body.spoiler)
car = Builder(Chassis).withA(spoiler_option).build()

>>>car.body.spoiler is not None
True

Пример 2. Хотим автомобиль с дизельным шестилитровым двигателем!

big_diesel = InstanceModifier(Engine).thatSets(type=ENGINE_DIESEL, volume=6.0)
car = Builder(Chassis).withA(big_diesel).build()

>>>car.engine.volume == 6.0
True
>>>car.engine.type == ENGINE_DIESEL
True<source>

То же самое можно сделать с помощью InstanceModifier.thatDoes():
<source lang="python">
def make_big_engine(engine):
  engine.type = ENGINE_DIESEL
  engine.volume = 6.0

big_diesel = InstanceModifier(Engine).thatDoes(make_big_engine)

Обычно, этот используется если параметры необходимо как-то динамически вычислить на этапе выполнения теста, так как в методе make_big_engine можно провести любые вычисления или реализовать любые условия.

Пример 3. Хотим 6 колес и тяжелую платформу.


six_wheeled_heavy_chassis = [NumberOf(Chassis.wheels, 6), 
                             InstanceModifier(Chassis).thatSets(Chassis.type=CHASSIS_HEAVY)]
car = Builder(Chassis).withA(six_wheeled_heavy_chassis).build()

>>>len(car.chassis.wheels) == 6
True
>>>car.chassis.type == CHASSIS_HEAVY
True

Здесь стоит обратить внимание на то, что метод Builder.withA() может принимать как один объект Modifier, массив таких объектов, либо массив с вложенными массивами любой глубины.
Пример 4. Хотим мощный вездеход с большим кузовом и кучей колес.


rover_capabilities = [big_diesel] + 
                     six_wheeled_heavy_chassis + 
                     [InstanceModifier(Body).thatSets(type=BODY_HEAVY)]

rover = Builder(Chassis).withA(rover_capabilities).build()

Ключевой момент — мы переиспользовали уже написанные нами модификаторы big_diesel и six_wheeled_heavy_chassis. Так и в реальной жизни — чем больше тестов вы пишете, тем больше у вас будет готовых модификаторов. Это дает три плюса:

  1. Вы сможете переиспользовать модификаторы и писать тесты быстрее, думая о том как тестировать, а не о том как подготовить нужные данные
  2. Правильные названия модификаторов позволяют легко понять, какие именно данные используются в тесте и как именно они настраиваются
  3. При изменениях в конфигурации системы (например разработчики сделали фичу, в которой меняются связи между объектами) сможете легко чинить все тесты в одном месте, починив только модификаторы, которые стали неправильными!

Пример 5. Хотим проверить как вездеход поедет, если одно из колес будет иметь радиус 14", одно 16", а 4 других — 15".

def wheel_radius(radius):
  return OneOf(Chassis.wheels, InstanceModifier(Wheel).thatSets(radius=radius))

car = Builder(Chassis).withA(rover_capabilities)
                      .withA(wheel_radius(14), wheel_radius(16))
                      .build()

Пример 6. У нас остались 2 последних незадействованных типа модификаторов: HavingIn и Given. Отличаются они только тем, что HavingIn применяется на конструкции Collection, а Given на всех остальных конструкциях, чтобы поместить готовый объект вместо них.

Рассмотрим только HavingIn на примере. Допустим, у нас уже осталось одно колесо от прошлого автомобиля и мы хотим его поставить на новый.

wheel = Wheel()
car = Builder(Chassis).withA(HavingIn(Chassis.wheels, wheel)).build()

Модификатор Given работает аналогично.

НО: нужно быть аккуратным с использованием этих модификаторов, так как передавая туда готовый объект, в нем не будут уже создаваться никакие связи или что-то изменяться, он будет подставлен в граф as-is.

Рассмотрим алгоритм работы вызова Builder(typeToBuild).withA(*modifiers).build():

  1. Создается текущий объект типа typeToBuild
  2. При наличии Construct модификаторов в списке *modifiers, они применяются на Construct объекты соответствующего типа в свойствах текущего объекта
  3. Все объекты Construct в свойствах текущего объекта конвертируются в объекты, в соответствие с правилами каждого отдельного Construct
  4. При наличии InstanceModifier(currentObjectType).thatSets() в списке *modifiers, они применяются к текущему объекту
  5. Рекурсивно спускаемся во все объекты, полученные из сконвертированных Construct'ов, и повторяем алгоритм с п.2
  6. При наличии InstanceModifier(clazz).thatDoes() в списке *modifiers, они применяются объектам типа clazz в построенном графе

Вот и всё! С помощью этой простой библиотеки мы смогли сильно облегчить себе жизнь и сэкономить много человеко-часов работы.

Итог: что мы имеем и зачем всё это надо?

  1. ObjectBuilders предоставляет возможность легко и в читаемом виде описывать свойства данных, описываемых объектной моделью, и динамически генерировать эти данные.
  2. Логика связей между объектами и их модификации выделена в отдельный логический уровень, что позволяет переиспользовать описанные ранее конфигурации данных и в случае необходимости легко чинить логику связей и конфигурации данных в одном месте, вместо того чтобы чинить это во всех местах, где были использованы данные.
  3. Теперь в тестах можно описывать только свойства действительно влияющие на поведение теста, не заботясь о всех необходимых «побочных» настройках и объектах, что позволяет легко понимать связь между данными и поведением системы (проверками в тесте).

Однако у нашего подхода на текущем этапе реализации есть свои минусы:

  • В случае больших объектных моделей становится довольно трудоемко правильно описывать все связи.
  • По мере накопления большого объема «пакетов» модификаторов, увеличивается соблазн написать свои, чем переиспользовать существующие.
  • При одновременном применении большого набора модификаторов к графу сложно заметить/найти несоответствия данных в случае ошибки.

Первый минус не так критичен, поскольку обычно модель либо меняется очень редко, либо меняется совсем незначительно. Второй минус присущ любому развивающемуся куску кода (любители копи-паста есть везде). Третий минус постепенно сводится на нет по мере накопления готовых (читай «отлаженных») пакетов модификаторов, к тому же несоответствия все равно станут видны во время выполнения теста над полученными данными.

Даже с учетом всех недостатков, наш подход экономит время при написании тестов, поддержке тестов, необходимости разобраться как именно настраивается то или иное поведение системы, зависящее от данных. Вообще всё, что было сказано ранее, говорилось именно об объектах в памяти при запуске Python, не имеющих ничего общего с данными реально попадающими в систему. Но в этом-то и плюс — можно реализовать различные алгоритмы непосредственно создания нагенерированных данных в тестируемой системе, либо использовать эти данные для других нужд.

Например, мы реализовали прослойку, позволяющую через XMLRPC API или SQL Alchemy загружать наши сгенерированные данные в тестируемую систему. Мы также используем ObjectBuilders для генерации данных для серверных запросов в форматах JSON и XML. Есть довольно широкий спектр использования ObjectBuilders, так как это довольно абстрактный механизм оперирования данными. Очень надеемся, что кто-то из читателей найдет этот инструмент интересным и полезным и присоединится к его разработке. Мы всегда рады новым идеям и предложениям.

Код проекта на Github.

Пользуйтесь на здоровье!

Автор: dchr

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js