Добрый день!
При unit-тестировании часто сталкиваешься с необходимостью заполнять сложные объекты, чтобы возвращать их со стороны заглушек или наоборот — давать их на вход методам и тестам. Некоторые разработчики игнорируют get-set конвенции Java, а даже если геттеры и сеттеры есть, то заполнение объекта достаточно сложной структуры порой требует больше кода, чем сам тест. Это анти-паттерн Excessive Setup, и хочется научиться с ним бороться. В этой статье я расскажу, как с помощью библиотеки PODAM заполнять объекты быстро и красиво, продолжая идеи разумной рандомизации как входных данных для тестов, так и данных, возвращаемых заглушками — покажу на примерах, пороюсь в исходниках.
Итак, чтобы долго не думать, но и не заниматься миром животных, сгенерим страну. Примитивную, но достаточную для демонстрации.

1. Модель


Страна будет состоять из наименования, национальной валюты и городов.

public class Country {
	private String name;
	private Currency currency;
	private List<City> cities;	
	
	public Country() {		
		setCities(new ArrayList<City>());
	}
	//... тут getters и setters
}

Города будут состоять из наименования, количества жителей и списка улиц:

public class City {
	private String name;
	private int population;
	private List<Street> streets;	

	public City(String name) {
		this.name = name;
	}
	//... тут getters и setters
}

Улицы с наименованиями

public class Street {
	private String name;
	
	public Street(String name) {
		this.name = name;
	}
	//... тут getters и setters
}

и валюта (для примера генерации с enum-ами — безусловно так не работают с валютами :) )

public enum Currency {
	RUB,
	EUR,
	USD;
}

Итак, модель готова.

2. Вспомогательный класс для красивого вывода результатов


Прежде чем генерить страну я позволю себе отклониться немного в сторону и создам свой RecursiveToStringStyle взамен тому, что есть у Apache (commons-lang3-3.4.jar), чтобы с помощью ReflectionToStringBuilder выводить получаемые с помощью PODAM данные.
Класс RecursiveToStringStyle
public class RecursiveToStringStyle extends ToStringStyle {
	private static final long serialVersionUID = 1L;
	private int offset;

	public RecursiveToStringStyle() {
		this(0);
	}

	private RecursiveToStringStyle(int offset) {
		setUseShortClassName(true);
		setUseFieldNames(true);
		setUseIdentityHashCode(false);
		this.offset = offset;
		String off = "";
		for (int i = 0; i < offset; i++)
			off += "\t";
		this.setContentStart("[");
		this.setFieldSeparator(SystemUtils.LINE_SEPARATOR + off + "  ");
		this.setFieldSeparatorAtStart(true);
		this.setContentEnd(SystemUtils.LINE_SEPARATOR + off + "]");
	}

	protected void appendDetail(StringBuffer buffer, String fieldName,
			Collection<?> col) {
		buffer.append('[');
		for (Object obj : col) {
			buffer.append(ReflectionToStringBuilder.toString(obj,
					new RecursiveToStringStyle(offset + 1)));
			buffer.append(',');
		}
		if (buffer.charAt(buffer.length() - 1) == ',')
			buffer.setCharAt(buffer.length() - 1, ']');
	}

	protected void appendDetail(StringBuffer buffer, String fieldName,
			Object value) {
		if (value instanceof String) {
			buffer.append("\"" + value.toString() + "\"");
		} else if (value instanceof BigDecimal) {
			buffer.append(value.getClass().getSimpleName() + "["
					+ value.toString() + "]");
		} else if (value instanceof BigInteger) {
			buffer.append(value.getClass().getSimpleName() + "["
					+ value.toString() + "]");
		} else if (!value.getClass().getName().startsWith("java.lang.")) {
			try {
				buffer.append(ReflectionToStringBuilder.toString(value,
						new RecursiveToStringStyle(offset + 1)));
			} catch (Throwable t) {
			}
		} else {
			super.appendDetail(buffer, fieldName, value);
		}
	}
}



3. Генерация


public class CountryCreatorSimple {
	public static void main(String[] args) {
		/** Создаём фабрику */
		PodamFactory factory = new PodamFactoryImpl();
		/** Генерим страну */
		Country myPojo = factory.manufacturePojo(Country.class);
		/** "Печатаем" страну */
		System.out.println(ReflectionToStringBuilder.toString(myPojo,new RecursiveToStringStyle()));
	}
}

Вот собственно и всё. В myPojo полноценная страна — получилось много буков, — так что желающие могут
развернуть результат
Country[
  name="2n_BNdJOpE"
  currency=Currency[
	  name="USD"
	  ordinal=2
	]
  cities=[City[
	  name="7_BmoRTDab"
	  population=-1863637717
	  streets=[Street[
		  name="XV_q7SPbvk"
		],Street[
		  name="GkNGKj6B9J"
		],Street[
		  name="y9GNakRAsW"
		],Street[
		  name="Mwo09nQx0R"
		],Street[
		  name="n4_EDMGNUR"
		]]
	],City[
	  name="1sifHwujvo"
	  population=1832262487
	  streets=[Street[
		  name="xpZiJH2sce"
		],Street[
		  name="ns8DRJDi4e"
		],Street[
		  name="7Ijv_UVZrF"
		],Street[
		  name="CYruDEhe2M"
		],Street[
		  name="4HFzN0v5mc"
		]]
	],City[
	  name="qJlUWEPoxp"
	  population=1979728140
	  streets=[Street[
		  name="_LbqmCPgWC"
		],Street[
		  name="yS6jX8vRqI"
		],Street[
		  name="yFysWkntdh"
		],Street[
		  name="RvP93uJphY"
		],Street[
		  name="WjARSGWfxB"
		]]
	],City[
	  name="W1J9mWpEFH"
	  population=493149274
	  streets=[Street[
		  name="8bFRRbPmqO"
		],Street[
		  name="ORJ4rP1i41"
		],Street[
		  name="qD9XU0I0K2"
		],Street[
		  name="I75Wt5cK9v"
		],Street[
		  name="viT8t5FkPq"
		]]
	],City[
	  name="33cPIh6go9"
	  population=693664641
	  streets=[Street[
		  name="kvPtj1GIL4"
		],Street[
		  name="aVv1taDA0j"
		],Street[
		  name="iQ6ZriwuZK"
		],Street[
		  name="fcf6JICEQ9"
		],Street[
		  name="1Pbdnc_7R6"
		]]
	]]
]


В нашей стране со странным названием «2n_BNdJOpE» и национальной валютой USD есть города с не менее странными названиями, порой отрицательной численностью населения и с улицами, которые страшно произносить вслух. Этого может быть вполне достаточно для многих вариантов unit-тестирования, но я решил посмотреть насколько глубока кроличья нора.

4. Собственная стратегия генерации


На официальном сайте предлагают имплементировать интерфейс DataProviderStrategy, но там оказывается 23 метода на каждый возвращаемый тип, на размер коллекции и т.д. Возможно, есть желающие и кому-то даже нужно будет, но для демонстрации хотелось найти что-то попроще — заглянул в исходники в поисках того, какая стратегия реально использовалась в предыдущем пункте — оказалось RandomDataProviderStrategy, но она public final class. Зато наследуется от AbstractRandomDataProviderStrategy — BINGO.
Приступаем к созданию собственной стратегии.

Хотим, например:
1. Один или два города в нашей стране — не больше. А то результат смотрится громоздко :)
Перекрываем
@Override
public int getNumberOfCollectionElements(Class<?> type)

2. Хотим нормальные названия городов и улиц — это будут два enum-а со статическими методами возвращающими нам случайные элементы.
Плюс перекрываем
@Override
public String getStringValue(AttributeMetadata attributeMetadata)

3. Хотим население — не отрицательное, а, например от миллиона до 10 миллионов.
Перекрываем
@Override
public Integer getInteger(AttributeMetadata attributeMetadata)


Будем пользоваться тем, что AttributeMetadata содержит в себе два важных метода:

attributeMetadata.getAttributeName() /** название поля */
attributeMetadata.getPojoClass() /** класс, в котором это поле */


Понеслась (выбор городов случаен, выбор улиц — нагуглил некоторое количество улиц в Париже — сорри за форматирование enum — но не хотелось вертикально):

public class CountryDataProviderStrategy extends
		AbstractRandomDataProviderStrategy {
	private static final Random random = new Random(System.currentTimeMillis());

	public CountryDataProviderStrategy() {
		super();
	}

	@Override
	public String getStringValue(AttributeMetadata attributeMetadata) {
		/**
		 * Если поле name, то в зависимости от класса либо генерим улицу, либо
		 * город, либо страну
		 */
		if ("name".equals(attributeMetadata.getAttributeName())) {
			if (Street.class.equals(attributeMetadata.getPojoClass())) {
				return Streets.randomStreet();
			} else if (City.class.equals(attributeMetadata.getPojoClass())) {
				return Cities.randomCity();
			} else if (Country.class.equals(attributeMetadata.getPojoClass())) {
				return "Podam States of Mockitia";
			}
		}
		return super.getStringValue(attributeMetadata);
	};

	@Override
	public int getNumberOfCollectionElements(Class<?> type) {
		/**
		 * Если список городов, то вернём или 1 или 2. Если список улиц, то
		 * вернём от 1 до 10
		 */
		if (City.class.getName().equals(type.getName())) {
			return 1 + random.nextInt(2);
		} else if (Street.class.getName().equals(type.getName())) {
			return 1 + random.nextInt(10);
		}
		return super.getNumberOfCollectionElements(type);

	};

	@Override
	public Integer getInteger(AttributeMetadata attributeMetadata) {
		/** Ну и вернём разумное население */
		if (City.class.equals(attributeMetadata.getPojoClass())) {
			if ("population".equals(attributeMetadata.getAttributeName())) {
				return 1_000_000 + random.nextInt(9_000_000);
			}
		}
		return super.getInteger(attributeMetadata);
	}

	private enum Cities {
		MOSCOW, SAINT_PETERSBURG, LONDON, NEW_YORK, SHANGHAI, KARACHI, BEIJING, DELHI, PARIS, NAIROBI;

		private static final List<Cities> values = Collections.unmodifiableList(Arrays.asList(values()));
		private static final int size = values.size();
		private static final Random random = new Random();

		public static String randomCity() {
			return values.get(random.nextInt(size)).toString();
		}
	}

	private enum Streets {
		RUE_ABEL, RUE_AMPERE, AVENUE_PAUL_APPELL, BOULEVARD_ARAGO, JARDINS_ARAGO, SQUARE_ARAGO, RUE_ANTOINE_ARNAULD, SQUARE_ANTOINE_ARNAULD, RUE_BERNOULLI, RUE_BEZOUT, RUE_BIOT, RUE_BORDA, SQUARE_BOREL, RUE_CHARLES_BOSSUT, RUE_DE_BROGLIE, RUE_BUFFON, AVENUE_CARNOT, BOULEVARD_CARNOT, VILLA_SADI_CARNOT, RUE_CASSINI, RUE_CAUCHY, RUE_MICHEL_CHASLES, RUE_NICOLAS_CHUQUET, RUE_CLAIRAUT, RUE_CLAPEYRON, RUE_CONDORCET, RUE_CORIOLIS, RUE_COURNOT, RUE_GASTON_DARBOUX, RUE_DELAMBRE, SQUARE_DELAMBRE, RUE_DEPARCIEUX, RUE_DE_PRONY, RUE_DESARGUES, RUE_DESCARTES, RUE_ESCLANGON, RUE_EULER;

		private static final List<Streets> values = Collections.unmodifiableList(Arrays.asList(values()));
		private static final int size = values.size();
		private static final Random random = new Random();

		public static String randomStreet() {
			return values.get(random.nextInt(size)).toString();
		}
	}
}

Собираем всё воедино и запускаем генерацию:

public class CountryCreatotWithStrategy {
	public static void main(String[] args) {
		/** Создаём нашу стратегию генерации */
		DataProviderStrategy strategy = new CountryDataProviderStrategy();
		/** Создаём фабрику на основании этой стратегии */
		PodamFactory factory = new PodamFactoryImpl(strategy);
		/** Генерим страну */
		Country myPojo = factory.manufacturePojo(Country.class);
		/** Печатаем страну */
		System.out.println(ReflectionToStringBuilder.toString(myPojo,new RecursiveToStringStyle()));	
	}
}

Результат намного приятнее, чем с использованием полного рандома:

Country[
  name="Podam States of Mockitia"
  currency=Currency[
	  name="RUB"
	  ordinal=0
	]
  cities=[City[
	  name="NAIROBI"
	  population=9563403
	  streets=[Street[
		  name="RUE_BORDA"
		],Street[
		  name="RUE_DE_PRONY"
		],Street[
		  name="SQUARE_ANTOINE_ARNAULD"
		],Street[
		  name="RUE_CASSINI"
		],Street[
		  name="RUE_DE_PRONY"
		]]
	],City[
	  name="PARIS"
	  population=7602177
	  streets=[Street[
		  name="RUE_DESCARTES"
		],Street[
		  name="RUE_CHARLES_BOSSUT"
		]]
	]]
]


5. Заключение


1. На самом деле можно аннотациями @PodamStrategyValue снабжать сами поля. Для этого надо будет имплементировать интерфейс AttributeStrategy, создав тем самым стратегии для атрибутов. Можно ещё много всего замечательного, но это уже для тех, кого всё это чудо заинтересовало — на официальном сайте есть много туториалов.

2. Некоторые могут сказать, что проще засетить конкретные значения в объект, как бы это сложно ни было — я не соглашусь и спорить не буду, так что сразу предупреждаю об отказе от дачи комментариев на эту тему — PODAM использует reflection, а это доступ к полям с ограниченной видимостью и даже к константам (через PODAM правда не пробовал, но reflection константы берёт), а это открывает большие возможности не только для погенерить, но и для поломать, что незаменимо для автоматизации негативного тестирования.

3. Цель была победить Excessive Setup анти-паттерн. Думаю, отчасти это удалось. А в сочетании с Mockito, о котором уже написано много, unit-тестирование (впрочем, как и заглушечные куски функционального) становится сплошным удовольствием.

4. Конечно же, ссылка на git-овую репу с тем, что описано выше.

Комментарии (11)


  1. bachin
    10.04.2015 11:39
    +1

    КДПВ на полтора метра — худший антипаттерн


  1. TimReset
    10.04.2015 12:37
    +1

    Интересно было почитать — я подобное сам писал, когда нужно было POJO объект заполнить для проверки сериализации/десериализации. Заняло несколько десятков строк кода. А тут прямо серьёзная вещь, даже с поддержкой стратегий генерирования :)


    1. FranciscoSuarez Автор
      10.04.2015 14:10
      +1

      Рад, что понравилось :)


  1. 2r2w
    10.04.2015 13:50
    +2

    Прочитал заголовок как «продам java объекты для Unit-тестирования». Прям таки объявление. Хорошее название — podam.


  1. Encircled
    10.04.2015 15:07

    Использовать одну strategy для всех объектов — не очень удачное решение по-моему. На примере Country и City с парой полей, конечно, все будет хорошо, но если у вас будут объекты со сложными связями и генерировать надо псевдо-случайные связи — в этом strategy будет сущий ад из if-else.
    Конечно, можно создать несколько factory, но можно ли связать объект из одной с объектами из другой (не руками)?

    Или, например, в объект Country надо добавить только те City, которые проходят какое-то условие, это тоже решается через if-else?

    Так же, по всей видимости, забыты enumeration'ы, которые живут в дб, их тоже руками в strategy добавлять?

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


    1. FranciscoSuarez Автор
      10.04.2015 15:43
      +1

      Блин. Кто же говорит про одну стратегию для всех — это же просто пример использования. Просто инструмент — ООП в руки желающим, — выводите уровень абстракции стратегии на тот, который нужен. Наследуйте стратегии…

      Или, например, в объект Country надо добавить только те City, которые проходят какое-то условие, это тоже решается через if-else?

      Создаём фабрики — не вижу проблемы связать фабрики друг с другом. Да, универсального способа разруливать сложную логику связей не существует, — но для каждой конкретной задачи, если подумать, существует элегантное решение.
      Так же, по всей видимости, забыты enumeration'ы, которые живут в дб, их тоже руками в strategy добавлять?

      Если речь идёт о справочниках, сделайте стратегию для отдельного атрибута, — в ней реализуйте обращение к базе, получение того, что нужно и возвращения генератору.

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


      1. Encircled
        10.04.2015 16:07

        Я только за генерацию. Просто похоже на инструмент, который делает inject значения в объект (может сгенерить рандом), а все остальное «сделай сам».
        Или взять аннотации "@Podam...", это же жесть. Я понимаю, что не обязательно их использовать, но представьте это в domain model слое… Грубо нарушаем SRP, domain model зависит от тестовой dependency, domain model определяет поведение тестов.


  1. FranciscoSuarez Автор
    10.04.2015 16:56
    +1

    Он разбирает объект используя reflection, рекурсивно опускается до примитивов и коллекций. Аннотации — договорились — лучше не использовать. Если так уже надо работать с базой, можно это сделать на уровне своей абстракции.

    public abstract class MyAbstractDataProviderStrategy implements DataProviderStrategy

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

    пример полей объекта результата банковской авторизации
    	private Long amount = null;
    	private String currencyCode = null;
    	private Date date = null;
    	private String authCode = null;
    	private Long refNumber = null;
    	private Long hostTransId = null;
    	private Long cashTransId = null;
    	private BankCard card = null;
    	private Long operationCode = null;
    	private String terminalId = null;
    	private String merchantId = null;
    	private String responseCode = null;
    	private Long resultCode = null;
    	private boolean status = false;
    	private String message = null;
    	private List<List<String>> slips = null;
    	private boolean isPrintNegativeSlip = false;
    	private boolean isTransactionCancelled = false;
    	private String bankid = null;
    


    1. Encircled
      10.04.2015 17:30

      Понятно, что реализуемо, но надо доделывать самим… Простейший пример на связи, который мог бы помочь решать данный инструмент:
      1. Надо создать N объектов User и рандомно присвоить enum UserType
      2. Надо создать M объектов UserGroup, у которых есть enum UserGroupType. Каждой UserGroup надо добавить Users, но таких, чтобы UserType каким-то заданным образом соответствовал UserGroupType.

      Насколько я понял, в Podan это делается через отдельную стратегию для зависимости UserType-UserGroupType.

      В общем я не говорю, что Podan плох, или надо все делать через сеттеры, просто он умеет не все, что мог бы (имхо).


  1. madhead
    10.04.2015 22:32

    Использую для похожих целей Gson. Просто храню кучу JSON'ов в тестовых ресурсах, из которых набиваются бины. Удобно тестировать Dozer маппинги — на входе два JSON документа. Ассерты пока, правда, руками пишу.


    1. FranciscoSuarez Автор
      11.04.2015 01:03

      Gson замечательная библиотека по приведению json к объектной модели, но она, увы, не даёт рандомизации и, в Вашем случае, приходится создавать ручками json-ы с фиксированными значениям в дереве. Если приходится писать ассерты на каждую пару классов в маппинге, то попробуйте через Generic-и — мне думается, что можно написать один ассерт. Ну или я не понял Вашей задачи.