Development

Unit Testing Servlets Using Jetty

Jetty is a very lightweight servlet container, it starts up in just a handful of milliseconds, and easily allows you to unit test your HTTP Servlets.

Unfortunately, the Jetty API isn’t that easy to navigate. Here is a ServletRunner that will start up a Jetty container, and some useful stuff that is easy to integrate into your unit tests to test the servlets.

This code uses

  • Jetty 7
  • Hamcrest
  • JUnit

First of all, an example:

Notice how the servlet runner is not assigned a port - it will pick a free port, and so allows tests to run concurrently on the same box.

ServletRunnerExample.java

@Before
    public void setUp() throws Exception {
        filter = new FooFilter();
 
        runner = new ServletRunner() {{
            mount("/sheep", StaticContentServlet.withStaticContent("Hello"));
            mount("/cheese", StaticContentServlet.withStaticContent("There"));
            filter("/*", filter);
        }};
 
        runner.start();
    }
 
    @After
    public void tearDown() throws Exception {
        runner.stop();
    }
 
 
    @Test
    public void canRunAServletOrTwo() throws Exception {
        HttpResult result = getter.get(runner.uriOf("/sheep"));
        assertThat(result, isOK());
        assertThat(result, hasBody(equalTo("Hello")));
 
        result = getter.get(runner.uriOf("/cheese"));
        assertThat(result, isOK());
    }

Retrieving a URL

Here is a useful class to allow the retrieval of things at the end of a URL - it probably needs a bit of cleanup… in particular it plays fast and loose with character encodings, and only cares about string content. UrlGetter.java

import com.google.common.io.CharStreams;
import org.joda.time.Duration;
 
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Map;
 
import static com.google.common.collect.Lists.newArrayList;
 
public class UrlGetter {
 
 
    public static class Timeouts {
 
        public static final Timeouts Default = new Timeouts(
                Duration.standardSeconds(20),
                Duration.standardMinutes(1)
        );
 
        public final int connectionTimeoutMillis;
        public final int receiveTimeoutMillis;
 
        public Timeouts(Duration connectTimeout, Duration receiveTimeout) {
            this.connectionTimeoutMillis = (int) connectTimeout.getMillis();
            this.receiveTimeoutMillis = (int) receiveTimeout.getMillis();
        }
    }
 
    public HttpResult get(URI uri) throws IOException, URISyntaxException {
        return handleResponse(uri, openConnectionTo(uri, Timeouts.Default));
    }
 
    public HttpResult put(URI uri, String content, String contentType) throws IOException, URISyntaxException {
        HttpURLConnection connection = openConnectionTo(uri, Timeouts.Default);
        connection.setDoOutput(true);
        connection.setRequestMethod("PUT");
        setContentTypeOn(connection, contentType);
 
        OutputStreamWriter out = new OutputStreamWriter(connection.getOutputStream());
 
        try {
            out.write(content);
        } finally {
            out.close();
        }
 
        return handleResponse(uri, connection);
    }
 
    public HttpResult put(URI uri) throws IOException, URISyntaxException {
        HttpURLConnection connection = openConnectionTo(uri, Timeouts.Default);
        connection.setDoOutput(true);
        connection.setRequestMethod("PUT");
 
        return handleResponse(uri, connection);
    }
 
    private HttpURLConnection openConnectionTo(URI uri, Timeouts timeouts) throws IOException {
        HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
        connection.setConnectTimeout(timeouts.connectionTimeoutMillis);
        connection.setReadTimeout(timeouts.receiveTimeoutMillis);
        return connection;
    }
 
    private List<HttpResult.Header> responseHeadersFrom(HttpURLConnection httpCon) {
        List<HttpResult.Header> headers = newArrayList();
        for (Map.Entry<String, List<String>> responseHeader : httpCon.getHeaderFields().entrySet()) {
            for (String headerValue : responseHeader.getValue()) {
 
                String name = responseHeader.getKey();
                if (name != null) {
                    headers.add(new HttpResult.Header(name, headerValue));
                }
            }
        }
        return headers;
    }
 
    private void setContentTypeOn(HttpURLConnection connection, String contentType) {
        connection.setRequestProperty("Content-Type", contentType);
    }
 
    private HttpResult handleResponse(URI uri, HttpURLConnection connection) throws IOException, URISyntaxException {
        int responseCode = connection.getResponseCode();
 
        InputStreamReader reader = openAppropriateResultStream(responseCode, connection);
 
        try {
            return new HttpResult(connection.getURL().toURI(), connection.getConnectTimeout(), connection.getReadTimeout(), connection.getRequestMethod(), responseCode, responseHeadersFrom(connection), CharStreams.toString(reader));
        } finally {
            reader.close();
        }
    }
 
    private InputStreamReader openAppropriateResultStream(int responseCode, HttpURLConnection connection) throws IOException {
        InputStreamReader reader;
 
        if (responseCode >= 400) {
            reader = new InputStreamReader(connection.getErrorStream());
        } else {
            reader = new InputStreamReader(connection.getInputStream());
        }
        return reader;
    }
}

HttpResult.java

import static com.google.common.collect.Maps.newHashMap;
 
public class HttpResult {
    public final String body;
    public final URI uri;
    public final int connectTimeout;
    public final int readTimeout;
    public final int status;
    public final Map<String, Header> headers = newHashMap();
    public final String method;
 
    public HttpResult(URI url, int connectTimeout, int readTimeout, String method, int status, List<Header> headers, String body) {
        this.connectTimeout = connectTimeout;
        this.readTimeout = readTimeout;
        this.status = status;
        this.method = method;
        this.uri = url;
        this.body = body;
        for (Header header : headers) {
            this.headers.put(header.name.toLowerCase(), header);
        }
    }
 
    public Header header(String name) {
        return headers.get(name.toLowerCase());
    }
 
    public boolean hasCacheExpiryHeader() {
        return headers.containsKey("Expires".toLowerCase());
    }
 
    public boolean hasLastModifiedHeader() {
        return headers.containsKey("Last-Modified".toLowerCase());
    }
 
    public boolean ok() {
        return status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_CREATED;
    }
 
    public boolean notFound() {
        return status == HttpURLConnection.HTTP_NOT_FOUND;
    }
 
    public boolean error() {
        return status >= HttpURLConnection.HTTP_INTERNAL_ERROR;
    }
 
    public boolean badGateway() {
        return status == HttpURLConnection.HTTP_BAD_GATEWAY;
    }
 
    public boolean inConflict() {
        return status == HttpURLConnection.HTTP_CONFLICT;
    }
 
    public boolean badRequest() {
        return status == HttpURLConnection.HTTP_BAD_REQUEST;
    }
 
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("HttpResult[");
 
        sb.append("url=");
        sb.append(uri);
        sb.append(",\nconnectTimeout=");
        sb.append(connectTimeout);
        sb.append(",\nreceiveTimeout=");
        sb.append(readTimeout);
        sb.append(",\nstatus=");
        sb.append(status);
        sb.append("\nheaders=");
        for (Header header : headers.values()) {
            sb.append("\n\t[name=");
            sb.append(header.name);
            sb.append(",value=");
            sb.append(header.value);
            sb.append("]");
        }
        sb.append(",\nbody=");
        sb.append(body);
        sb.append("]");
 
        return sb.toString();
    }
 
    public String contentType() {
        return header("Content-Type").value;
    }
 
    public static class Header {
        public final String name;
        public final String value;
 
        public Header(String name, String value) {
            this.name = name;
            this.value = value;
        }
    }
}

Asserting about the results

HttpResultMatchers.java

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.junit.internal.matchers.TypeSafeMatcher;
 
import javax.servlet.http.HttpServletResponse;
 
public class HttpResultMatchers {
 
    public static Matcher<HttpResult> isOK() {
        return hasResponseCode(HttpServletResponse.SC_OK);
    }
 
    public static Matcher<HttpResult> isBadRequest() {
        return hasResponseCode(HttpServletResponse.SC_BAD_REQUEST);
    }
 
    public static Matcher<HttpResult> isMovedTemporarily() {
        throw new Defect("You cannot assert for redirects, because the UrlGetter automatically follows them");
    }
 
    public static Matcher<HttpResult> isInternalServerError() {
        return hasResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
    }
 
    public static Matcher<HttpResult> isNotImplemented() {
        return hasResponseCode(HttpServletResponse.SC_NOT_IMPLEMENTED);
    }
 
    public static Matcher<HttpResult> hasResponseCode(final int code) {
        return new TypeSafeMatcher<HttpResult>() {
            HttpResult actualResult;
            @Override
            public boolean matchesSafely(HttpResult httpResult) {
                actualResult = httpResult;
                return actualResult.status == code;
            }
 
            @Override
            public void describeTo(Description description) {
                description.appendText("Expected response code ")
                        .appendValue(code)
                        .appendText(" but was ")
                        .appendValue(actualResult.status);
            }
        };
    }
 
    public static Matcher<HttpResult> hasBody(final Matcher<String> body) {
        return new TypeSafeMatcher<HttpResult>() {
            HttpResult actual;
            @Override
            public boolean matchesSafely(HttpResult httpResult) {
                actual = httpResult;
                return body.matches(httpResult.body);
            }
 
            @Override
            public void describeTo(Description description) {
                description.appendText("Expected response body ")
                                        .appendDescriptionOf(body);
 
            }
        };
    }
}

The ServletRunner itself

This has a couple of features:

  • Can run a web application (exploded or war)
  • Can set up global JNDI entries for datasources or whatever
  • Can set servlet / filter init parameters.

ServletRunner.java

import org.eclipse.jetty.plus.jndi.Resource;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.HandlerContainer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.bio.SocketConnector;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.eclipse.jetty.server.nio.SelectChannelConnector;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.FilterMapping;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.webapp.WebAppContext;
 
import javax.naming.NamingException;
import javax.servlet.Filter;
import javax.servlet.Servlet;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
 
public class ServletRunner {
 
    private Server server;
    private Connector connector;
    private ServletContextHandler servlets;
    private ContextHandlerCollection contexts;
 
    public ServletRunner() {
        this(0);
    }
 
    public ServletRunner(int port) {
        server = new Server();
        connector = new SocketConnector();
        connector.setPort(port);
        server.addConnector(connector);
 
        servlets = new ServletContextHandler(ServletContextHandler.SESSIONS);
        servlets.setContextPath("/");
 
        contexts = new ContextHandlerCollection();
        contexts.addHandler(servlets);
 
        server.setHandler(contexts);
 
    }
 
    public ServletHolderBuilder mount(String path, Servlet servlet) {
        ServletHolder holder = new ServletHolder(servlet);
        servlets.addServlet(holder, path);
        return new ServletHolderBuilder(holder);
    }
 
    public ContextHandler getContextHandler() {
        return servlets;
    }
 
    public static class ServletHolderBuilder {
 
        private final ServletHolder servletHolder;
 
        public ServletHolderBuilder(ServletHolder servletHolder) {
            this.servletHolder = servletHolder;
        }
 
        public ServletHolderBuilder withInitParameter(String name, String value) {
            servletHolder.setInitParameter(name, value);
            return this;
        }
    }
 
    public WebAppContext mount(String path, File warfile) {
        HandlerContainer handler = (HandlerContainer) server.getHandler();
        WebAppContext context = new WebAppContext(handler, warfile.getAbsolutePath(), path);
 
        context.setConfigurationClasses(new String[]{
                     "org.eclipse.jetty.webapp.WebInfConfiguration",
                     "org.eclipse.jetty.webapp.WebXmlConfiguration",
                     "org.eclipse.jetty.webapp.MetaInfConfiguration",
                     "org.eclipse.jetty.webapp.FragmentConfiguration",
                     "org.eclipse.jetty.plus.webapp.EnvConfiguration",
                     "org.eclipse.jetty.plus.webapp.PlusConfiguration",
                     "org.eclipse.jetty.webapp.JettyWebXmlConfiguration",
                     "org.eclipse.jetty.webapp.TagLibConfiguration"
        });
 
        contexts.addHandler(context);
 
        return context;
    }
 
    public void bind(WebAppContext context, String name, Object object) {
        bindJndi(context, name, object);
    }
 
    public void bind(String name, Object object) {
        bindJndi(null, name,object);
    }
 
    private void bindJndi(WebAppContext context, String name, Object object) {
        try {
            new Resource(context, name, object); // jetty does all this statically.....
        } catch (NamingException e) {
            throw new Defect("Unable to bind. Is the name right?", e);
        }
    }
 
    public FilterHolderBuilder filter(final String path, Filter filter) {
        FilterHolder holder = new FilterHolder(filter);
        servlets.addFilter(holder, path, FilterMapping.REQUEST);
        return new FilterHolderBuilder(holder);
    }
 
    public static class FilterHolderBuilder {
 
        private FilterHolder filterHolder;
 
        public FilterHolderBuilder(FilterHolder filterHolder) {
            this.filterHolder = filterHolder;
        }
 
        public FilterHolderBuilder withInitParameter(String name, String value) {
            filterHolder.setInitParameter(name, value);
            return this;
        }
    }
 
    public Stoppable start() throws Exception {
        server.start();
        waitAWhileForServerToStart();
        return this;
    }
 
    private void waitAWhileForServerToStart() {
        long start = System.currentTimeMillis();
        while (!server.isStarted() && System.currentTimeMillis() < (start + 5 * 1000)) {
            //
        }
        if (!server.isStarted()) {
            throw new Defect("Jetty didn't start in time");
        }
    }
 
    public void stop() throws Exception {
        server.stop();
    }
 
    public URI uriOf(String path) {
 
        if (!server.isStarted()) {
            throw new Defect("You can't ask me for a URI before I start!");
        }
 
        try {
            return new URI("http", null, "localhost", connector.getLocalPort(), path, null, null);
        } catch (URISyntaxException e) {
            throw new Defect("Couldn't construct a URI for " + path, e);
        }
    }
}
comments powered by Disqus