Есть 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; } }Все, паззл собрался. Теперь осталось добавить возможности конфигурировать это все дело - потому как у нас если помнишь имя бина захардкоджено. Но это уже не так важно - тем более, что в этих исходниках, я как бы все уже причесал. Качай на зоровье!
Комментариев нет:
Отправить комментарий