Доброго времени суток, коллеги.

Я решил поделиться своим видением на параметризованные юнит-тесты, как делаем это мы, и как возможно не делаете (но захотите делать) вы.

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

Основная цель статьи показать, как можно(и нужно) перестать захламлять свой юнит-тест кодом для создания объектов, и как декларативно создать тестовые данные, если одних mock(any()) не хватает, а таких ситуаций полно.

Создадим maven проект, добавим в него junit5, junit-jupiter-params и mokito

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

Создадим тест HabrServiceTest. В поле класса теста добавим ссылку на HabrService:

public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}

создадим сервис через ide (легким нажатием шортката), добавим на поле аннотацию @InjectMocks.

Приступаем непосредственно к тесту: HabrService в нашем небольшом приложении будет иметь один единственный метод handle(), который будет принимать один единственный аргумент HabrItem, и теперь наш тест выглядит так:

public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @Test
    void handleTest(){
        HabrItem item = new HabrItem();
        habrService.handle(item);
    }
}

Добавим в HabrService метод handle(), который будет возвращать id нового поста на хабре после его модерации и сохранения в БД, и принимает тип HabrItem, так же создадим наш HabrItem, и теперь тест компилируется, но падает.

Дело в том что мы добавили проверку, на ожидаемое возвращаемое значение.

public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        Long actual = habrService.handle(item);

        assertEquals(1L, actual);
    }
}

Также, я хочу убедиться, что в ходе вызова метода handle(), были вызваны ReviewService и PersistanceService, вызвались они строго друг за другом, отработали ровно по 1 разу, и никакие другие методы уже не вызывались. Иными словами вот так:

public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        
        Long actual = habrService.handle(item);
        
        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(item);
        inOrder.verify(persistenceService).makePersist(item);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}

Добавим в поля класса класса reviewService и persistenceService, создадим их, добавим им методы makeRewiew() и makePersist() соответственно. Теперь все компилируется, но конечно же тест красный.

В контексте данной статьи, реализации ReviewService и PersistanceService не так уж важны, важна реализация HabrService, сделаем его чуть интересней чем он есть сейчас:

public class HabrService {

    private final ReviewService reviewService;

    private final PersistenceService persistenceService;

    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
        this.reviewService = reviewService;
        this.persistenceService = persistenceService;
    }

    public Long handle(final HabrItem item) {
        HabrItem reviewedItem = reviewService.makeRewiew(item);
        Long persistedItemId = persistenceService.makePersist(reviewedItem);

        return persistedItemId;
    }
}

и с помощью конструкций when().then() замокируем поведение вспомогательных компонентов, в итоге наш тест стал вот таким и теперь он зеленый:

public class HabrServiceTest {

    @Mock
    private ReviewService reviewService;

    @Mock
    private PersistenceService persistenceService;

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp() {
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem source = new HabrItem();
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}

Макет для демонстрации мощи параметризованных тестов готов.

Добавим в нашу модель запроса к сервису HabrItem поле с типом хаба, hubType, создадим enum HubType и включим в него несколько типов:

public enum HubType {
    JAVA, C, PYTHON
}

а модели HabrItem добавим геттер и сеттер, на созданное поле HubType.

Предположим, что в недрах нашего HabrService спрятался switch, который в зависимости от типа хаба делает с запросом неведомое что-то, и в тесте мы хотим протестировать каждый из кейсов неведомого, наивная реализация метода выглядела бы так:

        
    @Test
    void handleTest() {
        HabrItem reviewedItem = mock(HabrItem.class);
        HabrItem source = new HabrItem();
        source.setHubType(HubType.JAVA);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }

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

@ParameterizedTest
    @EnumSource(HubType.class)
    void handleTest(final HubType type) 

красиво, декларативно, и все значения нашего enum обязательно будут задействованы при каком-нибудь очередном запуске тестов, у аннотации есть параметры, можем добавлять стратегии на include, exclude.

Но возможно я вас не убедил, что параметризованные тесты — это хорошо. Добавим в
исходный запрос HabrItem новое поле editCount, в которое будет записано количество тысяч раз, которое пользователи Хабра редактируют свою статью, перед тем как запостить, чтоб она вам хоть немного понравилась, и положим что где то в недрах HabrService есть какая то логика, которая делает неведомое что-то, в зависимости от того, насколько попыток автор постарался, что если я не хочу писать 5 или 55 тестов на все возможные варианты editCount, а хочу протестировать декларативно, и где то в одном месте сразу обозначить все значения которые я хотел бы проверить. Нет ничего проще, и воспользовавшись api параметризованных тестов получим в декларации метода что то такое:

    @ParameterizedTest
    @ValueSource(ints = {0, 5, 14, 23})
    void handleTest(final int type) 

Налицо проблема, мы хотим собирать в параметрах тестового метода сразу два значения декларативно, можно использовать еще один прекрасный метод параметризованных тестов @CsvSource, отлично подойдет для того чтоб протестировать несложные параметры, с несложным выходным значением(крайне удобен в тестировании утилитных классов), но что если объект станет гораздо сложней? Скажем, в нем будет порядка 10 полей, причем не только примитивы и джава-типы.

На помощь приходит аннотация @MethodSource, наш тестового метода стал ощутимо короче и в нем нет больше сеттеров, а источник входящего запроса подается в тестовый метод параметром:

    
    @ParameterizedTest
    @MethodSource("generateSource")
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }

в аннотации @MethodSource указана строка generateSource, что это? это название метода, который соберет для нас нужную модель, его декларация будет выглядеть так:

   private static Stream<Arguments> generateSource() {
        HabrItem habrItem = new HabrItem();
        habrItem.setHubType(HubType.JAVA);
        habrItem.setEditCount(999L);
        
        return nextStream(() -> habrItem);
    }

для удобства формирование стрима аргументов nextStream я вынесес в отдельный утилитный тестовый класс:

public class CommonTestUtil {
    private static final Random RANDOM = new Random();

    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
    }

    public static int nextIntBetween(final int min, final int max) {
        return RANDOM.nextInt(max - min + 1) + min;
    }
}

Теперь в при запуске теста, в параметр тестового метода декларативно будет добавляться модель запроса HabrItem, причем запускаться тест будет столько раз, сколько аргументов сгененрирует наша тестовая утилита, в нашем случае от 1 до 10.

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

По моему уже все супер, в тесте теперь описано только поведение наших заглушек, и ожидаемые результаты.

Но вот незадача, в модель HabrItem добавляется новое поле, text, массив строк, которое может быть очень большим или не очень, неважно, главное то, что мы не хотим захламять наши тесты, нам не нужны рандомные данные, мы хотим строго определенную модель, с конкретными данными, собирать ее в тесте или где либо еще — мы не хотим. Было бы круто, если бы можно было взять тело json запроса откуда угодно, например из постмана, сделать на его основе моковый файл и в тесте формировать модель декларативно, указав лишь путь к json файлу с данными.

Отлично. Используем аннотацию @JsonSource, которая будет принимать параметр path, с относительным путем к файлу и целевой класс. Черт! В параметризованных тестах нет такой аннотации, а хотелось бы.

Давайте напишем сами.

Обработкой всех аннотаций идущих в комплекте с @ParametrizedTest в junit занимаются ArgumentsProvider, мы напишем свой собственный JsonArgumentProvider:

public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {

    private String path;

    private MockDataProvider dataProvider;

    private Class<?> clazz;

    @Override
    public void accept(final JsonSource jsonSource) {
        this.path = jsonSource.path();
        this.dataProvider = new MockDataProvider(new ObjectMapper());
        this.clazz = jsonSource.clazz();
    }

    @Override
    public Stream<Arguments> provideArguments(final ExtensionContext context) {
        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
    }
}

MockDataProvider, это класс, для парсинга моковых json файлов, его реализация крайне простая:


public class MockDataProvider {

    private static final String PATH_PREFIX = "json/";

    private final ObjectMapper objectMapper;

     public <T> T parseDataObject(final String name, final Class<T> clazz) {
        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
    }

}

Моковый провайдер готов, провайдер аргументов для нашей аннотации тоже, осталось добавить саму аннотацию:


/**
 * Source-аннотация для параметризированных тестов,
 * использует в качестве источника json-файл
 */
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {

    /**
     * Путь к json-файлу, по умолчанию classpath:/json/
     *
     * @return относительный путь к моковому файлу
     */
    String path() default "";

    /**
     * Целевой тип, к которому будет приведен аргумент в результирующем стриме
     *
     * @return целевой тип
     */
    Class<?> clazz();
}

Ура. Наша аннотация готова к употреблению, тестовый метод стал таким:

  
    @ParameterizedTest
    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }

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

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

P.S. Статья не является покушением на знание TDD концепций, хотелось накидать тестовые данные походу повествования, чтоб было чуть понятней и интересней.