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


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

воскресенье, 18 ноября 2012 г.

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

Есть Web приложение на Spring (MVC + IoC). Есть Jetty сервер, на котором оно ранится. Web приложение в своей Model содержит ряд сервисов (Spring Beans), которыми пользуются контроллера.

Задача: Написать функциональный тест с испольованием WebDriver, но так, чтобы из теста была возможность мокать реальные сервиса модели.

Updated: Есть более продвинутая имплементация этой задачи, вот тут. Все что дальше по тексту этого поста - на память :)

Например в приложении есть 1 сервис MyService
package com.services;

import org.springframework.stereotype.Component;

/**
 * User: apofig
 * Date: 11/18/12
 * Time: 9:49 PM
 */
@Component("myService")
public class MyService {

    public String getString() {
        return "Hello from " + MyService.class.getSimpleName() + "!";
    }

}
Он инджектится в контроллер MyController с помощью @Autowired аннотации Spring'ом.
package com.controller;

import com.services.MyService;
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;

/**
 * User: apofig
 * Date: 9/20/12
 * Time: 1:37 PM
 */
@Controller
@RequestMapping("/")
public class MyController {

    @Autowired
    private MyService service;

    @RequestMapping(method = RequestMethod.GET)
    public String savePlayerGame(Model model) {
        model.addAttribute("string", service.getString());
        return "view";
    }
}
Контроллер отправляет данные на jsp, которая рисует сообщение на экране
<%@ 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>
Во время старта приложения я хочу, чтобы там был не реальный MyService, а его мок (или шпион), которым я буду рулить из теста вот так
public class IntegrationTest {
    private String url = "http://localhost:8080/";
    private WebDriver driver = new HtmlUnitDriver(true);
    private MyService service;

    @Test
    public void shouldGetMessageFromService() throws IOException, InterruptedException {
        when(service.getString()).thenReturn("Hi from mock!");

        driver.get(url);

        String message = driver.findElement(By.id("message")).getText();
        assertEquals("Hi from mock!", message);
    }
}
Зачем это делать? Если писать полностью функиональные тесты, тогда чтобы протестить, скажем, удаление пользователя из системы надо будет вначале создать этого пользователя по всем правилам. Но если иметь возможность мокать модель, тогда лишние действия не потребуются - мы всего лишь проверим, что до модели дошел запрос о удалении юзера и все.

Итак я долго (дня два) искал возможность это сделать. Google почти молчит. Самое близкое, что я нашел - библиотека Springockito, но нашел я ее тогда, когда практически моя версия была рабочей.

Как я решал проблему? При старте Jetty сервера есть возможность подслушать момент инициплизации контекста.
    Server server = new Server(0);
    WebAppContext context = new WebAppContext("src/main/webapp", "");
    context.addEventListener(new ServletContextListener() {
        @Override
        public void contextInitialized(ServletContextEvent sce)
            ServletContext servletContext = sce.getServletContext(); 
            // вот тут       
        }
    }
    server.setHandler(context);
    server.start();
    int port = server.getConnectors()[0].getLocalPort();
    String url = "http://localhost:" + port;
Я так же знаю, что после окончательного старта сервера в ServletContext будет находиться Spring'овый WebApplicationContext, но в момент отработки ServletContextListener его там еще нет.

После старта сервера я могу его получить вот так
    WebApplicationContext applicationContext = 
        WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
Имея на руках Spring'овый контекст я могу поковырять его - могу удалить существующий бин и заменить его моком, так что всякий раз когда клиент будет делать context.getBean(name) он будет получать мой мок. Вперед!
    private static Object mocking(WebApplicationContext webAppContext, String beanName, boolean isMockOfSpy) {
        Object realBean = webAppContext.getBean(beanName);

        AbstractRefreshableApplicationContext context = (AbstractRefreshableApplicationContext) webAppContext;
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory();

        Object newBean = null;
        if (isMockOfSpy) {
            newBean = mock(realBean.getClass());
        } else {
            newBean = spy(realBean);
        }

        beanFactory.removeBeanDefinition(beanName);
        beanFactory.registerSingleton(beanName, newBean);
        return beanFactory.getBean(beanName);
    }
Конечно, тут не помешает пару проверок (для ClassCast), потому как контекст мог быть не обязательно XmlWebApplicationContext как у меня - а там иди знай, что вернется.

Но это не все. В момент, когда я могу провернуть такую махинацию Spring уже проинджектил все бины в поля зависимых классов по аннотации @Autowired. И хоть я могу получить мок с помощью context.getBean(name), меня это не устраивает.

Несколько часов дебага с кондишенал брейкопинтами и я поймал момент, когда WebApplicationContext уже имеет на руках все бины, но еще не успел расставить их куда велено. Любопытно, что этот момент - такой же как и мой обработчик ServletContextListener.

В момент когда я добавляю свой обработчик, список обработчиков WebAppContext пуст (т.е. я первым слушаю) - тут нигде нет никакого WebApplicationContext. Даже в момент, когда мой обработчик отрабатывает, WebApplicationContext'а так же нигде нет.
    Server server = new Server(0);
    WebAppContext context = new WebAppContext("src/main/webapp", "");
    // Spring контекст недуступен
    context.addEventListener(new ServletContextListener() {
        @Override
        public void contextInitialized(ServletContextEvent sce)
            ServletContext servletContext = sce.getServletContext();        
            // Spring контекст так же недуступен
        }
    }
    server.setHandler(context);
    server.start();
    int port = server.getConnectors()[0].getLocalPort();
    String url = "http://localhost:" + port;
Обыно он хранится в _contextAttributes, но тут пока пусто
Но я точно знаю, что он там будет спустя некоторое время (ибо додебажился).
Через некоторое время, в очередь, после моего ServletContextListener обработчика, выстрится ряд других обработчиков. Последним из них будет Spring'овый. И вот когда он отработает, то WebApplicationContext появится внутри ServletContext.В этот момент бины будут уже готовые, но еще пока не расставленные по зависимым классам.

Мне надо как-то стать пятым в очереди, и сделал я это так
    private ServletContext servletContext;
    private WebApplicationContext applicationContext;

    public String start() throws Exception {
        server = new Server(0);
        final WebAppContext context = loadWebContext();
        context.addEventListener(new ServletContextListener() {
            @Override
            public void contextInitialized(ServletContextEvent sce) {
                servletContext = sce.getServletContext();

                context.addEventListener(new ServletContextListener() {
                    @Override
                    public void contextInitialized(ServletContextEvent sce) {
                        applicationContext = WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
                        // тут бины тепленькие, и все мои!!!
                    }

                    @Override
                    public void contextDestroyed(ServletContextEvent sce) {
                    }
                });
            }

            @Override
            public void contextDestroyed(ServletContextEvent sce) {
            }
        });
        server.setHandler(context);
        server.start();
        int port = server.getConnectors()[0].getLocalPort();

        String url = "http://localhost:" + port;
        return url;
    }
Дальше дело техники, вызываем метод
    private static Object mocking(WebApplicationContext webAppContext, String beanName, boolean isMockOfSpy) {
И сразу после того, как отработчик отработает Spring полезет деливерить бины по зависимым клиентам (@Autowired).

Я в дамках!

Вот исходный код простенького Spring Web проекта с демонстрацией этой возможности.

6 комментариев:

  1. Мб проще в тестах указывать на текстовый контекст, импортящий обычный? - Этот бин в текстовом контексте можно и переопределить.

    По идее, тогда не так зависим от порядка инициализации и контейнера.

    ОтветитьУдалить
    Ответы
    1. Тест запускает джетти, а уже унутри него появляется такое понятие как спринг. Если я на тесты навешаю тестовый контекст, то он не повлияет никак на то, что происходит в джетти. Я это проверил сразу же.
      Если плохо проверил, или не так Вас понял - прошу по-подробнее, если можно с сырцами.
      Спасибо!

      Удалить
  2. Браво, браво!
    Ужжжжасный dirty hack, но блин, то, что мне нужно.

    ОтветитьУдалить
    Ответы
    1. Есть небольшой апдейт:
      Как показала практика - сервиса, которые autowired'ся в другие сервиса не мокаются таким образом, мокаются только бины, которые попадают во внешние классы, например, вебконтроллеры или при получении бина из context.getBean("name");
      Вот тут описано более красивое решение.

      Удалить
  3. Как классно, что этот хак Вам пригодился. Дважды не зря, значит, старался.
    Как говорят - сделай рабочим, сделай красивым сделай быстрым. Мне хватило первого этапа.
    Спасибо за фидбек.

    ОтветитьУдалить
  4. Спасибо за WebApplicationContextUtils :) .
    Сетить еще можно по хардкору, через ReflectionUtils(естественно после того как конекст достали):)

    ОтветитьУдалить