Adding parameterized FindBy annotation to Selenium Page Object in Java to design multi-language tests

Despite page object support in Selenium (supplied by PageFactory tool-set) is really comfortable and elegant way to implement automated tests it still has certain trade-off. Unlike the classic approach with findElement(..) method where you can build your path in run-time, annotation values in Java cannot be evaluated dynamically so having such static locators does not seem to be very flexible.

However there is the way to work around this trade-off. Here in the article we’re going to implement automated test for multi-language application. This implementation will be built on top of PageFactory harness. The language-specific parts of locators will be set as parameters and will be substituted in run-time depending on the selected language.

Let’s now go step by step in our implementation.

Example description: implement parameterized locators for Page Object

In our example we have two pages which are only different in elements text. One page will show up English-language UI, another one - Russian language UI.

Sample pages

Below is the code of sample pages. Save it to your hard drive if you want to run everything at the end.

en.html:

<html>
  <body>
    <button name="Press me"></button>
    <label>Read me</label>
  </body>
</html>

ru.html:

<html>
  <body>
    <button name="Нажми меня"></button>
    <label>Прочитай меня</label>
  </body>
</html>

Page object code

Our intention is to have single page object and single test which would access elements rendered for different languages. So our page object class looks like this:

package click.webelement.pagefactory.localized;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory;

public class ParameterizedPageObject {

    @ParameterizedBy(xpath = "//button[@name='{wec:button.name}']")
    WebElement button;

    @ParameterizedBy(xpath = "//label[text()='{wec:label.text}']")
    WebElement label;

    public ParameterizedPageObject(SearchContext searchContext) {
        PageFactory
                .initElements(
                        new DefaultElementLocatorFactory(searchContext),
                        this);
    }

}

You can see that we use the constructs like {wec:button.name} in our xpath expressions. These are the parameters of our locators.

Storing localized strings

Since we’re talking about localization, we’ll be using typical approach to store localization files. Our files will be named after the chosen language and have .strings extension. So in this particular example there would be two files:

en.strings:

button.name=Press me
label.text=Read me

and ru.strings:

button.name=Нажми меня
label.text=Прочти меня

The code will be choosing among the languages depending on the value of wec.language system property. So let’s move to the implementation details.

Implement custom annotation

This is the most simple part of our customization. We need to have annotation that would be marking the fields of our page object. We cannot use standard FindBy because it is tightly coupled with default logic where locator parameterization is not supported:

package click.webelement.pagefactory.localized;

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;

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

public @interface ParameterizedBy {

    String xpath() default "";

}

The implementation shown in my example supports only xpath locators. However it is easily extendable to support other locators (which I omit here not to waste space - let’s concentrate on the idea).

Our new annotation is annotated itself. It is annotated with @PageFactoryFinder(ParameterizedByBuilder.class). It tells Selenium where to take the code that would build By object using the value from ParameterizedBy annotation with the logic which we’ll implement at one of the next steps.

Implement parameter value provider

Our code will be taking the parameter values from the property files. This is how localization is usually implemented. We’ll be having some default set of properties (which is en.strings) and any other set of properties which will be holding localized values.

The expected behavior should be the following: check the language that is used, look for the property in the file associated with chosen language. If the property cannot be found in that file, look it up in default file. If it cannot be found in default file, throw the exception.

So here is the code. Not really small but we need it anyway..

package click.webelement.pagefactory.localized;

import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;

public class ParameterProvider {

    private static final String WEC_LANGUAGE_PROP = "wec.language";
    private static final String WEC_LANGUAGE_DEFAULT = "en";
    private Properties properties;
    private static Map<String, ParameterProvider> parameterProviderMap = new HashMap<>();

    private ParameterProvider() {
        Properties defaultProperties = new Properties();
        String language = System.getProperty(WEC_LANGUAGE_PROP, WEC_LANGUAGE_DEFAULT);
        String fileName = language + ".strings";
        try (InputStreamReader defaultPropsIS = new InputStreamReader(Objects.requireNonNull(this
                .getClass()
                .getClassLoader()
                .getResourceAsStream("en.strings")), "UTF-8")) {
            defaultProperties
                    .load(defaultPropsIS);
        } catch (Exception e) {
            System.err.println("Unable to load default properties (en.strings)..");
            e.printStackTrace();
        }
        try (InputStreamReader propsIS = new InputStreamReader(Objects.requireNonNull(this
                .getClass()
                .getClassLoader()
                .getResourceAsStream(fileName)), "UTF-8")) {
            properties = new Properties(defaultProperties);
            properties
                    .load(propsIS);
        }catch (Exception e){
            System.err.println("Unable to load properties from: " + fileName);
            e.printStackTrace();
        }
    }

    private static ParameterProvider getParameterProvider() {
        String language = System.getProperty(WEC_LANGUAGE_PROP, WEC_LANGUAGE_DEFAULT);
        if (!parameterProviderMap.containsKey(language)) {
            parameterProviderMap.put(language, new ParameterProvider());
        }
        return parameterProviderMap.get(language);
    }

    public static String getParameter(String name) {
        return getParameterProvider().properties.getProperty(name);
    }

}

Most of the lines is the boilerplate code to load properties from the resources.

Implement ParameterizedByBuilder that would be building By object for our fields

Our custom builder has to extend AbstractFindByBuilder provided by Selenium Java bindings. We need to override public By buildIt(..) method that is used by Selenium to build By object. Our class will also contain the logic for taking the parameters from the xpath and substituting them with the values for the chosen language.

package click.webelement.pagefactory.localized;

import org.openqa.selenium.By;
import org.openqa.selenium.support.AbstractFindByBuilder;
import java.lang.reflect.Field;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ParameterizedByBuilder extends AbstractFindByBuilder {

    protected By buildParameterizedBy(ParameterizedBy findBy) {
        if (!"".equals(findBy.xpath())) {
            return By.xpath(processParameter(findBy.xpath()));
        }
        return null;
    }

    @Override
    public By buildIt(Object annotation, Field field) {
        ParameterizedBy parameterizedBy = (ParameterizedBy)annotation;
        return buildParameterizedBy(parameterizedBy);
    }

    private static String processParameter(String input) {
        Pattern p = Pattern.compile("\\{wec:(.+?)\\}");
        Matcher m = p.matcher(input);
        String result = input;
        while (m.find()) {
            String fullMatch = m.group();
            String propName = m.group(1);
            String propValue = ParameterProvider.getParameter(propName);
            if (propValue == null) {
                throw new IllegalArgumentException("Cannot find property: " + propName);
            }
            result = result.replace(fullMatch, propValue);
        }
        return result;
    }

}

Let’s take closer look at what is happening here. The core of the class that is important for Selenium is buildIt() method. Selenium calls it when initialize page object fields. It also passes the annotation that annotates the field and the field itself. Using that annotation we return By object like it is done in standard code but with re-processed value.

The core of the class that is important for us is the logic in processParameter() method. We use regular expressions in order to look up all the places where {wec:something} can be encountered. Then we search for the value for that parameter and replace the matching part with that value.

Take a rest and look back at what we already have

So now we’re ready to write our test. Let’s see what we have by the moment:

  • en.html and ru.html pages which we’re going to use as targets for our test (store them in the same folder)

  • en.strings and ru.strings files which hold the localized messages (aka properties) used in our locators (place them to resources folder of your project)

  • ParameterizedPageObject class that implements the code of our page object

  • ParameterizedBy annotation that is used for annotating fields of our page object

  • ParameterizedByBuilder class that is used by Selenium to prepare By (locator) object for the annotated field (with the logic of parameterization)

  • ParameterProvider that is basically takes the values for parameters from property files

Okay. Let’s go ahead for the test.

Implementing the test

We’ll be writing our test with the help of TestNG framework. It makes managing the test life-cycle convenient and allows easily create parameterized tests. So here is the test code that we’ll briefly discuss right after the listing:

package click.webelement.pagefactory.localized;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.testng.annotations.*;

public class ParameterizedPageObjectTest {

    WebDriver driver;

    @DataProvider(name = "languages")
    Object[][] dataProvider(){
        return new Object[][]{
                {"en"},
                {"ru"}
        };
    }

    @BeforeClass
    public static void globalSetup() {
        System.setProperty("webdriver.gecko.driver", "/PATH_TO_DRIVER/geckodriver");
    }

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

    @Test(dataProvider = "languages")
    public void testPage(String language){
        System.setProperty("wec.language", language);
        driver.get("file:///PATH_TO_PAGES/" + language + ".html");
        ParameterizedPageObject ppom = new ParameterizedPageObject(driver);
        ppom.button.click();
        ppom.label.getText();
    }

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

}

Run the test and watch the result. If you were following the examples carefully, the test for English language should pass, but for Russian should fail. This is because I intentionally put wrong value to the label text in ru.html page.

Limitation (disclaimer)

The design shown above works well for single-thread execution but wouldn’t work for tests running in parallel. The limitation comes from the way how we set up the language for the provider since we cannot have different values of the system property for different threads. If you will need to run your tests in parallel you will need to rework this mechanism. I tried to keep the things as simple as possible in order to keep the attention at the really important points.

Hope that the example spots the light at the problem of FindBy annotations flexibility. If you still have the questions please send them to me using this form. I will amend the article according to your feedback.