В TDD есть 3 фазы: организовать, действовать и утверждать (данно, когда, тогда в BDD). Фаза утверждения имеет отличную инструментальную поддержку. Возможно, вы знакомы с AssertJ, FEST-Assert или Hamcrest. Это контрастирует с этапом аранжировки. Хотя систематизация тестовых данных часто является сложной задачей и ей обычно посвящена значительная часть теста, трудно указать инструмент, который ее поддерживает.
Test Arranger пытается восполнить этот пробел, организуя экземпляры классов, необходимых для тестов. Экземпляры заполняются псевдослучайными значениями, которые упрощают процесс создания тестовых данных. Тестер лишь объявляет типы нужных объектов и получает совершенно новые экземпляры. Если псевдослучайное значение для данного поля недостаточно, вручную необходимо задать только это поле:
Product product = Arranger . some ( Product . class );
product . setBrand ( "Ocado" );
< dependency >
< groupId >com.ocadotechnology.gembus groupId >
< artifactId >test-arranger artifactId >
< version >1.6.3 version >
dependency >
testImplementation ' com.ocadotechnology.gembus:test-arranger:1.6.3 '
Класс Arranger имеет несколько статических методов для генерации псевдослучайных значений простых типов. Каждый из них имеет функцию переноса, упрощающую вызовы для Kotlin. Некоторые из возможных вызовов перечислены ниже:
Ява | Котлин | результат |
---|---|---|
Arranger.some(Product.class) | some | экземпляр Product со всеми полями, заполненными значениями |
Arranger.some(Product.class, "brand") | some | экземпляр Product без значения для поля бренда |
Arranger.someSimplified(Category.class) | someSimplified | экземпляр категории, поля коллекции типов имеют размер уменьшенный до 1, а глубина дерева объектов ограничена до 3 |
Arranger.someObjects(Product.class, 7) | someObjects | поток экземпляров Product размером 7 |
Arranger.someEmail() | someEmail() | строка, содержащая адрес электронной почты |
Arranger.someLong() | someLong() | псевдослучайное число типа long |
Arranger.someFrom(listOfCategories) | someFrom(listOfCategories) | запись из списка listOfCategories |
Arranger.someText() | someText() | строка, сгенерированная из цепи Маркова; по умолчанию это очень простая цепочка, но ее можно переконфигурировать, поместив другой файл enMarkovChain в тестовый путь к классам с альтернативным определением. Вы можете найти один, обученный на английском корпусе, здесь; формат файла указан в включенном в проект файле enMarkovChain. |
- | some | экземпляр Product со всеми полями, заполненными случайными значениями, за исключением name , для которого установлено значение «не так случайно», этот синтаксис можно использовать для установки столько полей объекта, сколько необходимо, но каждый из объектов должен быть изменяемым |
Полностью случайные данные могут подходить не для каждого тестового примера. Часто существует хотя бы одно поле, которое имеет решающее значение для цели теста и требует определенного значения. Если организованный класс является изменяемым, или это класс данных Kotlin, или есть способ создать измененную копию (например, @Builder(toBuilder = true) Lombok), тогда просто используйте то, что доступно. К счастью, даже если это невозможно настроить, вы можете использовать Test Arranger. Существуют специальные версии методов some()
и someObjects()
, которые принимают параметр типа Map
. Ключи на этой карте представляют собой имена полей, в то время как соответствующие поставщики предоставляют значения, которые организатор тестирования установит для вас в этих полях, например:
Product product = Arranger . some ( Product . class , Map . of ( "name" , () -> value ));
По умолчанию случайные значения генерируются в соответствии с типом поля. Случайные значения не всегда хорошо соответствуют инвариантам классов. Если сущность всегда необходимо упорядочить в соответствии с некоторыми правилами, касающимися значений полей, вы можете предоставить собственный органайзер:
class ProductArranger extends CustomArranger < Product > {
@ Override
protected Product instance () {
Product product = enhancedRandom . nextObject ( Parent . class );
product . setPrice ( BigDecimal . valueOf ( Arranger . somePositiveLong ( 9_999L )));
return product ;
}
}
Чтобы иметь контроль над процессом создания экземпляра Product
нам нужно переопределить метод instance()
. Внутри метода мы можем создать экземпляр Product
так, как захотим. В частности, мы можем генерировать некоторые случайные значения. Для удобства в классе CustomArranger
есть enhancedRandom
поле Random. В данном примере мы генерируем экземпляр Product
со всеми полями, имеющими псевдослучайные значения, но затем меняем цену на что-то приемлемое в нашем домене. Это не отрицательное число и меньше числа 10 тыс.
ProductArranger
автоматически (с использованием отражения) подхватывается Arranger и используется всякий раз, когда запрашивается новый экземпляр Product
. Это касается не только прямых вызовов, таких как Arranger.some(Product.class)
, но и косвенных. Предположим, что существует класс Shop
с полевыми products
типа List
. При вызове Arranger.some(Shop.class)
аранжировщик будет использовать ProductArranger
для создания всех продуктов, хранящихся в Shop.products
.
Поведение организатора тестов можно настроить с помощью свойств. Если вы создадите файл arranger.properties
и сохраните его в корне пути к классам (обычно это каталог src/test/resources/
), он будет выбран и будут применены следующие свойства:
arranger.root
Пользовательские аранжировщики подбираются с помощью отражения. Все классы, расширяющие CustomArranger
, считаются пользовательскими аранжировщиками. Отражение сосредоточено на определенном пакете, которым по умолчанию является com.ocado
. Это не обязательно удобно для вас. Однако с помощью arranger.root=your_package
его можно изменить на your_package
. Постарайтесь сделать пакет как можно более конкретным, поскольку наличие чего-то общего (например, просто com
, который является корневым пакетом во многих библиотеках), приведет к сканированию сотен классов, что займет заметное время.arranger.randomseed
По умолчанию всегда одно и то же начальное значение используется для инициализации основного генератора псевдослучайных значений. Как следствие, последующие выполнения будут генерировать те же значения. Чтобы добиться случайности во всех запусках, т. е. чтобы всегда начинать с других случайных значений, необходимо установить arranger.randomseed=true
.arranger.cache.enable
Процесс упорядочения случайных экземпляров требует некоторого времени. Если вы создаете большое количество экземпляров и вам не нужно, чтобы они были полностью случайными, включение кэша может быть подходящим вариантом. Если эта опция включена, в кэше сохраняется ссылка на каждый случайный экземпляр, и в какой-то момент организатор тестов перестает создавать новые и вместо этого повторно использует кэшированные экземпляры. По умолчанию кэш отключен.arranger.overridedefaults
Организатор тестов учитывает инициализацию поля по умолчанию, т.е. когда существует поле, инициализированное пустой строкой, экземпляр, возвращаемый организатором тестов, содержит пустую строку в этом поле. Не всегда это то, что нужно в тестах, особенно, когда в проекте есть соглашение инициализировать поля пустыми значениями. К счастью, вы можете заставить организатора тестирования перезаписать значения по умолчанию случайными значениями. Установите для arranger.overridedefaults
значение true, чтобы переопределить инициализацию по умолчанию.arranger.maxRandomizationDepth
Некоторые структуры тестовых данных могут генерировать цепочки объектов любой длины, которые ссылаются друг на друга. Однако для эффективного использования их в тестовых примерах крайне важно контролировать длину этих цепочек. По умолчанию Тест-организатор прекращает создание новых объектов на 4-м уровне глубины вложенности. Если эта настройка по умолчанию не подходит для тестовых случаев вашего проекта, ее можно настроить с помощью этого параметра. Если у вас есть запись Java, которую можно использовать в качестве тестовых данных, но вам нужно изменить одно или два ее поля, класс Data
с его методом копирования предоставляет решение. Это особенно полезно при работе с неизменяемыми записями, у которых нет очевидного способа напрямую изменить их поля.
Метод Data.copy
позволяет создать неполную копию записи, выборочно изменяя нужные поля. Предоставляя карту переопределений полей, вы можете указать поля, которые необходимо изменить, и их новые значения. Метод копирования заботится о создании нового экземпляра записи с обновленными значениями полей.
Такой подход избавляет вас от необходимости вручную создавать новый объект записи и задавать поля по отдельности, предоставляя удобный способ генерировать тестовые данные с небольшими отклонениями от существующих записей.
В целом, класс Data и его метод копирования спасают ситуацию, позволяя создавать поверхностные копии записей с измененными выбранными полями, обеспечивая гибкость и удобство при работе с неизменяемыми типами записей:
Data . copy ( myRecord , Map . of ( "recordFieldName" , () -> "altered value" ));
При тестировании программного проекта редко возникает впечатление, что его нельзя сделать лучше. Что касается организации тестовых данных, мы пытаемся улучшить две области с помощью Test Arranger.
Тесты гораздо легче понять, если знать намерения создателя, т. е. почему тест был написан и какие проблемы он должен обнаружить. К сожалению, нередко можно увидеть тесты, имеющие в разделе Arrange (Данные) операторы, подобные следующему:
Product product = Product . builder ()
. withName ( "Some name" )
. withBrand ( "Some brand" )
. withPrice ( new BigDecimal ( "12.99" ))
. withCategory ( "Water, Juice & Drinks / Juice / Fresh" )
...
. build ();
Глядя на такой код, трудно сказать, какие значения актуальны для теста, а какие предоставляются только для удовлетворения некоторых ненулевых требований. Если тест про бренд, почему бы не написать так:
Product product = Arranger . some ( Product . class );
product . setBrand ( "Some brand" );
Теперь очевидно, что бренд важен. Попробуем сделать еще один шаг вперед. Весь тест может выглядеть следующим образом:
//arrange
Product product = Arranger . some ( Product . class );
product . setBrand ( "Some brand" );
//act
Report actualReport = sut . createBrandReport ( Collections . singletonList ( product ))
//assert
assertThat ( actualReport . getBrand ). isEqualTo ( "Some brand" )
Сейчас мы тестируем, что отчет создан для бренда «Некоторые бренды». Но разве это цель? Разумнее ожидать, что отчет будет сформирован для того же бренда, которому присвоен данный товар. Итак, что мы хотим протестировать:
//arrange
Product product = Arranger . some ( Product . class );
//act
Report actualReport = sut . createBrandReport ( Collections . singletonList ( product ))
//assert
assertThat ( actualReport . getBrand ). isEqualTo ( product . getBrand ())
Если поле бренда является изменяемым и мы опасаемся, что sut
может изменить его, мы можем сохранить его значение в переменной перед переходом к фазе действия и позже использовать его для утверждения. Испытание продлится дольше, но намерение остается ясным.
Примечательно, что то, что мы только что сделали, — это применение шаблонов «Сгенерированная ценность» и, в некоторой степени, «Метод создания», описанных в книге «Тестовые шаблоны xUnit: Рефакторинг тестового кода» Джерарда Месароса.
Вы когда-нибудь меняли малейшую деталь в производственном коде и получали ошибки в дюжине тестов? Некоторые из них сообщают о неудачном утверждении, некоторые, возможно, даже отказываются компилировать. Это запах кода хирургии дробовика, который только что выстрелил в ваши невинные тесты. Ну, может быть, не так уж и безобидно, поскольку их можно было бы спроектировать по-другому, чтобы ограничить побочный ущерб, вызванный крошечными изменениями. Давайте проанализируем это на примере. Предположим, у нас в домене есть следующий класс:
class TimeRange {
private LocalDateTime start ;
private long durationinMs ;
public TimeRange ( LocalDateTime start , long durationInMs ) {
...
и что он используется во многих местах. Особенно в тестах без Test Arranger с использованием таких операторов: new TimeRange(LocalDateTime.now(), 3600_000L);
Что произойдет, если по каким-то важным причинам мы будем вынуждены изменить класс на:
class TimeRange {
private LocalDateTime start ;
private LocalDateTime end ;
public TimeRange ( LocalDateTime start , LocalDateTime end ) {
...
Довольно сложно придумать серию рефакторинга, который преобразует старую версию в новую, не нарушив при этом все зависимые тесты. Более вероятен сценарий, когда тесты один за другим подстраиваются под новый API класса. Это означает много не совсем увлекательной работы со множеством вопросов относительно желаемого значения длительности (нужно ли аккуратно конвертировать его в end
типа LocalDateTime или это было просто удобное случайное значение). Жизнь была бы намного проще с Test Arranger. Когда во всех местах, требующих не null TimeRange
у нас есть Arranger.some(TimeRange.class)
, это так же хорошо для новой версии TimeRange
, как и для старой. Это оставляет нас с теми немногими случаями, которые требуют не случайного TimeRange
, но поскольку мы уже используем Test Arranger для выявления намерения теста, в каждом случае мы точно знаем, какое значение следует использовать для TimeRange
.
Но это не все, что мы можем сделать для улучшения тестов. Предположительно, мы можем идентифицировать некоторые категории экземпляра TimeRange
, например, диапазоны из прошлого, диапазоны из будущего и активные в настоящее время диапазоны. TimeRangeArranger
— отличное место для организации следующего:
class TimeRangeArranger extends CustomArranger < TimeRange > {
private final long MAX_DISTANCE = 999_999L ;
@ Override
protected TimeRange instance () {
LocalDateTime start = enhancedRandom . nextObject ( LocalDateTime . class );
LocalDateTime end = start . plusHours ( Arranger . somePositiveLong ( MAX_DISTANCE ));
return new TimeRange ( start , end );
}
public TimeRange fromPast () {
LocalDateTime now = LocalDateTime . now ();
LocalDateTime end = now . minusHours ( Arranger . somePositiveLong ( MAX_DISTANCE ));
return new TimeRange ( end . minusHours ( Arranger . somePositiveLong ( MAX_DISTANCE )), end );
}
public TimeRange fromFuture () {
LocalDateTime now = LocalDateTime . now ();
LocalDateTime start = now . plusHours ( Arranger . somePositiveLong ( MAX_DISTANCE ));
return new TimeRange ( start , start . plusHours ( Arranger . somePositiveLong ( MAX_DISTANCE )));
}
public TimeRange currentlyActive () {
LocalDateTime now = LocalDateTime . now ();
LocalDateTime start = now . minusHours ( Arranger . somePositiveLong ( MAX_DISTANCE ));
LocalDateTime end = now . plusHours ( Arranger . somePositiveLong ( MAX_DISTANCE ));
return new TimeRange ( start , end );
}
}
Такой метод создания не должен создаваться заранее, а должен соответствовать существующим тестовым примерам. Тем не менее, есть вероятность, что TimeRangeArranger
будет охватывать все случаи, когда экземпляры TimeRange
создаются для тестов. Как следствие, вместо вызовов конструктора с несколькими загадочными параметрами мы имеем аранжировщик с хорошо названным методом, объясняющим доменное значение создаваемого объекта и помогающим понять намерение теста.
При обсуждении задач, решаемых Test Arranger, мы выделили два уровня создателей тестовых данных. Для полноты картины необходимо упомянуть хотя бы еще об одном – светильниках. В целях обсуждения мы можем предположить, что Fixture — это класс, предназначенный для создания сложных структур тестовых данных. Пользовательский аранжировщик всегда ориентирован на один класс, но иногда вы можете наблюдать в своих тестовых примерах повторяющиеся совокупности двух или более классов. Это может быть Пользователь и его банковский счет. Для каждого из них может быть свой CustomArranger, но зачем игнорировать тот факт, что они часто собираются вместе. Вот тогда нам и следует задуматься о приспособлении. Он будет отвечать за создание как Пользователя, так и Банковского аккаунта (предположительно с использованием специальных пользовательских аранжировщиков) и их связывание. Фикстуры подробно описаны, включая несколько вариантов реализации, в книге «Тестовые шаблоны xUnit: рефакторинг тестового кода» Джерарда Месароса.
Итак, у нас есть три типа строительных блоков в тестовых классах. Каждый из них можно рассматривать как аналог концепции (строительный блок предметно-ориентированного проектирования) из производственного кода:
На первый взгляд есть примитивы и простые объекты. Это проявляется даже в самых простых модульных тестах. Вы можете организовать такие тестовые данные с помощью методов someXxx
из класса Arranger
.
Таким образом, у вас могут быть службы, требующие тестов, которые работают исключительно с экземплярами User
или как с User
, так и с другими классами, содержащимися в классе User
, например список адресов. Для решения таких случаев обычно требуется специальный аранжировщик, например UserArranger
. Он создаст экземпляры User
с учетом всех ограничений и инвариантов классов. Более того, он выберет AddressArranger
, если он существует, чтобы заполнить список адресов действительными данными. Если для нескольких тестовых случаев требуется определенный тип пользователя, например бездомные пользователи с пустым списком адресов, в UserArranger можно создать дополнительный метод. Как следствие, всякий раз, когда для тестов потребуется создать экземпляр User
, достаточно будет заглянуть в UserArranger
и выбрать подходящий фабричный метод или просто вызвать Arranger.some(User.class)
.
Самый сложный случай касается тестов, зависящих от больших структур данных. В электронной коммерции это может быть магазин, содержащий множество товаров, а также учетные записи пользователей с историей покупок. Организация данных для таких тестовых случаев обычно является нетривиальной задачей, и повторять такую операцию было бы неразумно. Гораздо лучше хранить его в специальном классе под хорошо названным методом, например shopWithNineProductsAndFourCustomers
, и повторно использовать в каждом из тестов. Мы настоятельно рекомендуем использовать соглашение об именах для таких классов. Чтобы их было легко найти, мы предлагаем использовать постфикс Fixture
. В итоге у нас может получиться что-то вроде этого:
class ShopFixture {
Repository repo ;
public void shopWithNineProductsAndFourCustomers () {
Arranger . someObjects ( Product . class , 9 )
. forEach ( p -> repo . save ( p ));
Arranger . someObjects ( Customer . class , 4 )
. forEach ( p -> repo . save ( p ));
}
}
Новейшая версия организатора тестов скомпилирована с использованием Java 17 и должна использоваться в среде выполнения Java 17+. Однако существует также ветка Java 8 для обратной совместимости, охватываемая версиями 1.4.x.