Если нельзя, но очень хочется, то нужно обязательно и ничего в мире не стоит того, чтобы делать из этого проблему!


Интересна Java? Кликай по ссылке и изучай!
Если тебе полезно что-то из того, чем я делюсь в своем блоге - можешь поделиться своими деньгами со мной.
с пожеланием
столько времени читатели провели на блоге - 
сейчас онлайн - 

вторник, 17 июня 2014 г.

Как мокать Bean'ы внутри Spring context запущеного Jetty? Part 2

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

Комментариев нет:

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