Wrapping WebElement 2
Automating the Wrappers
December 10, 2012In the previous post, I outlined a basic WebElement
wrapper called Element
. Element
as a WebElement
wrapper
can only go so far in guaranteeing behavior. For starts the structure to wrap a WebElement
is cumbersome:
Checkbox cb = new CheckBox(checkBoxWebElement);
In this article I'll explain how we push WebDriver into generating pages using descendants of the Element
interface.
The following article is a bit tricky; I'd suggest looking at the existing WebDriver code,
starting with PageFactory
, and working through the various decorators it uses, and proxies it generates. Then,
if you're not familiar, study the concept of reflection, because classes like Proxy
and Construtor
are crucial to
this design.
We have to replace a few classes that generate WebElement
s, starting with PageFactory
. The
default implementation can only generate new driver-dependent WebElement
implementations. This is the first thing we
need to fix.
First, we need to create a decorator that recognizes our new interfaces. It works on WebElement descendants including
our own Element
and its descendants. This allows us to leave existing WebElement
s until functionality is available
to wrap them; they will still work as they have before.
/* WrappedElementDecorator recognizes a few things that DefaultFieldDecorator does not. */
public class ElementDecorator implements FieldDecorator {
/* factory to use when generating ElementLocator. */
protected ElementLocatorFactory factory;
/* Constructor for an ElementLocatorFactory. */
public ElementDecorator(ElementLocatorFactory factory) {
this.factory = factory;
}
@Override
public Object decorate(ClassLoader loader, Field field) {
if (!(WebElement.class.isAssignableFrom(field.getType()) || isDecoratableList(field))) {
return null;
}
ElementLocator locator = factory.createLocator(field);
if (locator == null) {
return null;
}
Class<?> fieldType = field.getType();
if (WebElement.class.equals(fieldType)) {
fieldType = Element.class;
}
if (WebElement.class.isAssignableFrom(fieldType)) {
return proxyForLocator(loader, fieldType, locator);
} else if (List.class.isAssignableFrom(fieldType)) {
Class<?> erasureClass = getErasureClass(field);
return proxyForListLocator(loader, erasureClass, locator);
} else {
return null;
}
}
private Class getErasureClass(Field field) {
Type genericType = field.getGenericType();
if (!(genericType instanceof ParameterizedType)) {
return null;
}
return (Class) ((ParameterizedType) genericType).getActualTypeArguments()[0];
}
private boolean isDecoratableList(Field field) {
if (!List.class.isAssignableFrom(field.getType())) {
return false;
}
Class erasureClass = getErasureClass(field);
if (erasureClass == null || !WebElement.class.isAssignableFrom(erasureClass)) {
return false;
}
if (field.getAnnotation(FindBy.class) == null && field.getAnnotation(FindBys.class) == null) {
return false;
}
return true;
}
/* Generate a type-parameterized locator proxy for the element in question. */
protected <T> T proxyForLocator(ClassLoader loader, Class<T> interfaceType, ElementLocator locator) {
InvocationHandler handler = new ElementHandler(interfaceType, locator);
T proxy;
proxy = interfaceType.cast(Proxy.newProxyInstance(
loader, new Class[]{interfaceType, WebElement.class, WrapsElement.class, Locatable.class}, handler));
return proxy;
}
/* generates a proxy for a list of elements to be wrapped. */
@SuppressWarnings("unchecked")
protected <T> List<T> proxyForListLocator(ClassLoader loader, Class<T> interfaceType, ElementLocator locator) {
InvocationHandler handler = new ElementListHandler(interfaceType, locator);
List<T> proxy;
proxy = (List<T>) Proxy.newProxyInstance(
loader, new Class[]{List.class}, handler);
return proxy;
}
}
Two bits of code are the core of this change. First:
Class<?> fieldType = field.getType();
if (WebElement.class.equals(fieldType)) {
fieldType = Element.class;
}
This code determines the type of the interface being wrapped, which is stored below by the proxyForLocator
and
proxyForListLocator
methods. Note the signature of these two essential methods. Where the original code guaranteed
the WebElement interface, we have to genericize the arguments:
protected <T> T proxyForLocator(ClassLoader loader, Class<T> interfaceType, ElementLocator locator);
protected <T> List<T> proxyForListLocator(ClassLoader loader, Class<T> interfaceType, ElementLocator locator);
Next up is ElementFactory
:
/* Element factory for wrapped elements. */
public class ElementFactory extends PageFactory {
/* Initializes a page factory from a class with a template of Elements. */
public static <T> T initElements(WebDriver driver, Class<T> pageClassToProxy) {
try {
T page = pageClassToProxy.getConstructor(WebDriver.class).newInstance(driver);
PageFactory.initElements(
new ElementDecorator(
new DefaultElementLocatorFactory(driver)), page);
return page;
} catch (InstantiationException | IllegalAccessException
| InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
}
ElementFactory
replaces page factory. Though it's functionally similar, this is the point where we replace the
DefaultFieldDecorator (which finds WebElements) with our ElementDecorator
.
We need to generate the elements underlying the types. We need to make some generic factories for the proxy
objects,
and they need to understand the implementation as well.
/* Replaces DefaultLocatingElementHandler. */
public class ElementHandler implements InvocationHandler {
private final ElementLocator locator;
private final Class<?> wrappingType;
/* Generates a handler to retrieve the WebElement from a locator for
a given WebElement interface descendant. */
public <T> ElementHandler(Class<T> interfaceType, ElementLocator locator) {
this.locator = locator;
if (!Element.class.isAssignableFrom(interfaceType)) {
throw new RuntimeException("interface not assignable to Element.");
}
this.wrappingType = getWrapperClass(interfaceType);
}
@Override
public Object invoke(Object object, Method method, Object[] objects) throws Throwable {
WebElement element = locator.findElement();
if ("getWrappedElement".equals(method.getName())) {
return element;
}
Constructor cons = wrappingType.getConstructor(WebElement.class);
Object thing = cons.newInstance(element);
try {
return method.invoke(wrappingType.cast(thing), objects);
} catch (InvocationTargetException e) {
// Unwrap the underlying exception
throw e.getCause();
}
}
}
This class's most important job is to wrap the returned WebElement in the implementation of the interface,
and cast it to the interface itself.
First we get the class of the wrapping type:
this.wrappingType = getWrapperClass(interfaceType);
Then we generate the object using the constructor on the implementation. All we have to do is wrap it during the
Proxy
's invoke:
Constructor cons = wrappingType.getConstructor(WebElement.class);
Object thing = cons.newInstance(element);
And then cast the element before invoking the method on it:
return method.invoke(wrappingType.cast(thing), objects);
Example
The new example is an embellishment of the original test code from the previous post. It adds in the automatic generation of elements, and the test generates itself from a static factory, in order to show all of the features in their completed form.
public class Part2ExampleTest {
private final WebDriver driver;
@FindBy(id = "checkbox")
CheckBox checkBox;
protected Part2ExampleTest(WebDriver driver) {
this.driver = driver;
}
protected static Part2ExampleTest initialize(WebDriver driver) {
return ElementFactory.initElements(driver, Part2ExampleTest.class);
}
@Test
public void simple() {
WebDriver driver = new FirefoxDriver();
Part2ExampleTest page = initialize(driver);
PageLoader.get(driver, "forms.html");
Assert.assertFalse(page.checkBox.isChecked());
page.checkBox.check();
Assert.assertTrue(page.checkBox.isChecked());
driver.close();
}
}
Conclusion
With all of the hard work behind us, we now have a factory that can generate both individual Element
s as well as List<Element>
fields. There are a few things here to consider.
I'm going to work on fleshing out the rest of this library, called selophane. If you want,
you can pull its master from its project page.