Статьи

Внедрение двойных тестов весной с использованием Mockito и BeanPostProcessors

Я вполне уверен, что если вы когда-либо использовали Spring и знакомы с модульным тестированием, вы столкнулись с проблемой, связанной с введением пародий / шпионов (Test Doubles) в контексте приложения Spring, которую вы не хотели бы изменять. В этой статье представлен подход к решению этой проблемы с использованием компонентов Spring.

Структура проекта

Начнем со структуры проекта:

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

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

В этом примере у нас есть PlayerService который получает Player используя PlayerWebService . У нас есть applicationContext, который просто определяет пакеты для автопроводки:

applicationContext.xml

01
02
03
04
05
06
07
08
09
10
<?xml version="1.0" encoding="UTF-8"?>
       xmlns:context="http://www.springframework.org/schema/context"
 
    <context:component-scan base-package="com.blogspot.toomuchcoding"/>
 
</beans>

Тогда у нас есть очень простая модель:

Player.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.blogspot.toomuchcoding.model;
 
import java.math.BigDecimal;
 
/**
 * User: mgrzejszczak
 * Date: 08.08.13
 * Time: 14:38
 */
public final class Player {
    private final String playerName;
    private final BigDecimal playerValue;
 
    public Player(final String playerName, final BigDecimal playerValue) {
        this.playerName = playerName;
        this.playerValue = playerValue;
    }
 
    public String getPlayerName() {
        return playerName;
    }
 
    public BigDecimal getPlayerValue() {
        return playerValue;
    }
}

Реализация PlayerWebService которая использует PlayerWebService для извлечения данных, касающихся Player :

PlayerServiceImpl.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.blogspot.toomuchcoding.service;
 
import com.blogspot.toomuchcoding.model.Player;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
/**
 * User: mgrzejszczak
 * Date: 08.06.13
 * Time: 19:02
 */
@Service
public class PlayerServiceImpl implements PlayerService {
    private static final Logger LOGGER = LoggerFactory.getLogger(PlayerServiceImpl.class);
 
    @Autowired
    private PlayerWebService playerWebService;
 
    @Override
    public Player getPlayerByName(String playerName) {
        LOGGER.debug(String.format("Logging the player web service name [%s]", playerWebService.getWebServiceName()));
        return playerWebService.getPlayerByName(playerName);
    }
 
    public PlayerWebService getPlayerWebService() {
        return playerWebService;
    }
 
    public void setPlayerWebService(PlayerWebService playerWebService) {
        this.playerWebService = playerWebService;
    }
}

реализация PlayerWebService который является поставщиком данных (в этом сценарии мы моделируем, ожидая ответа):

PlayerWebServiceImpl.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.blogspot.toomuchcoding.service;
 
import com.blogspot.toomuchcoding.model.Player;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
 
import java.math.BigDecimal;
 
/**
 * User: mgrzejszczak
 * Date: 08.08.13
 * Time: 14:48
 */
@Service
public class PlayerWebServiceImpl implements PlayerWebService {
    private static final Logger LOGGER = LoggerFactory.getLogger(PlayerWebServiceImpl.class);
    public static final String WEB_SERVICE_NAME = "SuperPlayerWebService";
    public static final String SAMPLE_PLAYER_VALUE = "1000";
 
    @Override
    public String getWebServiceName() {
        return WEB_SERVICE_NAME;
    }
 
    @Override
    public Player getPlayerByName(String name) {
        try {
            LOGGER.debug("Simulating awaiting time for a response from a web service");
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            LOGGER.error(String.format("[%s] occurred while trying to make the thread sleep", e));
        }
        return new Player(name, new BigDecimal(SAMPLE_PLAYER_VALUE));
    }
}

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

Эта проблема

Так в чем же проблема? Давайте предположим, что мы хотим, чтобы наш autowired PlayerWebServiceImpl был шпионом, которого мы можем проверить. Более того, вы не хотите ничего менять на самом деле в applicationContext.xml — вы хотите использовать текущую версию контекста Spring.

С mocks это проще, так как вы можете определить в своем XML-файле (используя фабричный метод Mockito) свой bean-компонент как mock, чтобы переопределить исходную реализацию следующим образом:

1
2
3
<bean id="playerWebServiceImpl" class="org.mockito.Mockito" factory-method="mock">
        <constructor-arg value="com.blogspot.toomuchcoding.service.PlayerWebServiceImpl"/>
    </bean>

Как насчет шпиона? Это более проблематично, поскольку для создания шпиона необходим уже существующий объект данного типа. В нашем примере у нас происходит автоматическое подключение, поэтому нам нужно сначала создать Spring PlayerWebService типа PlayerWebService (Spring должен был бы PlayerWebService все его зависимости), а затем обернуть его с помощью Mockito.spy(...) и только потом Должно ли это быть подключено где-то еще … Это очень усложняется, не так ли?

Решение

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

Начнем с проверки тестового класса:

PlayerServiceImplTest.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.blogspot.toomuchcoding.service;
 
import com.blogspot.toomuchcoding.model.Player;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
 
import java.math.BigDecimal;
 
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import static org.mockito.BDDMockito.doReturn;
import static org.mockito.Mockito.verify;
 
/**
 * User: mgrzejszczak
 * Date: 08.06.13
 * Time: 19:26
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:testApplicationContext.xml")
public class PlayerServiceImplTest {
 
    public static final String PLAYER_NAME = "Lewandowski";
    public static final BigDecimal PLAYER_VALUE = new BigDecimal("35000000");
 
    @Autowired
    PlayerWebService playerWebServiceSpy;
 
    @Autowired
    PlayerService objectUnderTest;
 
    @Test
    public void shouldReturnAPlayerFromPlayerWebService(){
        //given
        Player referencePlayer = new Player(PLAYER_NAME, PLAYER_VALUE);
        doReturn(referencePlayer).when(playerWebServiceSpy).getPlayerByName(PLAYER_NAME);
 
        //when
        Player player = objectUnderTest.getPlayerByName(PLAYER_NAME);
 
        //then
        assertThat(player, is(referencePlayer));
        verify(playerWebServiceSpy).getWebServiceName();
        assertThat(playerWebServiceSpy.getWebServiceName(), is(PlayerWebServiceImpl.WEB_SERVICE_NAME));
    }
 
 
}

В этом тесте мы хотим PlayerWebService получение Player из PlayerWebService (предположим, что обычно он пытается отправить запрос во внешний мир — и мы бы не хотели, чтобы это произошло в нашем сценарии) и проверить, что наш PlayerService возвращает Player который мы указали в заглушке метода, и, более того, мы хотим проверить на Spy, что метод getWebServiceName() был выполнен и что он имеет очень точно определенное возвращаемое значение. Другими словами, мы хотели getPlayerByName(...) метод getPlayerByName(...) и хотели выполнить проверку шпиона, проверив метод getWebServiceName() .

Давайте проверим контекст теста:

testApplicationContext.xml

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="UTF-8"?>
 
    <import resource="applicationContext.xml"/>
    <bean class="com.blogspot.postprocessor.PlayerWebServicePostProcessor" />
</beans>

Тестовый контекст очень мал, поскольку он импортирует текущий applicationContext.xml и создает Bean-компонент, который является ключевой функцией в этом примере — BeanPostProcessor :

PlayerWebServicePostProcessor.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.blogspot.postprocessor;
 
 
import com.blogspot.toomuchcoding.processor.AbstractBeanPostProcessor;
import com.blogspot.toomuchcoding.service.PlayerWebService;
 
import static org.mockito.Mockito.spy;
 
/**
 * User: mgrzejszczak
 * Date: 07.05.13
 * Time: 11:30
 */
public class PlayerWebServicePostProcessor extends AbstractBeanPostProcessor<PlayerWebService> {
    public PlayerWebServicePostProcessor() {
        super(PlayerWebService.class);
    }
 
    @Override
    public PlayerWebService doBefore(PlayerWebService bean) {
        return spy(bean);
    }
 
    @Override
    public PlayerWebService doAfter(PlayerWebService bean) {
        return bean;
    }
}

Класс расширяет AbstractBeanPostProcessor который реализует интерфейс BeanPostProcessor . Логика этого класса заключается в регистрации класса, для которого требуется выполнить некоторые действия либо до инициализации ( postProcessBeforeInitialization ), либо после инициализации компонента ( postProcessAfterInitialization ). AbstractBeanPostProcessor хорошо объяснен в моем посте
Spring BeanPostProcessor для указанного типа, но есть одно небольшое изменение — в моем старом посте абстракция позволила нам выполнить некоторые действия с bean-компонентом без возможности возврата оболочки или прокси-компонента на bean-компоненте.

Как вы можете видеть в случае PlayerWebServicePostProcessor перед инициализацией, мы создаем Spy, используя Mockito.spy(...) . Таким образом, мы создаем фабричную зацепку для инициализации bean-компонентов заданного типа — это так просто. Этот метод будет выполнен для всех классов, которые реализуют интерфейс PlayerWebService .

Другие возможности

Проверяя текущие решения этой проблемы, я столкнулся с библиотекой Springockito от Jakub Janczak.

Я не использовал это, так что я не знаю, каковы (если таковые имеются;)) производственные проблемы, связанные с этой библиотекой, но это кажется действительно хорошим и интуитивно понятным — отличная работа Jakub! Тем не менее, вы становитесь зависимыми от внешней библиотеки, тогда как в этом примере я показал, как решить проблему с помощью Spring.

Резюме

В этом посте я показал, как

  • создавать макеты для существующих компонентов с помощью конфигурации XML Spring
  • создать реализацию BeanPostProcessor, которая выполняет логику для заданного класса bean-компонентов
  • вернуть Spy (вы также можете вернуть Mock) для данного класса бобов

Теперь давайте пройдемся по преимуществам и недостаткам моего подхода:

преимущества

  • вы используете собственный механизм Spring для создания Test Doubles для ваших bean-компонентов
  • Вы не обязаны добавлять какие-либо дополнительные внешние зависимости
  • если вы используете AbstractBeanPostProcessor вас будет очень мало изменений для реализации

Недостатки

  • Вы должны быть знакомы с внутренней архитектурой Spring (которая использует BeanPostProcessors) — но является ли это недостатком? 😉 — на самом деле, если вы используете AbstractBeanPostProcessor вам не нужно быть знакомым с ним — вы просто должны предоставить тип класса и действия, которые будут выполняться до и после инициализации.
  • это менее интуитивно, чем аннотации, как в библиотеке Springockito

источники

Источники доступны в репозитории TooMuchCoding BitBucket и репозитории TooMuchCoding Github .