Learn how to extend Selenium framework. Integrate your own locator strategy to PageFactory - Java

As any good framework Selenium is extendable. While providing quite reach functionality out of the box it still unable to guess any potential need of a hypothetical project that makes use of it. Exactly for such the cases Selenium developers provide the capabilities to extend basic functionality. It this article we’ll take a look at one of such ways to extend the framework. Namely to add our own locator strategy (extending By class) and to integrate it to PageFactory tool set.

Why do I ever need special strategy if I already have xpath

Generally for your comfort. If some locator strategy is relevant and effective for your system, then such strategy is worth describing in terms which are understandable by Selenium. Besides your particular WebDriver might natively support some specific locators which cannot be implemented using the standard set. Thus you will need to add that strategy in order to be able to use it in your tests.

Example description

In one of my posts I explained how to interact with SVG-elements in Selenium. In that article I used a kind of classic approach to locating elements. For example in order to find one of the elements I used the command like driver.findElements(By.xpath("//*[name()='circle'"));.

Actually that time the xpath was much more specific, however I shorten it for demonstration sake.

If I would have such locators appearing often enough it would make sense to wrap them in a separate locator strategy, and, probably, integrate it into PageFactory infrastructure. So, in our example we’re going to extend Selenium functionality so that:

  • We’ll get our own locator strategy which extends By class. That strategy will locate SVG-elements which are of shape class.

  • We’ll get our own annotation (something like @FindBySvg), where we could specify the type of a shape which we’d like to associate with page object field.

  • We’ll be able to use our own annotation and standard Selenium annotations for different fields of the same page.

Implementing locator strategy

What is a locator strategy? It is a method that would be applied in order to find some element which is expected to be a part of some user interface. All WebDriver implementations have to comply W3C standard, thus support xpath locator, CSS locator and some other ones. The algorithm itself is implemented inside a WebDriver, we’re only let it know what we need to find. As I have already mentioned, we’re going to search SVG shapes. In order to minimize different typos of people who will be using our strategy, we’ll introduce enum that will contain the complete enumeration of supported shapes.

package click.webelement.pagefactory.customby.bys;

public enum SvgShape {
    CIRCLE, ELLIPSE, LINE, PATH, POLYGON, POLYLINE, RECT, NONE;

    @Override
    public String toString() {
        if(this.equals(NONE)){
            return "";
        }
        return super.toString().toLowerCase();
    }
}

After that we need to describe a class that would extend abstract class By and implement findElements method there. That method should return a list of elements which meet specified locator. We’re going to adhere the design that was introduced by Selenium developers. That means we’ll create our custom strategy class as static inner class like it is done in standard library. In the case of standard library we would write driver.findElement(By.xpath(…​)), but in our extension we’ll write driver.findElement(CustomBy.svgShape(…​)). To make such syntax possible we need to introduce a class structure like shown below.

package click.webelement.pagefactory.customby.bys;

import org.openqa.selenium.By;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;

import java.io.Serializable;
import java.util.List;


/**
 * This By is used to look up svg shape elements which have the given shape type
 */
public class CustomBy{

    public static By svgShape(SvgShape shape){
        return new BySvgShape(shape);
    }

    public static class BySvgShape extends By implements Serializable{

        SvgShape shape;

        public BySvgShape(SvgShape shape){
            this.shape = shape;
        }

        @Override
        public List<WebElement> findElements(SearchContext context) {
            return context.findElements(By.xpath("//*[name()='svg']//*[name()='" + shape.toString() + "']"));
        }

        @Override
        public String toString() {
            return "By shape: " + shape.toString();
        }
    }

}

So the idea of our extension is in inner BySvgShape class. We extend abstract By class that requires to override public List<WebElement> findElements(SearchContext context). Our implementation is built on top of existing xpath locator strategy. By the way we can see some flaws (a.k.a. features) here. For example:

  1. We implement our search on some very basic model of SVG image, which, for example, does not support nested SVG images. Actually if we’ll run such search on image with nested SVG it will return some result but I would not rely on it. The example we’re working with is applicable to demonstrate Selenium extendability and works properly with only the image that is presented on this site.

  2. When we translate object representation of shapes (enum) the result will always be in lowercase which might not work with your site if your tags are in upper case since we use the tag names as comparison pair to name() function of xpath which is case-sensitive.

Check our strategy in tests

We’re on half-way to our goal. We can use our own strategy in tests already. Lets look at complete example. We’re going to create one test with classic approach to element location and then after some new classes are introduced we’ll add a test with PageFactory approach. Here is classic approach with our new strategy.

package click.webelement.pagefactory.customby;

import click.webelement.pagefactory.customby.bys.CustomBy;
import click.webelement.pagefactory.customby.bys.SvgShape;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;

import java.util.List;

public class CustomByTest {

    WebDriver driver;

    @BeforeAll
    public static void globalSetup() {
        System.setProperty("webdriver.gecko.driver", "/home/alexey/Dev/webdrivers/geckodriver");
    }

    @BeforeEach
    public void setUp() {
        driver = new FirefoxDriver();
    }

    @Test
    public void testClassicApproach(){
        driver.get("https://webelement.click/stand/svg?lang=en");
        List<WebElement> circlesRoot = driver.findElements(CustomBy.svgShape(SvgShape.CIRCLE));
        List<WebElement> circlesParentContext = driver
                .findElement(By.className("post-body"))
                .findElements(CustomBy.svgShape(SvgShape.CIRCLE));
        List<WebElement> circlesWrongContext = driver
                .findElement(By.className("left-pane"))
                .findElements(CustomBy.svgShape(SvgShape.CIRCLE));
        Assertions.assertEquals(4, circlesRoot.size()
                , "Four circles are found from root");
        Assertions.assertEquals(4, circlesParentContext.size()
                , "Four circles are found from parent context");
        Assertions.assertEquals(0, circlesWrongContext.size()
                , "Zero circles are found from wrong context");
    }

    @AfterEach
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }

    }

}

Here is how our test class looks in our example. It already contains one test and include all required harness to housekeep the tests. So I won’t be reprint it further on but just show a new test method when demonstrate PageFactory approach. You ill be able to add that one to test class and execute it.

Implement @FindBySvg annotation

Our next goal is to make our strategy working with PageFactory. To do that we need to annotate fields and somehow let PageFactory know that such annotation should be treated as an entity that is carrying information about locator strategy and it should be used for binding page object field to the element of user interface.

Actually nearly everything which is related to annotations in Java is a part of a big topic that is called "Reflection" - a mechanism that allows your program to analyse itself in runtime. If you know how reflection works then it is very good, otherwise you will probably need to google around using some key words from this article.

Annotation code that would meet our requirements is given below. Later we will review it in more details.

package click.webelement.pagefactory.customby.bys;

import org.openqa.selenium.By;
import org.openqa.selenium.support.AbstractFindByBuilder;
import org.openqa.selenium.support.PageFactoryFinder;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@PageFactoryFinder(FindBySvg.FindByCustomBuilder.class)

public @interface FindBySvg {

    SvgShape shape() default SvgShape.NONE;

    public static class FindByCustomBuilder extends AbstractFindByBuilder {
        public By buildIt(Object annotation, Field field) {
            FindBySvg findBy = (FindBySvg) annotation;
            return CustomBy.svgShape(findBy.shape());
        }

    }

}

Attention: following text contains excessive mention of word "annotation" and its derivatives. Take away impressionable and excitable people from the screen.

So let me describe what you can see above.

  • public @interface FindBySvg - this is a syntax that is used for describing annotations. Our annotation is annotated itself. Those annotations describe the way we’re going to use our new annotation.

    • @Retention(RetentionPolicy.RUNTIME) is telling that we will be able to access our annotation in runtime

    • @Target(ElementType.FIELD) is telling that we can apply our annotations to fields only

    • @PageFactoryFinder(FindBySvg.FindByCustomBuilder.class) - this is Selenium annotation (unlike previous two) which is supplied with the framework. It is used in order to specify which class would be used for creating locator strategy that is associated with annotated field (that class should extend abstract class AbstractFindByBuilder).

  • SvgShape shape() default SvgShape.NONE; means that we are going to annotate the field in this way: @FindBySvg(shape = SvgShape.CIRCLE). In other words we’ll be specifying the parameter that will accept one of the values from SvgShape enumeration. If we omit the parameter then it will be taken as default SvgShape.NONE.

This part:

public static class FindByCustomBuilder extends AbstractFindByBuilder {
	public By buildIt(Object annotation, Field field) {
		FindBySvg findBy = (FindBySvg) annotation;
		return CustomBy.svgShape(findBy.shape());
	}

}

this piece of code takes its place in general outline in the following way.

When PageFactory starts processing the class we have marked up, the processing logic is going through DefaultElementLocator class (if we didn’t change the default behavior intentionally). That ElementLocator is being created basing on each particular field of our page object. The code logic extracts all the annotations the field has been marked with and if it encounters the annotations which themselves are annotated with PageFactoryFinder annotation the value of the latter one is taken (in our case it is FindBySvg.FindByCustomBuilder.class) which represents a reference to a class.

Using reflection mechanisms Java creates an object that is an instance of mentioned class (this is why we had to implement buildIt() method - otherwise the class would remain abstract and the instance wouldn’t be able to be created) and invokes buildIt() method. Hence we create an instance that implements ElementLocator for every particular field that incorporate the reference to By object that defines locator strategy for that field.

We’ll look at all the stuff related to PageFactory in my next article.

Now we have our custom annotation and we can use it for annotating the field of our class. Unfortunately our solution is still not mature enough. Sometimes attempts to initialize the page using our new annotation will result in that the field would be assigned with null.

Extending DefaultFieldDecorator

So, why does the above issue happen? The fact is that the standard approach eventually encounters DefaultFieldDecorator class. That entity is responsible for creating a proxy-object and assigning it to a field that is being decorated. All the information about the field we were talking about before in such or another way is propagated to that entity and used for decoration.

Class DefaultFieldDecorator that already exists in standard Selenium library, and that is used in standard approach does some checks before it starts decorating the fields. It tries to test if the field is applicable for decorating. It tests for two simple criteria:

  • if the field is of 'WebElement' type

  • if the field of List<WebElement> type

The second criteria is problematic. To check that the class has a dedicated method - protected boolean isDecoratableList(Field field). Fortunately it is not private, hence can be overridden. The problem of that method is that it has the following code:

if (field.getAnnotation(FindBy.class) == null &&
	field.getAnnotation(FindBys.class) == null &&
	field.getAnnotation(FindAll.class) == null) {
	return false;
}

Obviously it misses our new annotation and the only way to add it is to override the method in our custom class that extends DefaultFieldDecorator. Unfortunately we will have to copy-paste all remaining code of the method as is for only adding one line to the check logic. The class would look like this:

package click.webelement.pagefactory.customby.bys;

import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindAll;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.FindBys;
import org.openqa.selenium.support.pagefactory.DefaultFieldDecorator;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;

import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;

public class CustomFieldDecorator extends DefaultFieldDecorator {

    public CustomFieldDecorator(ElementLocatorFactory factory) {
        super(factory);
    }

    @Override
    protected boolean isDecoratableList(Field field) {
        if (!List.class.isAssignableFrom(field.getType())) {
            return false;
        }

        // Type erasure in Java isn't complete. Attempt to discover the generic
        // type of the list.
        Type genericType = field.getGenericType();
        if (!(genericType instanceof ParameterizedType)) {
            return false;
        }

        Type listType = ((ParameterizedType) genericType).getActualTypeArguments()[0];

        if (!WebElement.class.equals(listType)) {
            return false;
        }

        if (field.getAnnotation(FindBy.class) == null &&
            field.getAnnotation(FindBys.class) == null &&
            field.getAnnotation(FindAll.class) == null &&
            field.getAnnotation(FindBySvg.class) == null) {
            return false;
        }

        return true;
    }
}

Such extension will allow us to use the same approach to decorate as the field annotated with our new annotation as the fields annotated with standard annotations. The only one touch is still needs to be taken.

Now how do I apply everything?

We have done great job. By the moment we have:

  • Our custom BySvgShape locator strategy

  • Our custom @FindBySvg annotation

  • Our custom CustomFieldDecorator class that extends DefaultFieldDecorator with adding that new annotation to allowable list for elements of type List<WebElement>.

The trick is that Selenium will take care of everything out of listed above. However how do we tell Selenium to use our extended entities instead of default ones? For such purpose PageFactory has several initElements(…​) methods. One of them is initElements(FieldDecorator decorator, Object page). Since we have own FieldDecorator (CustomFieldDecorator class that extends DefaultFieldDecorator class) we can pass an anonymous object as decorator parameter of mentioned PageFactory method. When create new object of CustomFieldDecorator we have to specify parameter of ElementLocatorFactory which is to be propagated to parent’s fields but since we didn’t touch any stuff related to factories we can use the default implementation that Selenium provides. Finally we’ll be using the line like this:

PageFactory.initElements(new CustomFieldDecorator(new DefaultElementLocatorFactory(driver)), this);

Let’s see how the sample page class would look with our custom annotation and some default Selenium annotation.

package click.webelement.pagefactory.customby;

import click.webelement.pagefactory.customby.bys.CustomFieldDecorator;
import click.webelement.pagefactory.customby.bys.FindBySvg;
import click.webelement.pagefactory.customby.bys.SvgShape;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory;

import java.util.List;

public class SvgPage {

    @FindBySvg(shape = SvgShape.CIRCLE)
    private List<WebElement> circles;

    @FindBy(xpath = "//h2")
    private WebElement header;

    public SvgPage(WebDriver driver){
        PageFactory.initElements(new CustomFieldDecorator(new DefaultElementLocatorFactory(driver)), this);
    }

    public void printCustomElements(){
        circles.stream().forEach(System.out::println);
    }

    public void printNativeElement(){
        System.out.println(header.getText());
    }

}

Given model demonstrates object representation of the page of SVG-stand that is presented on this site. Lets now look at the test that demonstrates how we access to the page fields (as we have agreed before I do not provide the entire test class, nut only relevant test method so that you can add it to the test class presented in the beginning of the article).

@Test
public void testPageObject() {
	driver.get("https://webelement.click/stand/svg?lang=en");
	SvgPage page = new SvgPage(driver);
	page.printCustomElements();
	page.printNativeElement();
}

This test does not actually test anything but just shows how one can access page fields and transfer the approach to some real-life case.

If you still have the questions please send them to me using this form. I will amend the article according to your feedback.