Building a UI automation framework with Spring

Photo: victor estrada diaz

Photo: victor estrada diaz

Spring is a widely used and established Java application framework. The Page Object Pattern is the de-facto standard for implementing UI tests in an object-oriented manner. In this article, we will learn how we can combine the two to simplify writing these tests. We will use an Appium test (executed with JUnit) as an example. For Selenium, the code remains mostly the same. For other test automation tools, the concept applies too.

Test without page objects

First, let’s have a look at a test that doesn’t use the Page object pattern. We use a simple login test for the Carousell app. The full source code is available on GitHub.

Carousell login flow

Carousell login flow

public class LoginTest04 extends CarousellBaseTest {
  private By welcomePageLoginButton =
      MobileBy.id("com.thecarousell.Carousell:id/welcome_page_login_button");
  private By usernameField = MobileBy.xpath("//*[@text=\"email or username\"]");
  private By passwordField = MobileBy.xpath("//*[@text=\"password\"]");
  private By loginButton = MobileBy.id("com.thecarousell.Carousell:id/login_page_login_button");
  private By sellButton = MobileBy.id("com.thecarousell.Carousell:id/button_sell");

  @Test
  public void testLogin() {
    WebDriverWait wait = new WebDriverWait(driver, 15);
    wait.until(presenceOfElementLocated(welcomePageLoginButton)).click();
    wait.until(presenceOfElementLocated(usernameField)).sendKeys(username);
    driver.findElement(passwordField).sendKeys(password);
    driver.findElement(loginButton).click();
    assertThat(wait.until(presenceOfElementLocated(sellButton)).isDisplayed())
        .as("sell button is visible")
        .isEqualTo(true);
  }
}

As you can see, all locators are packed into the test class itself. If we want to re-use them across multiple tests we need to duplicate them (or worse, reference them across different test classes). It’s evident that this would become a maintenance nightmare for larger test suites.

Introducing page objects

Let’s have a look at the three screens (“pages”) in our test and their corresponding page object representations:

WelcomePage

Welcome page

Welcome page

public class WelcomePage {

  private WebDriver driver;

  private By welcomePageLoginButton =
      MobileBy.id("com.thecarousell.Carousell:id/welcome_page_login_button");

  public WelcomePage(WebDriver driver) {
    this.driver = driver;
  }

  public LoginPage goToLogin() {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    wait.until(presenceOfElementLocated(welcomePageLoginButton)).click();
    return new LoginPage(driver);
  }
}

LoginPage

Login page

Login page

public class LoginPage {

  private WebDriver driver;

  private By usernameField = MobileBy.xpath("//*[@text=\"email or username\"]");
  private By passwordField = MobileBy.xpath("//*[@text=\"password\"]");
  private By loginButton = MobileBy.id("com.thecarousell.Carousell:id/login_page_login_button");

  public LoginPage(WebDriver driver) {
    this.driver = driver;
  }

  public HomePage login(String username, String password) {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    wait.until(presenceOfElementLocated(usernameField)).sendKeys(username);
    driver.findElement(passwordField).sendKeys(password);
    driver.findElement(loginButton).click();
    return new HomePage(driver);
  }

  public HomePage login(User user) {
    return login(user.getUsername(), user.getPassword());
  }
}

HomePage

Home page

Home page

public class HomePage {

  private WebDriver driver;

  private By sellButton = MobileBy.id("com.thecarousell.Carousell:id/button_sell");

  public HomePage(WebDriver driver) {
    this.driver = driver;
  }

  public boolean canISell() {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    return wait.until(presenceOfElementLocated(sellButton)).isDisplayed();
  }
}

We have successfully moved the UI elements and all interactions with them to separate Java objects. This encapsulates the technical details from the test and makes the (functional) test steps clearer.

The page objects’ responsibility is to model pages (or screens) of the application. The tests’ responsibility is to interact with those pages.

@TestInstance(Lifecycle.PER_CLASS)
public class LoginTest06 extends CarousellBaseTest {

  @Test
  public void testLogin() {
    WelcomePage welcome = new WelcomePage(driver);
    assertThat(welcome.goToLogin()
      .login(new User(username, password))
      .canISell())
      .as("sell button visible")
      .isEqualTo(true);
  }
}

We can easily re-use page objects across multiple tests. If the layout of our application changes, ideally, we only need to touch the page objects. If we want to modify or add tests, our existing page objects will already be a good starting point.

Instantiating page object classes

One challenge with using page objects in Java is the question of how to create instances of them. In the example above, we use a constructor call:

new WelcomePage(driver);

Creation of all subsequent pages happens in the page object classes. Each method returns an instance of the page object representing the screen the application will be on after its execution. For example, the login method on the LoginPage will return an instance of WelcomePage (so the test doesn't need to care about resolving it). We consider this good practice because it allows method chaining and thus a fluent coding style.

The alternative to it would require to instantiate all page objects in the test:

@TestInstance(Lifecycle.PER_CLASS)
public class LoginTest05 extends CarousellBaseTest {

  @Test
  public void testLogin() {
    WelcomePage welcome = new WelcomePage(driver);
    welcome.goToLogin();
    LoginPage login = new LoginPage(driver);
    login.login(username, password);
    HomePage home = new HomePage(driver);
    assertThat(home.canISell()).as("sell button visible").isEqualTo(true);
  }
}

Regardless of whether in the test class or the page objects we need to call the constructor for all page objects in our code at some point. This is one hassle that will go away using Spring’s dependency injection.

Injecting the WebDriver

The other one is managing WebDriver instances that are required by each of the page objects. One common practice is to pass a WebDriver to the constructor:

public class HomePage {
  private WebDriver webDriver;

  public HomePage(WebDriver webDriver) {
    this.webDriver = webDriver;
  }
}

Constructors are not inherited in Java, so this is required for each and every page object! If we want to pass other objects between pages it becomes even messier (we need to manage multiple constructors). There are some solutions (or workarounds) to this, for example using a factory pattern. However, Spring’s dependency injection is probably one of the most elegant ones.

In the end, we don’t want to bother with such technicalities when writing tests. So let’s invest a little in our framework to hide the complexity from the test itself. The following code snippet shows how our test and the test objects (i.e. all relevant test code) will look like when using Spring for dependency injection:

@SpringJUnitConfig(SpringConfig.class)
public class LoginTest07 {

  @Autowired
  private WelcomePage welcomePage;

  @Test
  public void testLogin() {
    assertThat(welcomePage.goToLogin().login(new User(username, password)).canISell())
        .as("sell button visible").isEqualTo(true);
  }
}
@Component
public class WelcomePage extends BasePage {

  @Autowired
  private LoginPage loginPage;

  private By welcomePageLoginButton =
      MobileBy.id("com.thecarousell.Carousell:id/welcome_page_login_button");

  public LoginPage goToLogin() {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    wait.until(presenceOfElementLocated(welcomePageLoginButton)).click();
    return loginPage;
  }
}
@Component
public class LoginPage extends BasePage {

  @Autowired
  private HomePage homePage;

  private By usernameField = MobileBy.xpath("//*[@text=\"email or username\"]");
  private By passwordField = MobileBy.xpath("//*[@text=\"password\"]");
  private By loginButton = MobileBy.id("com.thecarousell.Carousell:id/login_page_login_button");

  public HomePage login(String username, String password) {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    wait.until(presenceOfElementLocated(usernameField)).sendKeys(username);
    driver.findElement(passwordField).sendKeys(password);
    driver.findElement(loginButton).click();
    return homePage;
  }

  public HomePage login(User user) {
    return login(user.getUsername(), user.getPassword());
  }
}
@Component
public class HomePage extends BasePage {

  private By sellButton = MobileBy.id("com.thecarousell.Carousell:id/button_sell");

  public boolean canISell() {
    WebDriverWait wait = new WebDriverWait(driver, 10);
    return wait.until(presenceOfElementLocated(sellButton)).isDisplayed();
  }
}

What’s going on behind the scenes?

First, let’s include two Spring libraries into our project. For Maven, we add this to our pom file:

<properties>
  <spring.version>5.1.7.RELEASE</spring.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>${spring.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-test</artifactId>
    <version>${spring.version}</version>
  </dependency>
</dependencies>

By introducing Spring, we turn the page objects, the test and the WebDriver into Spring beans and let the Spring IoC container manage them. This means Spring will instantiate these classes and provide an instance whenever it’s needed.

The Java configuration for this is quite simple. We use @ComponentScan to specify the packages Spring will scan. It will find all classes annotated with @Component and use the default (in our case empty) constructors to instantiate them.

@Configuration
@ComponentScan(basePackages = "io.github.martinschneider.appium.demo.pageobjects")
public class SpringConfig {

  @Bean
  public WebDriver webDriver() {
    return new AndroidDriver<WebElement>(new URL("http://localhost:4723/wd/hub"),
        getCapabilities());
  }

  ...

}

For the WebDriver, we create a method to describe how to construct it and annotate it with @Bean. On start-up, Spring will call this method to construct a WebDriver instance.

So far all Spring is doing is creating instances of page objects for us but it’s getting better.

Spring beans can be injected into other beans using the @Autowired annotation.

There are multiple ways to achieve this; the simplest is using private fields, for example:

@Autowired
private LoginPage login;

@Autowired
private WelcomePage welcome;

@Autowired
private WebDriver driver;

Finally, to make use of Spring during our test execution, we need to annotate the test class with

@SpringJUnitConfig(SpringConfig.class)

That’s it! Spring will take care of the rest, i.e. automatically inject the beans (in our case page objects and the WebDriver) into each other as needed. All we need to do is to declare them as private fields. There’s no need to call the constructors anymore. This is what’s sometimes referred to as the Hollywood Principle:

Don’t call us, we (Spring) will call you.

By default, all Spring beans are singletons which means they are created once during the start-up of the Spring context and are then re-used whenever they’re needed. Page objects should be stateless, so this fits well.

Conclusion

The use of Spring beans is, of course, not limited to page objects or the WebDriver. It’s an advantageous way to inject other services (for example for test data handling, API calls etc.) into our code as well.

On top of that, we can utilise Spring for many other use-cases besides IoC and DI which is another reason why, despite a slightly increased footprint from its libraries, we prefer Spring to a more light-weight or even homegrown dependency injection approach.

Coming up

Part 2: Multi-platform page objects

Resources