Есть Web приложение на Spring (MVC + IoC). Есть Jetty сервер, на котором оно ранится. Web приложение в своей Model содержит ряд сервисов (Spring Beans), которыми пользуются контроллера.
Задача: Написать функциональный тест с испольованием WebDriver, но так, чтобы из теста была возможность мокать реальные сервиса модели.
В прошлый раз задача решилась, но как показала практика - сервиса, которые autowired'ся в другие сервиса не мокаются, мокаются только бины, которые попадают во внешние классы, например, вебконтроллеры, или при получении бина из context.getBean("name");
Попробуем решить и эту задачку. Допустим есть два бина:
package com.services;
import org.springframework.stereotype.Component;
@Component("otherService")
public class OtherService {
public String getOtherString() {
return "Hello from " + OtherService.class.getSimpleName() + "!";
}
}
И package com.services;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component("service")
public class Service {
@Autowired
private OtherService otherService;
public String getString() {
return "Hello from " + Service.class.getSimpleName() + "! " + otherService.getOtherString();
}
}
Как видно по исходному коду - они зависимы друг от дружки. Есть еще SpringMVC контроллер
package com.controller;
import com.services.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
@RequestMapping("/")
public class SomeController {
@Autowired
private Service service;
@RequestMapping(method = RequestMethod.GET)
public String action(Model model) {
model.addAttribute("string", service.getString());
return "view";
}
}
Все как видно работает на аннотациях, о чем указано в идеально чистом applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.1.xsd">
<context:annotation-config/>
<context:component-scan base-package="com.services"/>
</beans>
Есть и вьюшка (но это не так важно, потому как она всего лишь выводит в html то, что ей передали)
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;">
<title>Main page</title>
</head>
<body>
<c:if test="${string!=null}">
<span id="message">${string}</span>
</c:if>
<body>
</html>
Есть еще два конфига servlet-context.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.1.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-3.1.xsd">
<context:annotation-config/>
<context:component-scan base-package="com.controller"/>
<mvc:annotation-driven/>
<mvc:resources mapping="/resources/**" location="/resources/" />
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/view/"/>
<property name="suffix" value=".jsp"/>
</bean>
</beans>
Ну и конечно же сердце web приложения web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
id="WebApp_ID" version="3.0">
<display-name>Mocker</display-name>
<context-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.XmlWebApplicationContext</param-value>
</context-param>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:com/applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/servlet-context.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
И как теперь сделать так, чтобы я в тестах, которые пишу мог мокать любой из бинов, при этом поднимая приложение на реальном allicationContext.xml не влезая тестами в продакшен код? Оказывается (спустя 5 часов ночного дебага) не так уж и сложно. История начинается с небольшого интерфейса BeanPostProcessor, реализовав который можно встроиться в процесс инициализации каждого бина в системе. Он прост.
* Copyright 2002-2010 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.beans.factory.config;
import org.springframework.beans.BeansException;
/**
* Factory hook that allows for custom modification of new bean instances,
* e.g. checking for marker interfaces or wrapping them with proxies.
*
* ApplicationContexts can autodetect BeanPostProcessor beans in their
* bean definitions and apply them to any beans subsequently created.
* Plain bean factories allow for programmatic registration of post-processors,
* applying to all beans created through this factory.
*
* Typically, post-processors that populate beans via marker interfaces
* or the like will implement {@link #postProcessBeforeInitialization},
* while post-processors that wrap beans with proxies will normally
* implement {@link #postProcessAfterInitialization}.
*
* @author Juergen Hoeller
* @since 10.10.2003
* @see InstantiationAwareBeanPostProcessor
* @see DestructionAwareBeanPostProcessor
* @see ConfigurableBeanFactory#addBeanPostProcessor
* @see BeanFactoryPostProcessor
*/
public interface BeanPostProcessor {
/**
* Apply this BeanPostProcessor to the given new bean instance <i>before</i> any bean
с * initialization callbacks (like InitializingBean's <code>afterPropertiesSet</code>
* or a custom init-method). The bean will already be populated with property values.
* The returned bean instance may be a wrapper around the original.
* @param bean the new bean instance
* @param beanName the name of the bean
* @return the bean instance to use, either the original or a wrapped one; if
* <code>null</code>, no subsequent BeanPostProcessors will be invoked
* @throws org.springframework.beans.BeansException in case of errors
* @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet
*/
Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
/**
* Apply this BeanPostProcessor to the given new bean instance <i>after</i> any bean
* initialization callbacks (like InitializingBean's <code>afterPropertiesSet</code>
* or a custom init-method). The bean will already be populated with property values.
* The returned bean instance may be a wrapper around the original.
* In case of a FactoryBean, this callback will be invoked for both the FactoryBean
* instance and the objects created by the FactoryBean (as of Spring 2.0). The
* post-processor can decide whether to apply to either the FactoryBean or created
* objects or both through corresponding <code>bean instanceof FactoryBean</code> checks.
* This callback will also be invoked after a short-circuiting triggered by a
* {@link InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation} method,
* in contrast to all other BeanPostProcessor callbacks.
* @param bean the new bean instance
* @param beanName the name of the bean
* @return the bean instance to use, either the original or a wrapped one; if
* <code>null</code>, no subsequent BeanPostProcessors will be invoked
* @throws org.springframework.beans.BeansException in case of errors
* @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet
* @see org.springframework.beans.factory.FactoryBean
*/
Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}
Один его метод postProcessAfterInitialization - нам и нужен. Именно в нем можно обернуть тот или иной бин в Mockito.spy() и получить желаемый результат. Чтобы BeanPostProcessor заработал, его так же надо (либо аннотациями либо через applicationContext.xml) засветить перед Spring.
@Component // вот без этого пока никак! Процессор должен быть виден для Spring
public class SpyPostProcessor implements BeanPostProcessor, Ordered {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (spy.equals("service")) { // как-то так
return Mockito.spy(bean);
} else {
return bean;
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE; // наивысший приоритет у этого BeanPostProcessor
}
}
Так же желательно реализовать еще один интерфейс, чтобы указать порядок обработки бина в очереди других зарегистрированных BeanPostProcessor'ов.
/*
* Copyright 2002-2009 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core;
/**
* Interface that can be implemented by objects that should be
* orderable, for example in a Collection.
*
* The actual order can be interpreted as prioritization, with
* the first object (with the lowest order value) having the highest
* priority.
*
* Note that there is a 'priority' marker for this interface:
* {@link PriorityOrdered}. Order values expressed by PriorityOrdered
* objects always apply before order values of 'plain' Ordered values.
*
* @author Juergen Hoeller
* @since 07.04.2003
* @see OrderComparator
* @see org.springframework.core.annotation.Order
*/
public interface Ordered {
/**
* Useful constant for the highest precedence value.
* @see java.lang.Integer#MIN_VALUE
*/
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
/**
* Useful constant for the lowest precedence value.
* @see java.lang.Integer#MAX_VALUE
*/
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
/**
* Return the order value of this object, with a
* higher value meaning greater in terms of sorting.
* Normally starting with 0, with <code>Integer.MAX_VALUE</code>
* indicating the greatest value. Same order values will result
* in arbitrary positions for the affected objects.
* Higher values can be interpreted as lower priority. As a
* consequence, the object with the lowest value has highest priority
* (somewhat analogous to Servlet "load-on-startup" values).
* @return the order value
*/
int getOrder();
}
Хорошо, это решает наш вопрос с оборачиванием бинов, но вовсе не решает другую часть задачи - "не лезть в целях тестов в production код". Как быть? Будем хачить... Я узнал, что есть некто BeanFactory - реализации которого участвуют в построении бинов. Один из его наследников ConfigurableBeanFactory содержит метод addBeanPostProcessor, с помощью которого можно добавить в систему "ручками" свой BeanPostProcessor. Вот его описание
/**
* Add a new BeanPostProcessor that will get applied to beans created
* by this factory. To be invoked during factory configuration.
* Note: Post-processors submitted here will be applied in the order of
* registration; any ordering semantics expressed through implementing the
* {@link org.springframework.core.Ordered} interface will be ignored. Note
* that autodetected post-processors (e.g. as beans in an ApplicationContext)
* will always be applied after programmatically registered ones.
* @param beanPostProcessor the post-processor to register
*/
void addBeanPostProcessor(BeanPostProcessor beanPostProcessor);
Теперь мне нужно место, в котором создается BeanFactory чтобы сразу после ее инициализации я добавил бы свой BeanPostProcessor в ее нутро. Где? Дядюшка Дебаг поможет! В итоге я пришел к самому главному XmlWebApplicationContext, который содержит в себе метод createBeanFactory. Занаследовавшись от него я получаю возможность реализовать желаемое
public class SpyXmlWebApplicationContext extends XmlWebApplicationContext {
@Override
protected DefaultListableBeanFactory createBeanFactory() {
DefaultListableBeanFactory factory = super.createBeanFactory();
factory.addBeanPostProcessor(new SpyPostProcessor());
return factory;
}
}
Мы уже близко к цели - теперь осталось поменять класс контекста в web.xml с XmlWebApplicationContext на мой SpyXmlWebApplicationContext.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
id="WebApp_ID" version="3.0">
<display-name>Mocker</display-name>
<context-param>
<param-name>contextClass</param-name>
<param-value>intergation.SpyXmlWebApplicationContext</param-value>
</context-param>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:com/applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
...
Но мы жеж договорились, что никаких грязных рук в production коде. А потому идем к нашему jetty и будем просить его нам помочь. Если мы добавим ServletContextListener лиснер для загруженного WebAppContext, то во время его обработки можно будет изменить все эти context-param свойства на значения, которые нам нужны.
package integraion;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.webapp.WebAppContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
public class JettyRunner {
public static int start() throws Exception {
Server server = new Server(0);
final WebAppContext context = new WebAppContext("src/main/webapp", "/mocker");
context.addEventListener(new ServletContextListener() {
@Override
public void contextInitialized(ServletContextEvent sce) {
String contextClass = context.getInitParameter(ContextLoader.CONTEXT_CLASS_PARAM);
if (!contextClass.equals(XmlWebApplicationContext.class.getName())) {
throw new RuntimeException("Тип " + contextClass + " не поддерживается!"); // да-да :)
}
context.setInitParameter(ContextLoader.CONTEXT_CLASS_PARAM, SpyXmlWebApplicationContext.class.getName()); // заменяем на свой
}
});
server.setHandler(context);
server.start();
int port = server.getConnectors()[0].getLocalPort();
return port;
}
}
Все, паззл собрался. Теперь осталось добавить возможности конфигурировать это все дело - потому как у нас если помнишь имя бина захардкоджено. Но это уже не так важно - тем более, что в этих исходниках, я как бы все уже причесал. Качай на зоровье!

Комментариев нет:
Отправить комментарий