The Role of Refactoring
A crucial point we touched on in a number of places is the importance of refactoring when testing enterprise applications.
The fundamental issue with many of these systems is that they are heavyweight processes that have many expectations about their environment. The environment doesn't just include containers and servers; it also includes APIs. For example, code that is coupled to javax.servlet.HttpSession is tricky to test because we'd have to lug in an implementation of that API along with its idiosyncrasies.
Likewise, a servlet is not easy to test. It needs to be deployed into a container, and we'd need to find a request and response to feed to it, and then even if we did manage all that in a test environment, the next problem we'd have to deal with is interrogating the response to a sufficient degree to obtain meaningful assertions that aren't fragile and susceptible to breaking from minor UI changes.
This issue crops up time and time again in almost all the enterprise APIs. EJB2 is a great (and horrific) example of components that are tightly controlled and managed by an expensive container (in terms of start-up and how difficult it is to embed). Similarly, JMS suffers from the same issues and requires a message broker to control the message flow.
The problem isn't restricted just to Java APIs. Enterprise systems often have to work with legacy applications, applications that are expensive and cumbersome to start up or use in a testing scenario.
Refactoring is that great wrench in our toolbox that can help attack all of these problems. Of course, there are many times when it's just not possible to sidestep that ugly, difficult API we need to work with. Refactoring might help minimize the pain, but sometimes there isn't really an easy way to avoid expensive, time-consuming tests, and that's fine, despite what you might read elsewhere!
A Concrete Example
Let's have a look at a concrete problem shown in Listing 3-18. We'd like to test a login servlet. The servlet takes in a request, checks the user name and password, and if they're valid, puts a token in the session denoting that the user is logged in.
Listing 3-18. Example of a login servlet
protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException { String username = req.getParameter("username"); String password = req.getParameter("password"); LoginToken token; HttpSession session = req.getSession(false); if(session != null) { token = (LoginToken)session.getAttribute("logintoken"); // if user is logged in, then we ignore the login attempt if(token.getUserName().equals(username)) { return; } } Connection c = null; try { Object o = new InitialContext().lookup("jdbc/MyDS"); DataSource ds = (DataSource)o; c = ds.getConnection(); // some SQL calls // manipulate the result set // extract data from the result set and verify password // assuming login is valid... token = new LoginToken(username); } catch(NamingException e) { throw new ServletException("Error looking up data source", e); } catch(SQLException e) { throw new ServletException("Error obtaining connection", e); } finally { if(c != null) { try { c.close(); } catch(SQLException e) { // just log it, not much else we can do log.error("Error closing connection " + c, e); } } } if(token != null) { session = req.getSession(true); session.setAttribute("logintoken", token); // record other info too session.setAttribute("loginTime", System.currentTimeMillis()); } }
This code in Listing 3-18 is quite common. It should be fairly obvious what it does from just glancing at it. The test approaches at our disposal are these.
- Embed a servlet container.
- Test it in the container by remote invoking it once it's deployed.
- Refactor!
No prizes awarded for choosing the last option, given that that is what this section is about!
What's so bad about the other two options? They'd both work equally well; the problem is their overhead. For the embedding solution, we'd still have to go through all the servlet code just to get at the meat of our business functionality. The second approach is even worse; it's not possible to use it during rapid development, and it incurs a very expensive overhead for servlet container start-up. Also, both of these solutions have a lot of collateral damage, in terms of testing APIs and functionality, that's incidental to the desired testing.
We know we need to refactor; how do we do it?
Let's examine what the code does at a high level:
- Takes in a number of parameters
- Performs a check against the environment (in this case, the session)
- Uses the parameters to query a data source
- Creates a value to place in the environment, along with some extra information
The objective of the refactoring is to capture these goals without resorting to a servlet-specific implementation so that, having done so, the servlet can act as a simple shell to the object that will now encapsulate the functionality.
The object we'll create is a LoginManager. A first stab at this appears in Listing 3-19.
Listing 3-19. Initial attempt at a refactored LoginManager
public class LoginManager { public LoginToken login(String username, String password) { try { Object o = new InitialContext().lookup("jdbc/MyDS"); DataSource ds = (DataSource)o; Connection c = ds.getConnection(); // some SQL calls // manipulate the result set // extract data from the result set // assuming login is valid... return new LoginToken(username); } catch(Exception e) { throw new RuntimeException("Error looking up DataSource", e); } } }
We ripped out the database calls from the servlet into this new class, which takes in the parameters from the request. Note that this new class is quite testable—we can create an instance of it without any servlet or container dependency.
When we try to do so, Listing 3-20 shows the next container dependency.
Listing 3-20. Lookup code for the data source
new InitialContext().lookup("jdbc/MyDS");
This works only in an environment where JNDI is already configured. While this is easily doable in the test (as we'll see in Chapter 4), it's another tangential concern that we shouldn't need to worry about for the purposes of our test. The solution here is to switch from the Service Locator pattern (where objects look up their dependency) to a Dependency Injection pattern. This is achieved by adding a DataSource property (a field with a getter/setter pair) in our UserManager and having the caller set it.
In terms of database interaction, our test right now is good enough. There's no need to try to abstract away the database. We could employ any of the approaches we discussed earlier for handling database connectivity inside of tests. We'll focus the remainder of our discussion on the role of refactoring and how to identify abstractions through testing.
The test still doesn't quite handle all of the functionality we require. The servlet still has to take care of populating the environment (the HTTP session) with the right information. Whether this is a problem or not depends on our use case. Do we expect the implementation of the login method to require more information from its environment in the future? Are we expecting that components other than the servlet will need to invoke LoginManager?
If we don't, then we're done, and we can stop refactoring now and start testing. However, assuming we do need to worry about the environment and abstracting that away, how do we achieve that?
The key issue here is that we're not really doing anything special with the HTTP session; we're simply treating it as a map of contextual information. It's not type safe, and pretty much anything can read attributes and dump others in. Ideally, we'd like to just pass in the session to the login method, but that would impose a servlet API burden.
Since all we do is use it as a map, we could instead add a Map parameter to the login method. This allows the implementation to manipulate the contents as needed. Listing 3-21 shows the new method signature.
Listing 3-21. Refactored signature of the login method
public LoginToken login(Map session, String username, String password)
The method could even return void and deal with putting the login token in the session map itself.
However, this approach presents a problem for the servlet. How does it deal with this map and ensure that it corresponds to the session map? In an ideal world, HttpSession would implement Map, or we'd be able to make it extend Map as well; neither is realistic at this point, so instead we create a proxy wrapper that handles all the messy work of synchronizing the contents for us.
The proxy in Listing 3-22 is a map implementation that wraps the javax.servlet.HttpSession object.
Listing 3-22. HttpSession-backed Map implementation
public class SessionMap extends AbstractMap { private final HttpSession session; private Set entries; public SessionMap(HttpSession s) { this.session = s; } public Set entrySet() { if(entries == null) { entries = new HashSet(); // loop over session attribute names // create new Map.Entry anonymous inner class // with attribute name/value // ensure setValue on the entry modifies underlying session // add to entries } return entries; } public Object put(Object key, Object value) { entries = null; Object originalValue = session.getAttribute(key.toString()); session.setAttribute(key.toString(), value); return originalValue; } public Object get(Object key) { return session.getAttribute(key.toString()); } public Object remove(Object key) { entries = null; Object value = get(key); session.removeAttribute(key.toString()); return value; } public void clear() { entries = null; session.invalidate(); } }
The entrySet() method is not implemented here (since it's quite verbose), but the idea is more important than the details.
Using the SessionMap class, our servlet can now easily pass in a map to the LoginManager, which can then make any reads/writes to the map that will be automatically reflected in the underlying HTTP session. This change now means that during testing, we can easily have the test supply a HashMap instead of the SessionMap, populate it with any values that need to be tested, and make assertions about its contents after invoking the LoginManager.
It can certainly be debated whether the SessionMap refactoring is the right approach or not; one could argue that we're mixing concerns here and having the login method do too much work by manipulating a map. It's also possible to argue that the loss of typing and weaker contract for callers means that it's more error prone. These are valid arguments, but the point of this exercise is to highlight an approach, not to specify a particular solution.
This example shows the power of refactoring as a testing aid and illustrates how even seemingly awkward APIs that need to be interacted with in complex ways can be abstracted away. The abstraction means that during testing, we can use simpler objects that are trivial to construct and thus test. A side effect of increasing testability is an improvement in the design. Refactoring for testability helps identify roles and concerns in a practical "in the code" way that's often much harder to spot when drawing design diagrams.
While the wrapper we created is verbose, it's something that we would write just once, and from then on it would become a valuable tool that could be reused many times. The time invested in writing it will pay off quickly in better code that is more testable.
An In-Container Approach
Servlets specifically can have an interesting in-container testing mechanism using the servlet API. This mechanism is Servlet Filters. It is possible, for example, to specify a test filter that can act on any requests to the servlet.
This approach allows us to noninvasively test a given servlet. The filter has access to the request before the servlet is invoked, so it can verify that the right parameters are specified. It can also interact with the request and response after the servlet has manipulated them and so is able to also make assertions about the state of the request, response, or session.
While this might not be as convenient from a testing standpoint as the earlier refactoring, it nevertheless introduces an important tool in our testing toolbox: the callback approach. Many APIs allow for custom code to be run before or after a component is invoked. Tests can take advantage of this mechanism and can thus get into locations where it's often difficult or tricky to have test code. A filter's access to HttpServletRequest and HttpServletResponse is one example of this since both of these objects are difficult to get outside of the servlet environment.