Abstract

This document describes a method of wrapping HtmlUnit Page objects to provide increased code reuse while writing acceptance (user) tests for a web site. This implementation was evolved within, and successfully used for, a year-long Java project involving seven developers that built a single web site that contained three separate login-protected sections.

Implementation

During the course of the project, the developers wrote three different types of tests: unit tests (for testing pure code; tests did not hit the database and did not require any external resources like an application server to run; mockobjects-java-0.09 were used to prevent database and other external system access), database tests (for testing database-related code; tests actually hit the database when run) and acceptance tests (for simulating a user with a web browser hitting the web site deployed to an application server).

Three or four iterations into the project, one of the developers realized that a lot of code was being duplicated in various acceptance test classes. The code was duplicated when testing the same page in two different test classes, and when testing many different pages across many test classes. What eventually evolved was a system of factory classes and wrapper classes that allowed the development team to consolidate duplicate code across all web pages into an abstract base class, HtmlPageWrapper, and to consolidate duplicate code for a particular web page into a individual classes like FooPageWrapper, BarPageWrapper, etc.

The top-level class that begins the implementation is called PageWrapperFactory.


package acme.test;

import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.WebClient;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;

public class PageWrapperFactory {

    
public static PageWrapper instance(String url) {
        
URL theUrl = null;
        
try {
            
theUrl = new URL(url);
            
WebClient webClient = new WebClient();
            
webClient.setRedirectEnabled(true);
            return
PageWrapper.instance(webClient.getPage(theUrl));
        }
        
catch (MalformedURLException e) {
            
throw new RuntimeException(e);
        }
        
catch (IOException e) {
            
throw new RuntimeException(e);
        }
    }

    
public static PageWrapper instance(Page page) {
        return
PageWrapper.instance(page);
    }
}

The developers later created FooNavigator and BarNavigator classes that provided starting points for a particular type of user in the system. These navigator classes simply contained static methods that would return various PageWrapper objects by logging into the web site and navigating to the desired page to begin testing. The navigator classes were then used in the acceptance tests instead of calling PageWrapperFactory directly.

The PageWrapper class provided the default implementation for wrapping non-HtmlPage objects returned from HtmlUnit (such as TextPage or UnexpectedPage) until such time that a specific page wrapper implementation was needed.


package acme.test;

import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import java.io.IOException;
import java.net.URL;

public class PageWrapper {

    
private Page page;

    
PageWrapper(Page page) {
        
this.page = page;
    }

    
public Page getPage() {
        return
page;
    }

    
public static PageWrapper instance(Page page) {
        if (
page instanceof HtmlPage) {
            return
HtmlPageWrapperFactory.instance((HtmlPage) page);
        }
        
// Add checks for other Page types here as new PageWrappers are written
        
else {
            return new
PageWrapper(page);
        }
    }

    
public static PageWrapper instance(HtmlPageWrapper page, URL url) {
        
try {
            return
instance(page.getHtmlPage().getWebClient().getPage(url));
        }
        
catch (IOException e) {
            
throw new RuntimeException(e);
        }
    }

    
public String toString() {
        return
page.getWebResponse().getContentAsString();
    }
}

The HtmlPageWrapperFactory class was used to instantiate specific subclasses of the HtmlPageWrapper class based upon the title of the web page from the text of the <title></title> tags in each page. If you have control over the titles of each web page, this is the ideal way to map a particular web page to its page wrapper. Web frameworks like Struts use internal redirects, which means that you may not rely on the URL to determine the content of the web page.

However, if you don't have control over the web page titles, you may have to resort to the use of URLs, which the example class below demonstrates.

You may also be clever by creating a subclass hierarchy below HtmlPageWrapper, then adding more logic into the instance() method of a subclass to check other elements on a page in order to return different subclasses. This is the beauty of using an instance() method rather than a constructor: you may return a subclass at your discretion!

Note that an anonymous instance of HtmlPageWrapper is returned if no specific page wrapper class is found by the factory.


package acme.test;

import acme.test.BarPageWrapper;
import acme.test.BazPageWrapper;
import acme.test.FooPageWrapper;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

public class HtmlPageWrapperFactory {

    
private static Map titles;
    
private static Map urls;

    static {
        
titles = new HashMap();
        
titles.put(BarPageWrapper.PAGE_TITLE, BarPageWrapper.class);
        
titles.put(FooPageWrapper.PAGE_TITLE, FooPageWrapper.class);

        
urls = new HashMap();
        
urls.put(BazPageWrapper.URL, BazPageWrapper.class);
    }

    
public static HtmlPageWrapper instance(HtmlPage page) {

        Class
klass = (Class) titles.get(page.getTitleText());
        if (
klass == null) {
            
klass = (Class) urls.get(getPathNoQueryString(page.getWebResponse().getUrl()));
        }

        if (
klass != null) {
            
try {
                
Method method = klass.getDeclaredMethod("instance", new Class[]{HtmlPage.class});
                return (
HtmlPageWrapper) method.invoke(klass, new Object[]{page});
            }
            
catch (NoSuchMethodException e) {
                
throw new RuntimeException(e);
            }
            
catch (IllegalAccessException e) {
                
throw new RuntimeException(e);
            }
            
catch (InvocationTargetException e) {
                
throw new RuntimeException(e);
            }
        }

        return new
HtmlPageWrapper(page) {
            
protected HtmlFormWrapper form() {
                
throw new IllegalStateException("Not implemented in anonymous class");
            }
        };
    }

    
private static String getPathNoQueryString(URL url) {
        
String path = url.toString();
        
int index = path.indexOf("?");
        if (
index > 0) {
            
path = path.substring(0, index);
        }
        return
path;
    }
}

The HtmlPageWrapper class itself contains the HtmlPage object, and provides common utility (convenience) methods that are used across all web pages such as clicking on a link by its text and returning a PageWrapper?. The actual HtmlPageWrapper class that evolved had many more utility methods on it that were moved up from specific page wrapper classes as the application evolved.

Note that the abstract form() method assumes that all web pages have only one main form. This assumption worked well in the application that the developers built, and greatly simplified some of the utility methods available on the class. Without this assumption, a form name would have to be passed into nearly every method.


package acme.test;

import com.gargoylesoftware.htmlunit.ElementNotFoundException;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.html.HtmlAnchor;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import java.io.IOException;
import java.util.List;

public abstract class HtmlPageWrapper extends PageWrapper {

    
protected HtmlPageWrapper(Page page) {
        
super(page);
    }

    
protected abstract HtmlFormWrapper form();

    
public PageWrapper clickLink(String linkText) {
        
try {
            return
PageWrapperFactory.instance(getAnchorByText(linkText).click());
        }
        
catch (IOException e) {
            
throw new RuntimeException(e);
        }
    }

    
protected HtmlAnchor getAnchorByText(String linkText) {
        List
anchors = getHtmlPage().getAnchors();
        for (
int i = 0; i < anchors.size(); i++) {
            
HtmlAnchor anchor = (HtmlAnchor) anchors.get(i);
            if (
linkText.equals(anchor.asText())) {
                return
anchor;
            }
        }
        
throw new ElementNotFoundException("a", "linkText", linkText);
    }

    
protected HtmlPage getHtmlPage() {
        return (
HtmlPage) getPage();
    }
}

The HtmlFormWrapper class wraps the HtmlForm class, similar to the way that the HtmlPageWrapper class wraps the Htmlpage class. Many utility methods may be moved into this class during the course of development. Note that unlike the HtmlPageWrapper class, though, there was never a need to subclass the HtmlFormWrapper for each web page. Differences in forms were handled by specific methods in each page wrapper subclass.


package acme.test;

import com.gargoylesoftware.htmlunit.html.HtmlElement;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlImageInput;
import com.gargoylesoftware.htmlunit.html.HtmlPasswordInput;
import com.gargoylesoftware.htmlunit.html.HtmlSubmitInput;
import com.gargoylesoftware.htmlunit.html.HtmlTextInput;
import com.gargoylesoftware.htmlunit.html.HtmlInput;
import java.io.IOException;
import java.util.List;

public class HtmlFormWrapper {

    
private HtmlForm form;

    
HtmlFormWrapper(HtmlForm form) {
        
this.form = form;
    }

    
public static HtmlFormWrapper instance(HtmlForm form) {
        return new
HtmlFormWrapper(form);
    }

    
public void setPasswordBox(String elementName, String value) {
        
HtmlPasswordInput passwordBox = (HtmlPasswordInput) form.getInputByName(elementName);
        
passwordBox.setValueAttribute(value);
    }

    
public void setTextBox(String elementName, String value) {
        
HtmlTextInput textBox = (HtmlTextInput) form.getInputByName(elementName);
        
textBox.setValueAttribute(value);
    }

    
public HtmlPageWrapper clickImageButton(String name, int x, int y) {
        
try {
            
HtmlImageInput imageButton = (HtmlImageInput) form.getInputByName(name);
            return (
HtmlPageWrapper) PageWrapperFactory.instance(imageButton.click(x, y));
        }
        
catch (IOException e) {
            
throw new RuntimeException(e);
        }
    }

    
public HtmlPageWrapper clickSubmitButton(String buttonName) {
        
try {
            
HtmlSubmitInput button = (HtmlSubmitInput) form.getInputByName(buttonName);
            return (
HtmlPageWrapper) PageWrapperFactory.instance(button.click());
        }
        
catch (IOException e) {
            
throw new RuntimeException(e);
        }
    }
}

Finally, HtmlPageWrapper must be extended for each specific web page. These subclasses provide a place for code that is used to fill out forms, submit forms, verify the contents of the web page, and other activities specific to this particular web page. This prevents duplication of code throughout the acceptance tests, and makes for much cleaner and more readable tests.


package acme.test;

import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;

public class LoginPageWrapper extends HtmlPageWrapper {

    
private static final String FORM_NAME = "LoginForm";
    
private static final String IMAGE_BUTTON_SIGN_IN = "SignIn";
    
private static final String TEXT_BOX_PASSWORD = "password";
    
private static final String TEXT_BOX_USERNAME = "username";

    
public static final String PAGE_TITLE = "Login Page";

    
protected LoginPageWrapper(HtmlPage page) {
        
super(page);
    }

    
public static LoginPageWrapper instance(HtmlPage page) {
        return new
LoginPageWrapper(page);
    }

    
private void setPassword(String password) {
        
form().setPasswordBox(TEXT_BOX_PASSWORD, password);
    }

    
private void setUsername(String username) {
        
form().setTextBox(TEXT_BOX_USERNAME, username);
    }

    
private HtmlPageWrapper clickSignIn() {
        return
form().clickImageButton(IMAGE_BUTTON_SIGN_IN, 50, 18);
    }

    
public MainMenuPageWrapper signIn(String username, String password) {
        
setUsername(username);
        
setPassword(password);
        return (
MainMenuPageWrapper) clickSignIn();
    }

    
protected HtmlFormWrapper form() {
        return
HtmlFormWrapper.instance((HtmlForm) getHtmlPage().getFormByName(FORM_NAME));
    }
}

Written by David Kilzer <ddkilzer (@) yahoo (.) com>.