Implementing Page Blocks

Implementing Page Blocks

I’ve been looking forward to getting around to this for some time. A chance to have a  go at extending WebDriver’s default PageFactory implementation.

So what do I mean by a PageBlock?

On many websites there are reusable panels that appear on many pages.

  1. Many are repeatable blocks within a page and are used in lists such as the article summaries on the homepage.
  2. Others occur on many pages throughout a site but are unique within the page such as the individual widgets that can be seen on the right hand column of this page.

Ideally, we want a ‘PageFactory object’ that we can use to find the fields of such blocks. We need to be able to define a root element that acts as the search context for the internal fields of the class.

Investigation

Time for some detective work. I have been told that the C# .net bindings have out of the box support for blocks unlike Java so now is a good time to fire up Visual Studio and have a look at the .net PageFactory.cs class:

        public static T InitElements<T>(IWebDriver driver)
        
        public static T InitElements<T>(IElementLocator locator)

        public static void InitElements(ISearchContext driver, object page)

        public static void InitElements(ISearchContext driver, object page, IPageObjectMemberDecorator decorator)

        public static void InitElements(object page, IElementLocator locator)

        public static void InitElements(object page, IElementLocator locator, IPageObjectMemberDecorator decorator)

and comparing this to the Java equivalent:

  public static <T> T initElements(WebDriver driver, Class<T> pageClassToProxy)
  
  public static void initElements(WebDriver driver, Object page)
  
  public static void initElements(ElementLocatorFactory factory, Object page)
  
  public static void initElements(FieldDecorator decorator, Object page)

We can easily see that the WebDriver options available in C# actually accept a (I)SearchContext whereas Java accepts only an actual WebDriver instance. Whilst I could provide my own PageFactory implementation, I really don’t want to be doing that, so do the other methods help at all?

One takes an ElementLocatorFactory, and the other a FieldDecorator, so lets have a look at these methods in more detail:

  public static void initElements(WebDriver driver, Object page) {
    final WebDriver driverRef = driver;
    initElements(new DefaultElementLocatorFactory(driverRef), page);
  }

  public static void initElements(ElementLocatorFactory factory, Object page) {
    final ElementLocatorFactory factoryRef = factory;
    initElements(new DefaultFieldDecorator(factoryRef), page);
  }

Given that the constructor for a FieldDecorator takes an ElementLocatorFactory, it looks like the ElementLocator / ElementLocatorFactory is a place we can do the magic. Looking at what actually happens:

  public static void initElements(FieldDecorator decorator, Object page) {
    Class<?> proxyIn = page.getClass();
    while (proxyIn != Object.class) {
      proxyFields(decorator, page, proxyIn);
      proxyIn = proxyIn.getSuperclass();
    }
  }

  private static void proxyFields(FieldDecorator decorator, Object page, Class<?> proxyIn) {
    Field[] fields = proxyIn.getDeclaredFields();
    for (Field field : fields) {
      Object value = decorator.decorate(page.getClass().getClassLoader(), field);
      if (value != null) {
        try {
          field.setAccessible(true);
          field.set(page, value);
        } catch (IllegalAccessException e) {
          throw new RuntimeException(e);
        }
      }
    }
  }

For each field in the PageObject, the PageFactory calls the decorate(page.getClass().getClassLoader(), field) method of the FieldDecorator instance:

  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;
    }

    if (WebElement.class.isAssignableFrom(field.getType())) {
      return proxyForLocator(loader, locator);
    } else if (List.class.isAssignableFrom(field.getType())) {
      return proxyForListLocator(loader, locator);
    } else {
      return null;
    }
  }

and this in turn passes the field to the ElementLocatorFactory to generate an ElementLocator which calls proxyForLocator(locator, loader)

  protected WebElement proxyForLocator(ClassLoader loader, ElementLocator locator) {
    InvocationHandler handler = new LocatingElementHandler(locator);

    WebElement proxy;
    proxy = (WebElement) Proxy.newProxyInstance(
        loader, new Class[]{WebElement.class, WrapsElement.class, Locatable.class}, handler);
    return proxy;
  }

which instantiates a LocatingElementHandler which is used as the InvocationHandler required to generate the proxy WebElement:

  public Object invoke(Object object, Method method, Object[] objects) throws Throwable {
    WebElement element;
    try {
      element = locator.findElement();
    } catch (NoSuchElementException e) {
      if ("toString".equals(method.getName())) {
        return "Proxy element for: " + locator.toString();
      }
      throw e;
    }

    if ("getWrappedElement".equals(method.getName())) {
      return element;
    }

    try {
      return method.invoke(element, objects);
    } catch (InvocationTargetException e) {
      // Unwrap the underlying exception
      throw e.getCause();
    }
  }

A similar process occurs for List<WebElement> locators so I shall spare you the details.

Looking at the DefaultElementLocator in more detail:

// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

package org.openqa.selenium.support.pagefactory;

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

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

/**
 * The default element locator, which will lazily locate an element or an element list on a page. This class is
 * designed for use with the {@link org.openqa.selenium.support.PageFactory} and understands the
 * annotations {@link org.openqa.selenium.support.FindBy} and {@link org.openqa.selenium.support.CacheLookup}.
 */
public class DefaultElementLocator implements ElementLocator {
  private final SearchContext searchContext;
  private final boolean shouldCache;
  private final By by;
  private WebElement cachedElement;
  private List<WebElement> cachedElementList;

  /**
   * Creates a new element locator.
   *
   * @param searchContext The context to use when finding the element
   * @param field The field on the Page Object that will hold the located value
   */
  public DefaultElementLocator(SearchContext searchContext, Field field) {
    this(searchContext, new Annotations(field));
  }

  /**
   * Use this constructor in order to process custom annotaions.
   *
   * @param searchContext The context to use when finding the element
   * @param annotations AbstractAnnotations class implementation
   */
  public DefaultElementLocator(SearchContext searchContext, AbstractAnnotations annotations) {
    this.searchContext = searchContext;
    this.shouldCache = annotations.isLookupCached();
    this.by = annotations.buildBy();
  }

  /**
   * Find the element.
   */
  public WebElement findElement() {
    if (cachedElement != null && shouldCache) {
      return cachedElement;
    }

    WebElement element = searchContext.findElement(by);
    if (shouldCache) {
      cachedElement = element;
    }

    return element;
  }

  /**
   * Find the element list.
   */
  public List<WebElement> findElements() {
    if (cachedElementList != null && shouldCache) {
      return cachedElementList;
    }

    List<WebElement> elements = searchContext.findElements(by);
    if (shouldCache) {
      cachedElementList = elements;
    }

    return elements;
  }

  @Override
  public String toString() {
    return this.getClass().getSimpleName() + " '" + by + "'";
  }
}

There in line 69 is the line we need to change for the WebElements

  • WebElement element = searchContext.findElement(by);

and line 85 for the Lists

  • List<WebElement> elements = searchContext.findElements(by);

So what’s the plan?

If I was not using the PageFactory, this would be a simple

WebElement element = driver.findElement(By.{rootElementLocator}).findElement(By.{subElementLocator});

so I guess the simplest approach is to create a DefaultElementLocatorFactory using the rootElement (driver.findElement(By.{rootElementLocator})) instead of the driver in the constructor.

Block Type 1 – the repeatable list item

HomePage Article summaries:

package tech.alexontest.poftutor.pageblocks;

import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.CacheLookup;
import org.openqa.selenium.support.FindBy;

public class PostSummaryBlockDesktop implements PostSummaryBlock {

    @FindBy(css = ".entry-title")
    @CacheLookup
    private WebElement postTitle;

    @FindBy(css = ".entry-date .entry-date")
    @CacheLookup
    private WebElement postDate;

    @FindBy(css = ".author")
    @CacheLookup
    private WebElement authorName;

    @FindBy(css = ".entry-summary p")
    @CacheLookup
    private WebElement postSummary;

    @FindBy(css = ".read-more .screen-reader-text")
    @CacheLookup
    private WebElement readMore;

    public PostSummaryBlockDesktop(final WebElement rootElement) {
        final ElementLocatorFactory locatorFactory = 
            new DefaultElementLocatorFactory(rootElement);
        PageFactory.initElements(locatorFactory, this);
        this.rootElement = rootElement;
    }

    @Override
    public String getPostTitle() {
        return postTitle.getText();
    }

    @Override
    public String getAuthorName() {
        return authorName.getText();
    }

    @Override
    public String getPostDate() {
        return postDate.getText();
    }

    @Override
    public String getPostSummary() {
        return postSummary.getText();
    }

    @Override
    public String getReadMore() {
        return readMore.getText();
    }
}

This is called during runtime by passing a found article WebElement from my HomePageDesktop class, so modifying the getArticles method to return my new Blocks:

    @Override
    public List<PostSummaryBlockDesktop> getArticles() {
        return articles.stream()
                .map(PostSummaryBlockDesktop::new)
                .collect(Collectors.toList());
    }

Now we can run a simple demonstration ‘test’ to see if this all works:

    @Test
    @DisplayName("Block from a found rootElement Work Demonstration")
    void foundBlockWorks() {
        homePageSteps
                .printAllPostDetails();
    }

using a couple of new methods in my HomePageSteps

    public HomePageSteps printAllPostDetails() {
        homePage.getArticles()
                .forEach(this::printSummaryBlockDetails);
        return this;
    }

    private void printSummaryBlockDetails(final PostSummaryBlock postSummaryBlock) {
        System.out.println("Details of Summary Block:");
        System.out.println("");
        System.out.println(postSummaryBlock.getPostTitle() + " = Title");
        System.out.println(postSummaryBlock.getPostDate() + " = Date posted text");
        System.out.println(postSummaryBlock.getAuthorName() + " = Author");
        System.out.println("Post summary:");
        System.out.println(postSummaryBlock.getPostSummary());
        System.out.println(postSummaryBlock.getReadMore() + " = Read more button text");
        System.out.println("");
        System.out.println("---------------------------------------------------------------------------");
        System.out.println("");
    }

and off we go:

Mar 04, 2018 11:30:19 AM org.openqa.selenium.remote.ProtocolHandshake createSession
INFO: Detected dialect: W3C
FirefoxDriver Started
Details of Summary Block:

INJECTING THE WEBDRIVER NOT THE MANAGER. = Title
February 20, 2018 = Date posted text
Alexander = Author
Post summary:
Sorry! I forgot to mention last time that I was having a week off to spend some quality time with my long suffering wife and kids. I have added a fair deal of code, and I don’t plan on explaining all of it here, just the most relevant. Last time, I managed to get dependency injection working, but not in a way that I was at all happy with. My decision to create WebDriverManager classes led me to create a…
READ MORE = Read more button text

---------------------------------------------------------------------------

Details of Summary Block:

DEPENDENCY INJECTION AND ABSTRACTION. = Title
February 6, 2018 = Date posted text
Alexander = Author
Post summary:
Every rose has its thorn. In the unlikely event that you have read all of my posts, you will be aware that I have had a try at dependency injection before but wasn’t happy enough with the results to push them. After looking at it this week I have managed to understand where my difficulties lie and and implement a solution that works. I’m still not really happy with it, but its enough to be able to get going on…
READ MORE = Read more button text

---------------------------------------------------------------------------

Details of Summary Block:

ENTER THE MATRIX: SELENIUM GRID MADE SIMPLE = Title
January 30, 2018 = Date posted text
Alexander = Author
Post summary:
As I discussed in ‘Driver factory part 3 – Remotewebdrivers and my very own grid’ I found that it was pretty painless to set up a grid hub and node on a single Windows machine. Whilst the grid was itself perfectly stable, it is a bit of a pain to start up multiple command prompts and run individual nodes etc. There were two obvious improvements to this that I could try: Work out how to configure multiple browsers on a…
READ MORE = Read more button text

---------------------------------------------------------------------------

Details of Summary Block:

ALL IS NOT AS IT SEEMS: CHECKING THE RIGHT BROWSER LAUNCHED = Title
January 22, 2018 = Date posted text
Alexander = Author
Post summary:
When tests don’t work…. One of those issues that I could very easily have missed. Over Christmas, in between working through CheckStyle hell I had a play around with adding Opera to the list of browsers available on my grid. I know its kind of pointless as being based on the same engine as Chrome, but it is one of my favourite browsers so I thought I’d give it a go. I’ve made a few incremental changes to my grid…
READ MORE = Read more button text

---------------------------------------------------------------------------

Details of Summary Block:

CHECKSTYLE – ENFORCING A CODING STYLE: PART 4 = Title
January 15, 2018 = Date posted text
Alexander = Author
Post summary:
Final Thoughts After struggling to get the last few posts out I have adopted a more agile approach to my blog. Now, whatever state the post is in it, will be published on Monday evening. I didn’t quite get everything finished off last week so here goes with my final thoughts about implementing CheckStyle into a new project. The last changes in my CheckStyle implementation Having set the Javadoc rules to ignore in my ruleset, I of course remembered that…
READ MORE = Read more button text

---------------------------------------------------------------------------

Quitting Driver
WebDriver quit

Process finished with exit code 0

Perfect! Just what I was after.

Block Type 2 – the unique block defined by a rootElement locator as a field

‘Tag cloud’ widget:

This is a different business entirely. This panel is present on every / many pages, so clearly is an object that I should write once and use everywhere. Now clearly I could take the above approach and generate at runtime from the found WebElement, but that really goes against the ‘Inversion Of Control’ pattern that I am using. It should be like any other object that Guice can inject for me. So lets give it a try and see what happens. A similar approach to before:

package tech.alexontest.poftutor.pageblocks;

import com.google.inject.Inject;
import org.apache.commons.lang3.tuple.Pair;
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 org.openqa.selenium.support.pagefactory.ElementLocatorFactory;

import java.util.List;
import java.util.stream.Collectors;

public class TagCloudWidgetBlockDesktop implements TagCloudWidgetBlock {

    @FindBy(className = "widget_tag_cloud")
    private WebElement rootElement;

    @FindBy(className = "widget-title")
    private WebElement title;

    @FindBy(className = "tag-cloud-link")
    private List<WebElement> tags;

    @Inject
    public TagCloudWidgetBlockDesktop(final WebDriver webDriver) {
        final ElementLocatorFactory locatorFactory = new DefaultElementLocatorFactory(rootElement);
        PageFactory.initElements(locatorFactory, this);
    }

    @Override
    public WebElement getRootElement() {
        return rootElement;
    }

    @Override
    public String getTitle() {
        return title.getText();
    }

    @Override
    public List<Pair<String, String>> getTags() {
        return tags.stream()
                .map(we -> Pair.of(we.getText(), we.getAttribute("style")))
                .collect(Collectors.toList());
    }
}

Note this time I am defining the rootElement  with an @FindBy  annotation.

We can now add a tagCloudWidgetBlock as an injected field in the HomePageDesktop:

    private final TagCloudWidgetBlock tagCloudWidgetBlock;

    @Inject
    public HomePageDesktop(final WebDriver webDriver, final TagCloudWidgetBlock tagCloudWidgetBlock) {
        super(webDriver);
        this.tagCloudWidgetBlock = tagCloudWidgetBlock;
    }

    @Override
    public TagCloudWidgetBlock getTagCloudWidgetBlock() {
        return tagCloudWidgetBlock;
    }

Again adding a simple demonstration ‘test’:

    @Test
    @DisplayName("Block from a defined rootElement works")
    void definedBlockWorks() {
        homePageSteps
                .assertTagCloudTitle()
                .printTags();
    }

and HomePageSteps methods

    public HomePageSteps assertTagCloudTitle() {
        assertThat(homePage.getTagCloudWidgetBlock().getTitle())
                .as("Tag Could Title is correct")
                .isEqualToIgnoringCase("Tags");
        return this;
    }

    public HomePageSteps printTags() {
        homePage.getTagCloudWidgetBlock()
                .getTags()
                .forEach(p -> System.out.println(p.getLeft() + " : " + p.getRight()));
        return this;
    }

The result:

Failed Injection of default block
Injected Block using DefaulElementLocator fails

To be honest I am more surprised that it didn’t fail in the constructor when PageFactory.initElements(locatorFactory, this) is called given that, at this point, it is trying to use an as yet uninitialised PageFactory field as the SearchContext. So rather as expected I am going to have to get my hands dirty implementing my own ElementLocator and its ElementLocatorFactory to pass to the PageFactory to initialise the elements in the block. Looking back up at the DefaultElementLocator, this is likely to be fun: Time to get dirty playing with java.reflect…..

Writing a Custom ElementLocator:

I had several attempts at this before settling on my final solution. I started out by sending a By for my defined rootElement in the constructor using a private static String to hold the locator string so I can use it for the field as well without duplication. It’s yucky but I got it working.

public class TagCloudWidgetBlockDesktop implements TagCloudWidgetBlock {
    private static final String ROOT_ELEMENT_LOCATOR_CLASSNAME = "widget_tag_cloud";

    //is not locatable!!
    @FindBy(className = ROOT_ELEMENT_LOCATOR_CLASSNAME)
    private WebElement rootElement;
    
    .
    .
    
    @Inject
    TagCloudWidgetBlockDesktop(final WebDriver webDriver) {
        final BlockingElementLocatorFactory locatorFactory =
                new BlockingElementLocatorFactory(webDriver, By.className(ROOT_ELEMENT_LOCATOR_CLASSNAME));
        PageFactory.initElements(locatorFactory, this);
    }

    .
    .
}

The BlockingElementLocatorFactory is pretty simple:

package tech.alexontest.poftutor.infrastructure.pagefactory;

import org.openqa.selenium.By;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.support.pagefactory.DefaultElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;

import java.lang.reflect.Field;

public class BlockingElementLocatorFactory implements ElementLocatorFactory {
    private final SearchContext searchContext;

    private final By searchContextBy;

    public BlockingElementLocatorFactory(final SearchContext searchContext) {
        this(searchContext, null);
    }

    public BlockingElementLocatorFactory(final SearchContext searchContext, final By searchContextBy) {
        this.searchContext = searchContext;
        this.searchContextBy = searchContextBy;
    }

    @Override
    public ElementLocator createLocator(final Field field) {
        return searchContextBy == null
                ? new DefaultElementLocator(searchContext, field)
                : new BlockingElementLocator(searchContext, searchContextBy, field);
    }
}

I have just added a second constructor accepting the By for the rootElement locator, and I use the presence of the By field to decide which Locator class to return. So I can use this one factory for both types of Block just by using the appropriate constructor. Then the BlockingElementLocator itself:

package tech.alexontest.poftutor.infrastructure.pagefactory;

import org.openqa.selenium.By;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.pagefactory.AbstractAnnotations;
import org.openqa.selenium.support.pagefactory.Annotations;
import org.openqa.selenium.support.pagefactory.ElementLocator;

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

/**
 * A custom element locator, which will allow the pagefactory finders to work from a defined rootElement.
 * This will lazily locate an element or an element list under the defined rootElement. This class is
 * designed for use with the {@link org.openqa.selenium.support.PageFactory} and understands the
 * annotations {@link org.openqa.selenium.support.FindBy} and {@link org.openqa.selenium.support.CacheLookup}.
 */
public class BlockingElementLocator implements ElementLocator {

    private final SearchContext searchContext;

    private final By searchContextBy;

    private final boolean shouldCache;

    private final By by;

    private WebElement cachedElement;

    private List<WebElement> cachedElementList;

    /**
     * Constructor for a locator for a block defined by a PageFactory annotation
     *
     * @param searchContext The context to use when finding the element
     * @param searchContextBy the By locator for your defined class
     * @param field The field on the Page Object that will hold the located value
     */
    public BlockingElementLocator(final SearchContext searchContext,
                                  final By searchContextBy,
                                  final Field field) {
        this(searchContext, searchContextBy, new Annotations(field));
    }

    /**
     * Use this constructor in order to process custom annotations.
     *
     * @param searchContext The context to use when finding the element
     * @param searchContextBy the By locator for your defined class
     * @param annotations AbstractAnnotations class implementation
     */
    public BlockingElementLocator(final SearchContext searchContext,
                                  final By searchContextBy,
                                  final AbstractAnnotations annotations) {
        this.searchContext = searchContext;
        this.searchContextBy = searchContextBy;
        this.shouldCache = annotations.isLookupCached();
        this.by = annotations.buildBy();
    }

    /**
     * Find the element.
     */
    public WebElement findElement() {
        if (cachedElement != null && shouldCache) {
            return cachedElement;
        }

        final WebElement element = searchContext.findElement(searchContextBy).findElement(by);
        if (shouldCache) {
            cachedElement = element;
        }

        return element;
    }

    /**
     * Find the element list.
     */
    public List<WebElement> findElements() {
        if (cachedElementList != null && shouldCache) {
            return cachedElementList;
        }

        final List<WebElement> elements = searchContext.findElement(searchContextBy).findElements(by);
        if (shouldCache) {
            cachedElementList = elements;
        }

        return elements;
    }

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + " '" + by + "'";
    }
}

I have highlighted the changes from the DefaultElementLocator. Although relatively minor, the affect every method except toString() so it is pointless to inherit.

Whilst I wasn’t happy with it, I found that this worked just fine for all elements bar one. I hope you can spot the problem?

Of course the rootElement calls it own locator within itself and always throws a NoSuchElementException when called. Still its a good start and in fact my demonstration ‘test’ passed.

So what’s left to fix?

  1. rootElement locator should return the correct element.
  2. Find a way to use the annotated rootElement field to construct the BlockingElementLocatorFactory in the first place so that it is properly ‘PageFactory.’

1. Fix the rootElementLocator

This is actually pretty simple and obvious, at least to me. To find the rootElement I just want to use a DefaultElementLocator as if I was not inside the Block. The simplest way to do that is to provide a DefaultElementLocator when the rootElement field is passed to BlockingElementLocatorFactory.createLocator(field), and the BlockingElementLocator for all other fields.

Fortunately Field provides access to the method .getName() that I can use for just this purpose. It does however put restrictions on the implementation of my Block class, but I think that fixing my rootElement field name is a fair price to pay for this.

    @Override
    public ElementLocator createLocator(final Field field) {
        final boolean useDefaultLocator = searchContextBy == null || field.getName().equals("rootElement");
        return useDefaultLocator
                ? new DefaultElementLocator(searchContext, field)
                : new BlockingElementLocator(searchContext, searchContextBy, field);
    }

(In case you were wondering I defined the boolean first just for readability)

Simple and effective. As my colleague John would say – ‘Ship it!’

2. Construct the BlockingElementLocatorFactory using PageFactory annotations

Not so obvious for this one, but the solution to part one pointed me in the right direction.

  • I need to pass a parameter to the BlockingElementLocatorFactory constructor that gives me access to the rootElement field of my Block.

It occurred to me that I could use the Block class itself and leverage reflection to find the rootElement field.

Whilst I am at it, lets add a constructor to match the DefaultElementLocatorFactory and use it for ALL Block classes:

package tech.alexontest.poftutor.infrastructure.pagefactory;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.pagefactory.DefaultElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;
import tech.alexontest.poftutor.pageblocks.Block;

import java.lang.reflect.Field;

public class BlockingElementLocatorFactory implements ElementLocatorFactory {
    private final SearchContext searchContext;

    private final Field rootElementField;

    /**
     * Instantiate a new ElementLocatorFactory that is functionally equivalent to the DefaultElementLocatorFactory.
     * @param searchContext the SearchContext to use as the rootElement of the Block. Typically a 'found' WebElement.
     */
    public BlockingElementLocatorFactory(final SearchContext searchContext) {
        this.searchContext = searchContext;
        rootElementField = null;
    }

    /**
     * Instantiate a new BlockingLocatorFactory that sets up a block using PageFactory annotations.
     * The rootElement of the block is defined by @FindBy annotation on the 'rootElement' field of the Block parameter.
     * @param driver A SearchContext (Typically WebDriver)
     * @param definedBlock A Block class where the rootElement field has a PageFactory @FindBy annotation
     */
    public BlockingElementLocatorFactory(final SearchContext driver, final Block definedBlock) {
        this.searchContext = driver;
        try {
            rootElementField = definedBlock.getClass().getDeclaredField("rootElement");
            // This constructor should only be used for Blocks with an annotated rootElement
            if (rootElementField.getAnnotationsByType(FindBy.class).length == 0) {
                throw new IllegalArgumentException("The 'rootElement' field does not have an @FindBy annotation.");
            }
        } catch (final NoSuchFieldException e) {
            throw new IllegalArgumentException("A defined Block requires a @FindBy annotated 'rootElement' field.", e);
        }
    }

    @Override
    public ElementLocator createLocator(final Field field) {
        return (rootElementField == null || field.getName().equals("rootElement"))
                ? new DefaultElementLocator(searchContext, field)
                : new BlockingElementLocator(searchContext, rootElementField, field);
    }
}

Note:

  • As I am now using this for all blocks, I have added some checks to ensure that my new constructor is only used for Blocks with a rootElement field annotated with @FindBy. Other Blocks should be using the standard constructor.
  • createLocator now returns a DefaultElementLocator when the standard constructor was used, or when the rootElement field is being decorated.

Finishing up

The last thing to do is to simplify the construction of my Block classes. To that end I just create a couple of really basic Abstract classes to handle the construction.

For found Blocks, extend AbstractFoundBlock:

package tech.alexontest.poftutor.pageblocks;

import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;
import tech.alexontest.poftutor.infrastructure.pagefactory.BlockingElementLocatorFactory;

public abstract class AbstractFoundBlock implements Block {

    private final WebElement rootElement;

    AbstractFoundBlock(final WebElement rootElement) {
        final ElementLocatorFactory locatorFactory = new BlockingElementLocatorFactory(rootElement);
        PageFactory.initElements(locatorFactory, this);
        this.rootElement = rootElement;
    }

    @Override
    public WebElement getRootElement() {
        return rootElement;
    }
}

and for PageFactory defined Blocks extend AbstractDefinedBlock:

package tech.alexontest.poftutor.pageblocks;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;
import tech.alexontest.poftutor.infrastructure.pagefactory.BlockingElementLocatorFactory;

public abstract class AbstractDefinedBlock implements Block {
    AbstractDefinedBlock(final WebDriver webDriver) {
        final ElementLocatorFactory locatorFactory = new BlockingElementLocatorFactory(webDriver, this);
        PageFactory.initElements(locatorFactory, this);
    }
}

In either case, a simple super(..) call in the constructor is sufficient to set up the Block and initialise the PageFactory.

A bit more testing and now I am ready to ‘Ship it!’ John!

Phew!

That was a big piece of work and worth waiting two weeks for. I hope the full story of how I got to where I did hasn’t made this too complicated (or long) to follow. I was inspired by a tweet from Mr Selenium java Simon Stewart this week. This isn’t a bug, but might be something that people might find useful, so I think I’ll try to work out what I need to do to put in a PR and see if he thinks it might be a useful addition too. Once I get over the hurdle of managing to submit that first PR I might even manage to help getting more useful fixes in.

Next week will be six months since the release of JUnit5. Time for a look at how well it is supported in the wider Java ecosystem I think.

Progress made:

  • Lots of time spent analysing and debugging how the PageFactory actually works.
  • Custom ElementLocator and ElementLocatorFactory implementations produced to support PageBlock functionality with IoC / Dependency Injection.
  • Abstract Block classes to simplify the creation of Blocks based on either an instantiated (found) WebElement, or one defined using PageFactory annotations.
  • An in depth explanation of how my solution it ended up as it is.

Lessons learnt:

  • After a lot of investigation, implementation was a lot simpler than I expected, but I still learnt a lot from doing it. I enjoy tackling problems I really don’t know how to solve.
  • When tackling more complex tasks, it is a good idea (essential even) to tackle each case separately, solve the problems, and look for the generalised solution later.
  • Always check back for programming against the implementation instead of the interface. Its easy an easy mistake to make, and not easy to spot.
Comments are closed.