Testing J2EE applications

Recipes for testing enterprise apps

We advocate testing an application by testing its components thoroughly and then integrating those components as simply as possible. Specifically, "integration" for us is little more than choosing which implementations of various interfaces to use and then creating an application entry point object with references to those implementations. Which logging strategy do we use? How about log4J! We know that our components work with any implementation of the logging strategy interface. What kind of model? A JDBC-based (Java Database Connectivity) one, although our controller really only knows about our model interface, so an in-memory implementation, or one based on Prevayler will do. To us, this is integration. As a result, we tend not to emphasize end-to-end testing for correctness, but rather to give us confidence that we have built the features we needed to build. Object tests tell you whether you built the thing right; whereas end-to-end tests help you decide whether you built the right thing.

There are certain aspects of J2EE applications that people associate with end-to-end tests rather than object tests. These include page flow—or navigating a Web application—and using container services, such as security and transactions. We discuss these topics in recipes in this chapter, showing you how to test these behaviors in isolation—as object tests. We do not want to give the impression that we shy away from end-to-end tests—that is, testing an application by simulating the way an end user interacts with it through its end-user interface. We use end-to-end tests to play a different role than other programmers do: we use end-to-end tests to help us determine whether what we have built actually does what our customers need. We no longer see JUnit as the best tool available for testing an application from end to end. We use Fit and its companion tool, FitNesse.

Note: We are certainly not the only people who see end-to-end tests in this role: we got the idea from the Agile community at large. Still, while Agile developers remain in the minority, organizations will continue to see end-to-end tests as their primary tool for validating software, an approach that we feel ultimately wastes resources that could be better spent ensuring correctness from the inside out through programmer tests.

We will describe Fit only briefly. Imagine writing tests entirely as spreadsheets and word-processor documents. You could annotate your tests with plain-language descriptions of what they verify— mix code and text together so that both the programmers and the nonprogrammers can follow them. Now imagine running those tests through software that decorates your documents green for the parts that are right (tests pass) and red for the parts that are wrong (tests fail). That is Fit, and it allows those with business knowledge to write executable tests, even if they are not programmers. FitNesse is a Wiki that can execute Fit tests, providing an excellent way to organize them and collaborate on them. Many people have designated FitNesse as "the way they do end-to-end tests." (Pronounced "fit-NESS." Micah Martin, cocreator of FitNesse, tells the story how Uncle Bob (Robert C. Martin) was tired of executing Fit tests from the command line and wanted to execute them "with finesse.")

But this is a chapter on writing object tests for aspects of J2EE applications that one usually tests from end to end. In here is a collection of recipes that will help you test certain aspects of J2EE applications more effectively. These are behaviors that tend to be sprinkled throughout the application: page flow, broken links, security, transactions, and JNDI (Java Naming and Directory Interface). We have added a recipe related to the Struts application framework, but for more on testing Struts applications, we recommend the StrutsTestCase project. It provides both a mock-objects approach (testing outside the application server) and a Cactus approach (testing inside the application server). It embodies many of the techniques we have described, and, rather than duplicate their fine work, we refer you to them.

Test page flow

Problem

You want to verify that a user can navigate through your Web application's pages correctly, and you want to make the verification without involving all the machinery of the application itself.

Background

Even though you will execute end-to-end tests that can help uncover problems with page flow, it is generally easier to verify page flow without involving your application's business logic, presentation layer, external resources, and so on. What we recommend you do is translate your Web application into a large state diagram where moving from page to page depends on two things:

  1. The action the user took
  2. The result of the action the user took

Some actions have a single outcome, such as clicking a link to browse a catalog. For the most part, that action cannot fail—when you click that link, you end up at the catalog page. Some actions have multiple outcomes, such as "OK" and "failed." At a registration page, for example, if submitting the registration form fails, then the shopper stays at the registration page. In your diagram, you can label those arrows using a format of action/result, so that "submit/OK" means "follow this arrow if submitting the form is successful." When we model page flow this way, it does not look very complex, and so it ought to be relatively easy to test—after all, they are just names of pages, names of actions, and names of results. They are all strings! How hard can that be?

Not hard at all. If you can focus your attention like this on just the page flow of your system, then certainly you can extract the page-to-page navigation code from your system into a single navigation "engine" that operates on navigation data. The Struts Web application framework3 works on this principle, and we discuss how much simpler it is to verify page flow on a Struts application in "Test Navigation Rules in a Struts Application." (We recommend James Turner and Kevin Bedell, Struts Kick Start (Sams Publishing, 2002) as well as Ted Husted et al., Struts in Action (Manning, 2002). The former is an excellent tutorial and the latter shows you what Struts can really do.)

This recipe shows a simple example of how to create a refactoring safety net, refactor navigation logic to a single class, and then verify the resulting data.

Recipe

We will start with what seems to be the simpler test: an end-to-end test that verifies the ability to move from one page to another. Returning to our Coffee Shop application, we first verify that we can move from the Welcome page to the Catalog page. We can use HtmlUnit to first load the Welcome page, and then push the Browse Catalog button and verify that the resulting Webpage is indeed the Coffee Catalog page. Of course, before we execute this test, we need to deploy the application to a live application server and start the server. You can see the test in question in Listing 1.

Listing 1. NavigationTest, a sample page flow test

package junit.cookbook.coffee.endtoend.test;
import java.net.URL;
import junit.framework.TestCase;
import com.gargoylesoftware.htmlunit.*;
import com.gargoylesoftware.htmlunit.html.*;
public class NavigationTest extends TestCase {
   private WebClient webClient;
   protected void setUp() throws Exception {
      webClient = new WebClient();
      webClient.setRedirectEnabled(true);
   }
   public void testNavigateToCatalog() throws Exception {
      Page page =
         webClient.getPage(
            new URL("http://localhost:8080/coffeeShop/"));
      assertTrue(
         "Welcome page not an HTML page",
         page instanceof HtmlPage);
      HtmlPage welcomePage = (HtmlPage) page;
      HtmlForm launchPointsForm =
          welcomePage.getFormByName("launchPoints");
      HtmlInput htmlInput =
         launchPointsForm.getInputByName("browseCatalog");
      assertTrue(
         "'browseCatalog' is not a submit button",
         htmlInput instanceof HtmlSubmitInput);
      HtmlSubmitInput browseCatalogSubmit =
         (HtmlSubmitInput) htmlInput;
      Page page2 = browseCatalogSubmit.click();
      assertTrue(
         "Catalog page not an HTML page",
         page2 instanceof HtmlPage);
      HtmlPage catalogPage = (HtmlPage) page2;
      assertEquals(
         "Coffee Shop - Catalog",
         catalogPage.getTitleText());
   }
}

There are a number of things about this test that are worthy of concern. We need to deploy the application and start the application server in order to execute it. Although this seems like a reasonable requirement to verify page flow, any number of unrelated problems can make it impossible to execute this test: EJB (Enterprise JavaBeans) deployment problems, servlet/URL mapping problems, and so on. That is not to say that these problems are not important enough to fix, but we generally prefer to verify them separately. These tests are meant to verify page flow and nothing else.

The test hard-codes information about the server to which the application is deployed (localhost) and the port on which it is listening (8080). Both of these pieces of information vary from environment to environment, so at a minimum, you ought to refactor the information to some external source, such as a configuration file. This makes the tests slightly more complex to configure and execute correctly.

The test depends on the correctness of the Webpages themselves, which is not guaranteed. If there is a problem with either the Welcome page or the Catalog page, then this test might fail, even though the problem is not navigation related. Certainly you will test the pages themselves in isolation using the techniques in Chapter 12, "Testing Web Components," so there is no need to duplicate that effort here.

This test hard-codes information about the structure of the Webpages—the form named launchPoints and the submit button named browseCatalog. If someone changes these names, then these tests will fail, making them somewhat brittle. A Web author ought to be able to change that button to a link, get the URL right, and the only tests to fail would be the ones for the page itself. This test does not allow that to happen.

Now please do not get us wrong: the foregoing is not an indictment of HtmlUnit. Far from it. HtmlUnit is an excellent package that does a very good job of automating end-to-end tests. With its focus on analyzing the result of a Web request—its comprehensive HTML page object model—HtmlUnit is an ideal choice for automating end-to-end tests for Web applications, so HtmlUnit is not the issue here. The issue is using a hammer to kill a fly, as it were: using end-to-end tests (no matter how you write them) to verify page flow invites the kinds of problems we have just described. Instead, we ought to write tests that focus on the navigation rules themselves.

This is the kind of test we want to write, assuming the existence of an object representing a "navigation engine:"

public void testNavigateToCatalog() {
   assertEquals(
      "Catalog Page",
      navigationEngine.getNextLocation(
         "Browse Catalog",
         "OK"));
}

This test simply says, "If I push the button marked Browse Catalog and everything goes OK, then I should be taken to the Catalog page." The test is expressed in a somewhat abstract fashion in terms of locations, actions, and results: a location is usually a Webpage, an action is usually either submitting a form or clicking a link, and a result is a description of the result of the action. If we want to talk in terms of a finite state machine, the locations are the machine's states and the action/result pairs are the machine's transitions. We can model navigating through our site entirely in terms of locations and actions.

A location corresponds to the URI of a Webpage or a Webpage template (Velocity template or JSP (JavaServer Pages)). An action corresponds to the URI of a form submit button or of a hypertext link. This means that we need some way to translate incoming request URIs into locations and actions, and vice versa. Once we give each URI the name of either a location or an action, we can ignore the details of which JSP displays the catalog page or which request parameter indicates "add a product to the shopcart." We can test that all separately.

First, Listing 2 shows our Coffee Shop Controller using a separate requesttoaction mapper.

Listing 2. CoffeeShopController using an Action Mapper

private void handleRequest(
   HttpServletRequest request,
   HttpServletResponse response)
   throws IOException, ServletException {
   String forwardUri = "index.html";
   String userName = "jbrains";
   try {
      String actionName = actionMapper.getActionName(request);
      log("Performing action: " + actionName);
      if ("Browse Catalog".equals(actionName)) {
         CoffeeCatalog catalog = model.getCatalog();
         CatalogView view = new CatalogView(request);
         view.setCatalog(catalog);
         forwardUri = view.getUri();
      }
      else if ("Add to Shopcart".equals(actionName)) {
         AddToShopcartCommand command =
            makeAddToShopcartCommand(request);
         executeCommand(command);
      }
      else {
         log("I don't understand action " + actionName);
      }
   }
   catch (Exception wrapped) {
      throw new ServletException(wrapped);
   }
   request.getRequestDispatcher(forwardUri).forward(
      request,
      response);
}

Here, actionMapper is an object of type HttpServletRequestToActionMapper, for which we have started with the tests in Listing 3.

Listing 3. MapRequestToActionTest

package junit.cookbook.coffee.web.test;
import java.util.*;
import java.util.regex.*;
import java.util.regex.Pattern;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.*;
import junit.cookbook.coffee.HttpServletRequestToActionMapper;
import junit.framework.TestCase;
import org.apache.catalina.connector.HttpRequestBase;
import com.diasparsoftware.java.util.*;
import com.diasparsoftware.javax.servlet.http.*;
public class MapRequestToActionTest extends TestCase {
   private HttpServletRequestToActionMapper actionMapper;
   protected void setUp() throws Exception {
      actionMapper = new HttpServletRequestToActionMapper();
   }
   public void testBrowseCatalogAction() throws Exception {
      Map parameters =
         Collections.singletonMap(
            "browseCatalog",
            new String[] { "catalog" });
      doTestMapAction(
         "Browse Catalog",
         "/coffeeShop/coffee",
         parameters);
   }
   public void testAddToShopcart() throws Exception {
      HashMap parameters = new HashMap() {
         {
            put("addToShopcart-18", new String[] { "Buy!" });
            put("quantity-18", new String[] { "5" });
         }
      };
      doTestMapAction(
         "Add to Shopcart",
         "/coffeeShop/coffee",
         parameters);
   }
   private void doTestMapAction(
      String expectedActionName,
      String uri,
      Map parameters) {
      HttpServletRequest request =
         HttpUtil.makeRequestIgnoreSession(uri, parameters);
      assertEquals(
         expectedActionName,
         actionMapper.getActionName(request));
   }
}

These tests create a fake HttpServletRequest using HttpUtil from Diasparsoft Toolkit. As the method name implies, we create a request without worrying about keeping track of session information, as we do not care about session information for these tests. The more variables you can eliminate in a test, the better. The request-to-action mapper is simple: turn a request into the name of an action. This test case is a good candidate to be turned into a parameterized test case.

We have built a location-to-URI mapper in a similar style: it turns location names into URIs. After adding that into the equation, our servlet's request handler method can now be seen in Listing 4.

Listing 4. CoffeeShopController using the location mapper

private void handleRequest(
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
String userName = "jbrains";
String nextLocationName = "Welcome";
try {
   String actionName = actionMapper.getActionName(request);
   log("Performing action: " + actionName);
   if (knownActions.contains(actionName) == false) {
      log("I don't understand action " + actionName);
   }
   else {
      String actionResult = "OK";
      if ("Browse Catalog".equals(actionName)) {
         CoffeeCatalog catalog = model.getCatalog();
         CatalogView view = new CatalogView(request);
         view.setCatalog(catalog);
      }
      else if ("Add to Shopcart".equals(actionName)) {
         AddToShopcartCommand command =
            makeAddToShopcartCommand(request);
         executeCommand(command);
      }
      nextLocationName =
         navigationEngine.getNextLocation(actionName, "OK");
   }
}
catch (Exception wrapped) {
   throw new ServletException(wrapped);
}
String forwardUri = locationMapper.getUri(nextLocationName);
request.getRequestDispatcher(forwardUri).forward(
   request,
   response);
}

The overall behavior of the request handler is straightforward:

  1. Interpret the incoming request to determine which action the user wants to perform.
  2. Perform the action, assuming it goes "OK." If the action fails for some reason, set the value of result to some short description of the failure. For example, if the user tries to add -1 kg of Sumatra to his shopcart, set result to invalid quantity.
  3. Ask the navigator for the next location, based on the action performed and the result of that action.
  4. Determine the URI that corresponds to the next location.
  5. Provide that URI to the request dispatcher.

This design makes it possible to test the rest of your application's navigation rules—no matter how complex they might be—without actually running the servlet! Of course, you should write at least one test suite that verifies that the servlet invokes the mappers and the navigator. Use either ServletUnit or mock objects, depending on the technique with which you feel most comfortable.

Discussion

Once you have written these tests and extracted the navigation rules into an easily tested object, you might notice a striking similarity to the Struts Web application framework. With Struts, you specify navigation rules as data in struts-config.xml and the "navigation engine" operates on that data, rather than having navigation rules strewn about the site, as is common. Not only is the Struts approach easy to understand and maintain, it is easy to test: one can substitute dummy Actions programmed to return the desired ActionForward to verify the expected navigation path. See the recipe in the next section for more.

Test navigation rules in a Struts application

Problem

You want to test page-to-page navigation in your Struts application, preferably without starting Struts.

Background

Testing navigation rules using end-to-end tests is expensive. We described the issues in the previous recipe. Here, we are interested in verifying the navigation rules for a Struts application. Using end-to-end tests to do this is no less expensive for a Struts application than for any other type of Web application. Using a framework does not make end-to-end tests any simpler than not using a framework. What Struts does, however, is provide a way to test navigation rules without resorting to end-to-end tests, something that makes isolated navigation tests remarkably easy.

Recipe

The most direct approach you can use is to verify the content of struts-config.xml using XMLUnit. Here we will show a few example tests. We will use the sample struts-config.xml currently posted at the Struts Website. Listing 5 shows some tests. (The tests here will fail without an Internet connection, because XMLUnit will try to validate the XML document against its DTD.)

Listing 5. XMLUnit tests for struts-config.xml

package junit.cookbook.coffee.web.test;
import java.io.*;
import junit.extensions.TestSetup;
import junit.framework.*;
import org.custommonkey.xmlunit.XMLUnit;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
public class StrutsNavigationTest extends StrutsConfigFixture {
   private static Document strutsConfigDocument;
   public static Test suite() {
      TestSetup setup =
      new TestSetup(new TestSuite(StrutsNavigationTest.class)) {
      private String strutsConfigFilename =
         "test/data/sample-struts-config.xml";
      protected void setUp() throws Exception {
         XMLUnit.setIgnoreWhitespace(true);
         strutsConfigDocument =
            XMLUnit.buildTestDocument(
               new InputSource(
                  new FileReader(
                     new File(strutsConfigFilename))));
      }
   };
   return setup;
}
public void testLogonSubmitActionExists() throws Exception {
   assertXpathExists(
      getActionXpath("/LogonSubmit"),
      strutsConfigDocument);
   }
   public void testLogonSubmitActionSuccessMappingExists()
      throws Exception {
      assertXpathExists(
         getActionForwardXpath("/LogonSubmit"),
         strutsConfigDocument);
   }
   public void testLogonSubmitActionSuccessMapsToWelcome()
      throws Exception {
      assertXpathEvaluatesTo(
         "/Welcome.do",
         getActionForwardPathXpath("/LogonSubmit", "success"),
         strutsConfigDocument);
   }
}

The Struts configuration file combines navigation rules with the mapping between locations and URIs. When an action forwards to another action, it uses a navigation rule; whereas, actions that forward to page templates (a JSP or a Velocity template) are location/URI mapping rules. In this way, the Struts configuration file plays the role of navigation engine as well as location mapper, as we described them in the previous recipe. You can use the same approach to test location mappings as for navigation rules.

Discussion

There are a few things to notice about the tests in this recipe. First, notice that we load the Struts configuration file using one-time setup. Next, notice the methods getActionXpath(), getActionForwardXpath(), and getActionForwardPathXpath(). These methods translate the concepts of "action" and "action forward" to the corresponding XPath locations in struts-config.xml. Not only do you not need to remember the various XPath expressions for actions and action forwards, but you also avoid duplicating those expressions in case of future changes in the Struts configuration file DTD. We extracted a fixture class StrutsConfigFixture and pulled those methods up into the new fixture class for reuse. Listing 6 shows these methods.

Listing 6. A sample fixture for struts-config.xml tests

package junit.cookbook.coffee.web.test;
import org.custommonkey.xmlunit.XMLTestCase;
public abstract class StrutsConfigFixture extends XMLTestCase {
   protected String getActionForwardPathXpath(
      String action,
      String forward) {
      return getActionXpath(action)
         + "/forward[@name='" + forward + "']/@path";
   }
   protected String getActionXpath(String path) {
      return "/struts-config/action-mappings/action[@path='"
   + path + "']";
   }
   protected String getActionForwardXpath(String action) {
      return getActionXpath(action) + "/forward";
   }
}

Notice the incremental style of the tests. This is a good approach to take when verifying XML documents with XPath, because when an XPath-based assertion fails, there is no easy way to determine the cause. Perhaps you mistyped the name of an XML element three levels down in the expression, or you forgot to include an "at" sign (@) for an attribute. By writing many small, increasingly specific tests, it is easier to determine the problem by observing which tests fail. For an action mapping, consider these three tests:

  1. Is the action configured at all?
  2. Does it have any forwards?
  3. Are its forwards correct?

Writing three tests rather than just the third one makes it possible to say that, for example, if the second and third tests both fail, then there is a problem with the Struts configuration file—there is an action without a forward.

J.B. Rainsberger is a developer and consultant who has been a leader in the JUnit community since 2001. His popular online tutorial JUnit: A Starter Guide is read by thousands of new JUnit users each month. Rainsberger lives in Toronto, Canada.

Learn more about this topic

Join the discussion
Be the first to comment on this article. Our Commenting Policies
See more