Я вполне уверен, что если вы когда-либо использовали 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"?> xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd"> <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 */@Servicepublic 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 */@Servicepublic 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"?> xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <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 .