Tuesday, 9 December 2008

Integration testing Spring MVC without web server

Unit testing is very useful for finding bugs in isolated pieces of code and for checking whether a method obeys its contract. However, testing the configuration of an app or testing how a framework like Spring MVC calls or manipulates the data passed in.

This is where integration testing comes in. Testing the app with everything wired in. End to end testing. This usually entails a tester firing up the browser and working through a list of test cases. This can be automated with something similar Test Director or made into JUnit test cases using HtmlUnit or HttpUnit.

Creating a test case using HtmlUnit (or HttpUnit if you want to do more work) can be quiet tedious and time consuming. However there is a way to programmatically invoke the Spring's servlet and obtain the response object without having to parse html. By invoking it directly you gain a level of control, and the testing can be more specific. The downside is that you don't test the JSP files, though I'm sure there are ways to do that too.

When it came to actually setting up the environment to perform the test, I discovered this was the one area where Spring's test framework is lacking. So I ended up extending the framework to perform the necessary tasks.

Loading The Web Application Context

The first hurdle encountered was trying to load the correct application context.
The test framework, by default loads the GenericXmlContextLoader. However the DispatcherServlet expects to a XmlWebApplicationContext to be loaded. No problem, the annotation @ContextConfiguration allows a custom loader to be specified.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:/common-beans.xml", "classpath:/myservlet-test.xml" }, loader = WebAppLoader.class)

Just extend AbstractContextLoader, override loadContext(), create an instance of XmlWebApplicationContext create a MockServletContext set the ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE to be the new context. Next obtain a reference to the servlet that was defined in the myservlet-test.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"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd">

<!-- ========================= SERVLET ========================= -->
<bean id="aptt" class="org.springframework.web.servlet.DispatcherServlet">
</bean>
</beans>
As long as you set the servlet context's path to be the web directory of the project your testing then all the relevant configuration files will be loaded.
Here is the loader:

/**
* @author Chris Watts
*
*/
public class WebAppLoader extends AbstractContextLoader
{
/** Application root directory (usually project/web). */
protected String basePath = "../myproj/web";
/**
* set to false for each servlet to load an additional context (the way
* {@link DispatcherServlet} works by default). Useful if you want to load
* the servlet's context in the main context.
*/
protected boolean shareContext = false;
private static final Logger logger = Logger.getLogger(WebAppLoader.class);
/** attribute name to pass context via */
private static final String CONTEXT_PATH = "com.example.WebApplicationContext";

/**
* {@inheritDoc}
*
* @see org.springframework.test.context.support.AbstractContextLoader#getResourceSuffix()
*/
@Override
protected String getResourceSuffix()
{
return "-context.xml";
}

/**
* {@inheritDoc}
*
* @see org.springframework.test.context.ContextLoader#loadContext(java.lang.String[])
*/
@SuppressWarnings("unchecked")
public XmlWebApplicationContext loadContext(String... locations) throws Exception
{
if (logger.isDebugEnabled())
{
logger.debug("Loading ApplicationContext for locations ["
+ StringUtils.arrayToCommaDelimitedString(locations) + "].");
}

//resolve absolute path
File path = ResourceUtils.getFile(basePath);
if (!path.exists())
throw new FileNotFoundException("base path does not exist; " + basePath);
basePath = "file:" + path.getAbsolutePath();

XmlWebApplicationContext appContext = new XmlWebApplicationContext();

MockServletContext servletContext = new MockServletContext(basePath);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, appContext);

if (shareContext)
{
servletContext.setAttribute(CONTEXT_PATH, appContext);
}

appContext.setServletContext(servletContext);
appContext.setConfigLocations(locations);
appContext.refresh();
appContext.registerShutdownHook();

Map<String, DispatcherServlet> map = appContext.getBeansOfType(DispatcherServlet.class);
for (Entry<String, DispatcherServlet> entry : map.entrySet())
{
DispatcherServlet servlet = entry.getValue();
String servletName = entry.getKey();

if (shareContext)
servlet.setContextAttribute(CONTEXT_PATH);

MockServletConfig servletConfig = new MockServletConfig(servletContext, servletName);
servlet.init(servletConfig);
}

return appContext;
}
}

Now the class to call the dispatcher and perform the test:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:/common-beans.xml", "classpath:/myservlet-test.xml" }, loader = WebAppLoader.class)
public class MyServletTester
{
/** TILES_STACK. */
private static final String TILES_STACK = "org.apache.tiles.AttributeContext.STACK";
protected final Logger log = Logger.getLogger(getClass());
/** the servlet */
@Autowired
protected DispatcherServlet servlet;
/** user session info, session scoped. */
@Autowired
protected UserSession userSession;
/** request scoped bean */
protected MessageList messages;
/** */
protected MockHttpServletRequest request;
/** */
protected MockHttpServletResponse response;
/** special util extending RequestDispatcher to capture forward() data. */
protected RequestCopyingDispatcher requestDispatcher = new RequestCopyingDispatcher();

public void onStart() throws Exception
{
request = null;
response = null;
// obtain bean stored in the servlet's own context
messages = (MessageList) servlet.getWebApplicationContext().getBean("messages");
}

/**
* Perform the actual test.
*/
@Test
public void welcomePageLoggedIn() throws Exception
{
HashMap<String, String> parameters = new HashMap<String, String>();
MockHttpServletResponse ret = process("/home.do", parameters, true);
checkTilesResponse(response, request, "/jsp/tiles/mainLayout.jsp", "/jsp/welcomePage.jsp");
}

protected MockHttpServletResponse process(String uri, Map<String, String> reqParams, boolean loggedIn)
throws Exception
{
MockInterceptingRequest req = new MockInterceptingRequest("GET", uri);
MockHttpServletResponse res = new MockHttpServletResponse();
req.setRequestDispatcher(requestDispatcher);

//used for accessing request scoped proxied beans
WebRequest webRequest = new ServletWebRequest(req, res);
RequestContextHolder.setRequestAttributes(webRequest);

req.addParameters(reqParams);
HttpSession session = req.getSession();
if (loggedIn)
{
//set properties to indicate logged in
}

//call
servlet.service(req, res);

response = res;
request = req;
//done
return res;
}

public void checkResponse(MockHttpServletResponse response, MockHttpServletRequest request)
throws Exception
{
assertThat("ret", response, notNullValue());
assertThat("status code", response.getStatus(), equalTo(200));
assertThat("getErrorMessage", response.getErrorMessage(), nullValue());
assertThat("redirect", response.getRedirectedUrl(), nullValue());
}

/**
* Checks the response and request of the call.
*/
public void checkTilesResponse(MockHttpServletResponse response, MockHttpServletRequest request,
String template, String page) throws Exception
{
checkResponse(response, request);

assertThat("forward", response.getForwardedUrl(), equalTo(template));

//Attributes from the dispatcher tiles calls
Stack tilesStack;
tilesStack = (Stack) requestDispatcher.getCapturedAttributes().get(TILES_STACK);
assertThat(TILES_STACK, tilesStack, notNullValue());
assertThat("stack.isEmpty", tilesStack.isEmpty(), not(equalTo(true)));
if (log.isDebugEnabled())
log.debug(Arrays.toString(tilesStack.toArray()));

BasicAttributeContext tilesContext = (BasicAttributeContext) tilesStack.pop();
if (log.isDebugEnabled())
log.debug(toString(tilesContext));

assertThat("tiles.body", tilesContext.getAttribute("body"), notNullValue());
String body = (String) tilesContext.getAttribute("body").getValue();
assertThat("body", body, equalTo(page));
}

protected void debugAttributes()
{
Enumeration<String> names = request.getAttributeNames();
while (names.hasMoreElements())
{
String name = names.nextElement();
log.debug(name + ":" + request.getAttribute(name));
}
}

public static String toString(BasicAttributeContext cntx)
{
StringBuilder sb = new StringBuilder();
sb.append("TilesAttributes: {");

Iterator<String> i = cntx.getAttributeNames();
boolean hasNext = i.hasNext();
while (hasNext)
{
String key = i.next();
Attribute value = cntx.getAttribute(key);
sb.append(key);
sb.append("=");
sb.append(value);
hasNext = i.hasNext();
if (hasNext)
sb.append(", ");
}

sb.append("}");
return sb.toString();
}
}

No comments:

Post a Comment