cowwoc
2014-03-18 22:20:02 UTC
Hi,
I just wanted to let you guys know that I managed to get run Jersey unit
tests in parallel using TestNG in "methods=parallel" mode, launching a
separate Jetty instance per @Test. My unit tests now complete 2x faster.
I don't know about you, but for me that is a very big deal.
My advice to you: dump JerseyTest. Its design is flawed and it doesn't
look like it'll get fixed anytime soon. You can replace Jetty with your
server of choice. Just dump the abstraction (JerseyTest) and talk
directly to the server.
Here is my code:
1. Unit tests extend "UnitTest" (below). It launches and shuts down a
separate server per @Test.
2. Each @Test accesses context-specific data through "TestClient". This
class holds a reference to the server URI and Jersey's Client instance.
3. The "JettyServer" class launches a Jetty instance against port 0
(meaning, it picks a random port number and ensures it is available
for use).
That's it. Easy as pie. I hope this helps other people.
-------------------
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.config.ClientConfig;
import com.sun.jersey.api.client.config.DefaultClientConfig;
import com.sun.jersey.api.client.filter.LoggingFilter;
import com.sun.jersey.api.json.JSONConfiguration;
import java.io.IOException;
import org.bitbucket.cowwoc.myapp.backend.JettyServer;
import org.bitbucket.cowwoc.myapp.backend.jackson.ObjectMapperProvider;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
/**
* Configures each @Test to run against a separate Jetty instance.
* <p>
* @author Gili Tzabari
*/
public abstract class UnitTest
{
private final ThreadLocal<JettyServer> jettyServer = new
ThreadLocal<>();
private final ThreadLocal<Client> jerseyClient = new ThreadLocal<>();
/**
* @return the context associated with the test
*/
protected TestClient getClient()
{
return new TestClient(jerseyClient.get(),
jettyServer.get().getBaseUri());
}
/**
* Launches a new server before each test.
* <p>
* @throws IOException if an I/O error occurs
*/
@BeforeMethod
protected void beforeTest() throws IOException
{
JettyServer server = new
JettyServer(TestContainer.class).setPort(0).start();
ClientConfig config = new DefaultClientConfig();
config.getFeatures().put(JSONConfiguration.FEATURE_POJO_MAPPING, true);
config.getClasses().add(ObjectMapperProvider.class);
Client client = Client.create(config);
client.addFilter(new LoggingFilter());
this.jettyServer.set(server);
this.jerseyClient.set(client);
}
/**
* Shuts down the server after each test.
* <p>
* @throws IOException if an I/O error occurs
*/
@AfterMethod
protected void afterTest() throws IOException
{
JettyServer server = this.jettyServer.get();
server.stop();
Client client = this.jerseyClient.get();
client.destroy();
this.jettyServer.remove();
this.jerseyClient.remove();
}
}
-------------------
-----------------------
import com.sun.jersey.spi.container.servlet.ServletContainer;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.EnumSet;
import javax.servlet.DispatcherType;
import org.bitbucket.cowwoc.preconditions.Preconditions;
import org.bitbucket.cowwoc.myapp.backend.jersey.MyApplication;
import org.bitbucket.cowwoc.myapp.backend.jersey.ServerMode;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.webapp.WebAppContext;
public final class JettyServer implements AutoCloseable
{
private final Server server = new Server();
private final Class<? extends ServletContainer> containerClass;
private int port = 80;
private String contextPath = "/";
private ServerMode mode = ServerMode.DEBUG;
/**
* Creates a new JettyServer.
* <p>
* @param containerClass the servlet or filter to respond to
incoming requests
* @throws NullPointerException if containerClass is null
*/
public JettyServer(Class<? extends ServletContainer> containerClass)
{
Preconditions.requireThat(containerClass,
"containerClass").isNotNull();
this.containerClass = containerClass;
}
/**
* Sets the port the server should listen on. The default value is
{@code 80}.
* <p>
* @param port 0 if a random port should be used
* @return this
*/
public JettyServer setPort(int port)
{
this.port = port;
return this;
}
/**
* @return the port the server should listen on
*/
public int getPort()
{
return port;
}
/**
* @return the port the server is listening on
* @throws IllegalStateException if the server is not running
*/
public int getBoundPort() throws IllegalStateException
{
// @see http://stackoverflow.com/a/14982831/14731
int result = ((NetworkConnector)
server.getConnectors()[0]).getLocalPort();
if (result < 0)
{
assert (!server.isStarted()): server;
throw new IllegalStateException("Server must be started");
}
return result;
}
/**
* @param contextPath the application context path. The default
value is {@code "/"}.
* @return this
* @throws NullPointerException if contextPath is null
*/
public JettyServer setContextPath(String contextPath)
{
Preconditions.requireThat(contextPath, "contextPath").isNotNull();
this.contextPath = contextPath;
return this;
}
/**
* @return the application context path
*/
public String getContextPath()
{
return contextPath;
}
/**
* @param mode the server mode. The default value is {@link
ServerMode#DEBUG}.
* @return this
* @throws NullPointerException if mode is null
*/
public JettyServer setServerMode(ServerMode mode)
{
Preconditions.requireThat(mode, "mode").isNotNull();
this.mode = mode;
return this;
}
/**
* @return the server mode
*/
public ServerMode getMode()
{
return mode;
}
/**
* Launches the server asynchronously.
* <p>
* @return this
* @throws IOException if an I/O error occurs
* @see #join()
*/
public JettyServer start() throws IOException
{
// Path to "target/classes"
Path moduleRoot = Modules.getRootPath(MyServer.class);
ResourceHandler sourceFiles = new ResourceHandler();
if (mode == ServerMode.DEBUG)
{
// Hotswap static resources during development
Path sourceDir =
moduleRoot.resolve("../../../frontend/src/main/webapp");
Preconditions.requireThat(sourceDir,
"sourceDir").isNotNull().exists();
sourceFiles.setResourceBase(sourceDir.toString());
}
Path webAppDir =
moduleRoot.resolve("../../../frontend/target/classes");
Preconditions.requireThat(webAppDir,
"webAppdir").isNotNull().exists();
ResourceHandler staticFiles = new ResourceHandler();
staticFiles.setResourceBase(webAppDir.toString());
HandlerList staticResourceHandlers = new HandlerList();
staticResourceHandlers.setHandlers(new Handler[]
{
sourceFiles, staticFiles
});
ContextHandler staticResources = new ContextHandler();
staticResources.setContextPath(contextPath);
staticResources.setHandler(staticResourceHandlers);
// Servlets and Javascript files loaded from META-INF/resources
in JAR files
WebAppContext dynamicResources = new WebAppContext();
dynamicResources.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed",
"false");
dynamicResources.setThrowUnavailableOnStartupException(true);
dynamicResources.setServer(server);
dynamicResources.setContextPath(contextPath);
// Avoid configuring system classpath because we've only got a
single webapp
dynamicResources.setParentLoaderPriority(true);
dynamicResources.setResourceBase(moduleRoot.toString());
FilterHolder container =
dynamicResources.addFilter(containerClass, "/*",
EnumSet.allOf(DispatcherType.class));
container.setInitParameter("mode", mode.name());
container.setInitParameter("javax.ws.rs.Application",
MyApplication.class.getName());
HandlerList topLevelHandlers = new HandlerList();
topLevelHandlers.setHandlers(new Handler[]
{
staticResources, dynamicResources
});
ServerConnector httpConnector = new ServerConnector(server);
httpConnector.setPort(port);
server.addConnector(httpConnector);
server.setHandler(topLevelHandlers);
server.setStopAtShutdown(true);
try
{
server.start();
}
catch (IOException | AssertionError e)
{
stop();
throw e;
}
catch (Exception e)
{
stop();
throw new IOException(e);
}
return this;
}
/**
* Blocks until the server finishes launching.
* <p>
* @return this
* @throws InterruptedException if the thread is interrupted while
waiting
*/
public JettyServer join() throws InterruptedException
{
server.join();
return this;
}
/**
* Shuts down the server.
* <p>
* @return this
* @throws IOException if an I/O error occurs
*/
public JettyServer stop() throws IOException
{
try
{
server.stop();
}
catch (Exception e)
{
IOException ioException = new IOException(e);
ioException.addSuppressed(e);
throw ioException;
}
return this;
}
/**
* @return the base URI of the application
*/
public URI getBaseUri()
{
return server.getURI();
}
@Override
public void close() throws IOException
{
stop();
}
}
---------------
------------------------------
/**
* The context associated with a @Test.
* <p>
* @author Gili Tzabari
*/
public class TestClient
{
private final Client delegate;
private final URI baseUri;
/**
* Creates a new TestClient.
* <p>
* @param client the Jersey client to delegate to
* @param baseUri the base URI of the application
* @throws NullPointerException if client or baseUri are null
*/
public TestClient(Client client, URI baseUri)
{
Preconditions.requireThat(client, "client").isNotNull();
Preconditions.requireThat(baseUri, "baseUri").isNotNull();
this.delegate = client;
this.baseUri = baseUri;
}
/**
* @return a web resource whose URI refers to the base URI of the
application
*/
public WebResource resource()
{
return delegate.resource(baseUri);
}
// Convenience methods that facilitate construction of common
client-side classes
/**
* @return the companies resource
*/
public CompaniesResource companies()
{
return new CompaniesResource(resource().path("companies/"));
}
}
--------------
Gili
I just wanted to let you guys know that I managed to get run Jersey unit
tests in parallel using TestNG in "methods=parallel" mode, launching a
separate Jetty instance per @Test. My unit tests now complete 2x faster.
I don't know about you, but for me that is a very big deal.
My advice to you: dump JerseyTest. Its design is flawed and it doesn't
look like it'll get fixed anytime soon. You can replace Jetty with your
server of choice. Just dump the abstraction (JerseyTest) and talk
directly to the server.
Here is my code:
1. Unit tests extend "UnitTest" (below). It launches and shuts down a
separate server per @Test.
2. Each @Test accesses context-specific data through "TestClient". This
class holds a reference to the server URI and Jersey's Client instance.
3. The "JettyServer" class launches a Jetty instance against port 0
(meaning, it picks a random port number and ensures it is available
for use).
That's it. Easy as pie. I hope this helps other people.
-------------------
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.config.ClientConfig;
import com.sun.jersey.api.client.config.DefaultClientConfig;
import com.sun.jersey.api.client.filter.LoggingFilter;
import com.sun.jersey.api.json.JSONConfiguration;
import java.io.IOException;
import org.bitbucket.cowwoc.myapp.backend.JettyServer;
import org.bitbucket.cowwoc.myapp.backend.jackson.ObjectMapperProvider;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
/**
* Configures each @Test to run against a separate Jetty instance.
* <p>
* @author Gili Tzabari
*/
public abstract class UnitTest
{
private final ThreadLocal<JettyServer> jettyServer = new
ThreadLocal<>();
private final ThreadLocal<Client> jerseyClient = new ThreadLocal<>();
/**
* @return the context associated with the test
*/
protected TestClient getClient()
{
return new TestClient(jerseyClient.get(),
jettyServer.get().getBaseUri());
}
/**
* Launches a new server before each test.
* <p>
* @throws IOException if an I/O error occurs
*/
@BeforeMethod
protected void beforeTest() throws IOException
{
JettyServer server = new
JettyServer(TestContainer.class).setPort(0).start();
ClientConfig config = new DefaultClientConfig();
config.getFeatures().put(JSONConfiguration.FEATURE_POJO_MAPPING, true);
config.getClasses().add(ObjectMapperProvider.class);
Client client = Client.create(config);
client.addFilter(new LoggingFilter());
this.jettyServer.set(server);
this.jerseyClient.set(client);
}
/**
* Shuts down the server after each test.
* <p>
* @throws IOException if an I/O error occurs
*/
@AfterMethod
protected void afterTest() throws IOException
{
JettyServer server = this.jettyServer.get();
server.stop();
Client client = this.jerseyClient.get();
client.destroy();
this.jettyServer.remove();
this.jerseyClient.remove();
}
}
-------------------
-----------------------
import com.sun.jersey.spi.container.servlet.ServletContainer;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.EnumSet;
import javax.servlet.DispatcherType;
import org.bitbucket.cowwoc.preconditions.Preconditions;
import org.bitbucket.cowwoc.myapp.backend.jersey.MyApplication;
import org.bitbucket.cowwoc.myapp.backend.jersey.ServerMode;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.webapp.WebAppContext;
public final class JettyServer implements AutoCloseable
{
private final Server server = new Server();
private final Class<? extends ServletContainer> containerClass;
private int port = 80;
private String contextPath = "/";
private ServerMode mode = ServerMode.DEBUG;
/**
* Creates a new JettyServer.
* <p>
* @param containerClass the servlet or filter to respond to
incoming requests
* @throws NullPointerException if containerClass is null
*/
public JettyServer(Class<? extends ServletContainer> containerClass)
{
Preconditions.requireThat(containerClass,
"containerClass").isNotNull();
this.containerClass = containerClass;
}
/**
* Sets the port the server should listen on. The default value is
{@code 80}.
* <p>
* @param port 0 if a random port should be used
* @return this
*/
public JettyServer setPort(int port)
{
this.port = port;
return this;
}
/**
* @return the port the server should listen on
*/
public int getPort()
{
return port;
}
/**
* @return the port the server is listening on
* @throws IllegalStateException if the server is not running
*/
public int getBoundPort() throws IllegalStateException
{
// @see http://stackoverflow.com/a/14982831/14731
int result = ((NetworkConnector)
server.getConnectors()[0]).getLocalPort();
if (result < 0)
{
assert (!server.isStarted()): server;
throw new IllegalStateException("Server must be started");
}
return result;
}
/**
* @param contextPath the application context path. The default
value is {@code "/"}.
* @return this
* @throws NullPointerException if contextPath is null
*/
public JettyServer setContextPath(String contextPath)
{
Preconditions.requireThat(contextPath, "contextPath").isNotNull();
this.contextPath = contextPath;
return this;
}
/**
* @return the application context path
*/
public String getContextPath()
{
return contextPath;
}
/**
* @param mode the server mode. The default value is {@link
ServerMode#DEBUG}.
* @return this
* @throws NullPointerException if mode is null
*/
public JettyServer setServerMode(ServerMode mode)
{
Preconditions.requireThat(mode, "mode").isNotNull();
this.mode = mode;
return this;
}
/**
* @return the server mode
*/
public ServerMode getMode()
{
return mode;
}
/**
* Launches the server asynchronously.
* <p>
* @return this
* @throws IOException if an I/O error occurs
* @see #join()
*/
public JettyServer start() throws IOException
{
// Path to "target/classes"
Path moduleRoot = Modules.getRootPath(MyServer.class);
ResourceHandler sourceFiles = new ResourceHandler();
if (mode == ServerMode.DEBUG)
{
// Hotswap static resources during development
Path sourceDir =
moduleRoot.resolve("../../../frontend/src/main/webapp");
Preconditions.requireThat(sourceDir,
"sourceDir").isNotNull().exists();
sourceFiles.setResourceBase(sourceDir.toString());
}
Path webAppDir =
moduleRoot.resolve("../../../frontend/target/classes");
Preconditions.requireThat(webAppDir,
"webAppdir").isNotNull().exists();
ResourceHandler staticFiles = new ResourceHandler();
staticFiles.setResourceBase(webAppDir.toString());
HandlerList staticResourceHandlers = new HandlerList();
staticResourceHandlers.setHandlers(new Handler[]
{
sourceFiles, staticFiles
});
ContextHandler staticResources = new ContextHandler();
staticResources.setContextPath(contextPath);
staticResources.setHandler(staticResourceHandlers);
// Servlets and Javascript files loaded from META-INF/resources
in JAR files
WebAppContext dynamicResources = new WebAppContext();
dynamicResources.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed",
"false");
dynamicResources.setThrowUnavailableOnStartupException(true);
dynamicResources.setServer(server);
dynamicResources.setContextPath(contextPath);
// Avoid configuring system classpath because we've only got a
single webapp
dynamicResources.setParentLoaderPriority(true);
dynamicResources.setResourceBase(moduleRoot.toString());
FilterHolder container =
dynamicResources.addFilter(containerClass, "/*",
EnumSet.allOf(DispatcherType.class));
container.setInitParameter("mode", mode.name());
container.setInitParameter("javax.ws.rs.Application",
MyApplication.class.getName());
HandlerList topLevelHandlers = new HandlerList();
topLevelHandlers.setHandlers(new Handler[]
{
staticResources, dynamicResources
});
ServerConnector httpConnector = new ServerConnector(server);
httpConnector.setPort(port);
server.addConnector(httpConnector);
server.setHandler(topLevelHandlers);
server.setStopAtShutdown(true);
try
{
server.start();
}
catch (IOException | AssertionError e)
{
stop();
throw e;
}
catch (Exception e)
{
stop();
throw new IOException(e);
}
return this;
}
/**
* Blocks until the server finishes launching.
* <p>
* @return this
* @throws InterruptedException if the thread is interrupted while
waiting
*/
public JettyServer join() throws InterruptedException
{
server.join();
return this;
}
/**
* Shuts down the server.
* <p>
* @return this
* @throws IOException if an I/O error occurs
*/
public JettyServer stop() throws IOException
{
try
{
server.stop();
}
catch (Exception e)
{
IOException ioException = new IOException(e);
ioException.addSuppressed(e);
throw ioException;
}
return this;
}
/**
* @return the base URI of the application
*/
public URI getBaseUri()
{
return server.getURI();
}
@Override
public void close() throws IOException
{
stop();
}
}
---------------
------------------------------
/**
* The context associated with a @Test.
* <p>
* @author Gili Tzabari
*/
public class TestClient
{
private final Client delegate;
private final URI baseUri;
/**
* Creates a new TestClient.
* <p>
* @param client the Jersey client to delegate to
* @param baseUri the base URI of the application
* @throws NullPointerException if client or baseUri are null
*/
public TestClient(Client client, URI baseUri)
{
Preconditions.requireThat(client, "client").isNotNull();
Preconditions.requireThat(baseUri, "baseUri").isNotNull();
this.delegate = client;
this.baseUri = baseUri;
}
/**
* @return a web resource whose URI refers to the base URI of the
application
*/
public WebResource resource()
{
return delegate.resource(baseUri);
}
// Convenience methods that facilitate construction of common
client-side classes
/**
* @return the companies resource
*/
public CompaniesResource companies()
{
return new CompaniesResource(resource().path("companies/"));
}
}
--------------
Gili