Я вполне уверен, что если вы когда-либо использовали 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 */ @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" ?> 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 .