Sharing Code With Multiple TestProject OpenSDK Tests

After you have written a few test methods which use the TestProject OpenSDK, the odds are that you notice that your test methods contain duplicate code. This blog post describes how you can eliminate duplicate code from your test suite.

After you have read this blog post, you:

  • Understand why you should reuse test code.
  • Know what a page object is.
  • Can create page objects.
  • Understand how you can use page objects in your test classes when you are using JUnit 5.

Let's begin.

This blog post is the sixth part of my TestProject OpenSDK tutorial that's sponsored by TestProject.io. However, the views and opinions expressed in this tutorial are mine.

This blog post assumes that:

By the way, you might want to read the other parts of my TestProject OpenSDK tutorial.

Why Should You Reuse Test Code?

When you write tests which use the Selenium API, you create a dependency between your test classes and the implementation of the system under test. To be more specific, you create this dependency when you find HTML elements from an HTML page by using id attributes, class attributes, element names, CSS selectors, and so on.

To make matters worse, because it's likely that multiple test classes have to interact with the same HTML pages (login page) or use the UI components (main navigation menu), you might end up adding duplicate code to your test suite. This is an awful idea because:

  • Your tests are hard to write because you have to write the same code every time when you want to interact with an HTML page or a component which is required by multiple tests.
  • If you change an HTML page or a common UI component, you might have to change every test class that interacts with the changed page or component. In other words, you tests are hard to maintain.

Also, it's good to understand that you cannot eliminate the dependency between your test classes and the implementation of the system under test because your tests must be able to interact with the system under test and extract information from the rendered HTML page. That being said, you can make your situation a lot better by using page objects.

Next, you will find out what a page object is.

Introduction to Page Objects

Martin Fowler specifies the term page object as follows:

A page object wraps an HTML page, or fragment, with an application-specific API, allowing you to manipulate page elements without digging around in the HTML.

In other words, a page object hides the structure of an HTML page or fragment from your test methods and provides methods which allow your test methods to either interact with the HTML page or find information from it.

Page objects help you to write tests which are easy to read, write, and maintain because of these two reasons:

  • Page objects help you to eliminate duplicate code because your test suite has only one place that has knowledge of the structure of the specific HTML page or page fragment. This makes your tests easier to write and maintain.
  • If you give application-specific names for the methods of your Page objects, you can create a domain-specific language for your tests. This makes your tests easier to read.

Let's move on and find out how you can write good page objects.

Writing Good Page Objects

If you want to write good page objects, you must follow these three rules:

First, you shouldn’t create one page object per HTML page. Instead, you should divide the HTML page into sections and create one page object per section. For example, the SearchPage page object could contain a page object called SearchForm.

Also, when you divide an HTML page into page objects, you should model the structure of the HTML page in a way that makes sense to the users of your application. If you follow this technique, you can create a domain-specific language for your tests. This allows you to write tests which highlight the essence of your test cases.

Second, the methods of a page object should return either data container objects or other page objects. Typically a page object has three types of methods:

  • The methods that find information from the current HTML page should return either primitive data types, data container objects, or collections which contain primitive data types or data container objects.
  • The methods which allow you to access other sections of the current HTML page should return other page objects.
  • The methods which navigate to another HTML page or reload the current HTML page should return a new page object that represents the loaded HTML page.

If you follow this rule, you can enjoy from these two benefits:

  • Most of the time you don’t have to expose the Selenium API to your test classes. This means that you can write your tests by using the domain-specific language that's defined by your page objects.
  • If you change the navigation path of a feature, you can simply change the signature of the appropriate method and see immediately which test classes are affected by the change (hint: those classes won't compile anymore).
One consequence of this rule is that if the same action has different results, you have to model these results by using different methods. For example, if a different page is rendered after a successful and a failed login, you have to add two login methods to the page object that represents the login page.

Third, page objects shouldn't contain any assertions. As you remember, page objects are responsible for providing a domain-specific language that allows you to interact with an HTML page and find information from it.

On the other hand, assertions help you to specify the conditions that must be true after your test method has been run. In other words, assertions help you to specify the business rules of your application, and that’s why I think that you should add your assertions to your test methods.

Keep in mind that if you add assertions to your page objects, you end up mixing the presentation logic with the business logic of your application. In other words, you write “bloated” page objects which have too many responsibilities.

Some people argue that if you add our assertions to your page objects, you can eliminate duplicate assertion logic and provide better error messages. I admit that this is true, but you can achieve the same benefits by using a proper assertion library such as AssertJ that allows you to create custom assertions. If you use a proper assertion library, you can write clean assertions and you don't have to mix the presentation logic with business logic.

Before you can write your page objects, you have learn to pass environment specific configuration to your test code. Next, you will find out how you can solve this problem.

Passing Environment Specific Configuration to Your Test Code

It's likely that you have to run your tests in different environments. For example, you might have to ensure that the system under test is working as expected when it's run in your local development environment, test environment, or production environment.

This means that you must be able to pass environment specific configuration to your test code. For example, you must be able to configure the base url of the system under test. When you want to pass the base url of the system under test to your test code, you have follow these steps:

First, you have to create a final WebDriverEnvironment class and ensure that you cannot instantiate it. This class provides static methods which allow you to access environment-specific configuration that's passed to your tests by using JVM system properties.

After you have created this class, you have to write a static getBaseUrl() method by following these steps:

  1. Read the base url of the system under test from a JVM system property called webdriver.base.url.
  2. If no base url is found, throw a new RuntimeException.
  3. Return the found base url.

After you have written the WebDriverEnvironment class, its source code looks as follows:

final class WebDriverEnvironment {

    private WebDriverEnvironment() {}
    
    static String getBaseUrl() {
        String baseUrl = System.getProperty("webdriver.base.url");
        if (baseUrl == null) {
            throw new RuntimeException("No base url found!");
        }
        return baseUrl;
    }
}

Second, you have to create a public and final class called WebDriverUrlBuilder and put this class to the same package as the WebDriverEnvironment class. After you have created this class, you must ensure that you cannot instantiate it.

The WebDriverUrlBuilder class provides one static factory method that helps you to replace hard coded URL addresses with environment-specific URL addresses. In other words, this class helps you to write tests which can be run in different environments.

After you have created the WebDriverUrlBuilder class, you have to write the required factory method by following these steps:

  1. Add a static buildFromPath() method to the WebDriverUrlBuilder class. This method takes the path template and the parameters which are referenced by the format specifiers found from the path String as method parameters. Also, this method returns the created URL address.
  2. If the path is null, throw a new NullPointerException.
  3. Create the real path by using the format() method of the String class.
  4. Get the base URL.
  5. If the base URL doesn't end with the character: '/', append the character '/' to the base URL.
  6. If the path starts with the character: '/', replace that character with an empty string.
  7. Append the path to the base URL and return the created URL address.

After you have written the WebDriverUrlBuilder class, its source code looks as follows:

public final class WebDriverUrlBuilder {

    private WebDriverUrlBuilder() {}
    
    public static String buildFromPath(String path, Object... params) {
        if (path == null) {
            throw new NullPointerException("Path must be given.");
        }

        path = String.format(path, params);

        String baseUrl = WebDriverEnvironment.getBaseUrl();
        if (!baseUrl.endsWith("/")) {
            baseUrl += "/";
        }

        if (path.startsWith("/")) {
            path = path.replaceFirst("/", "");
        }

        return baseUrl + path;
    }
}

Third, you have to set the value of the webdriver.base.url system property by using one of these three options:

If you run your tests with Gradle, you can set the value of the webdriver.base.url system property by adding the following code to your build.gradle file:

tasks.withType(Test) {
    systemProperty 'webdriver.base.url',
            System.getProperty('webdriver.base.url', 'https://www.petrikainulainen.net')
}

If you run your tests with Maven, you can set the value of the webdriver.base.url system property by using the following plugin configuration:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
    <configuration>
        <systemPropertyVariables>
            <webdriver.base.url>https://www.petrikainulainen.net</webdriver.base.url>
        </systemPropertyVariables>
    </configuration>
</plugin>

If you run your tests with your IDE, you can set the value of the webdriver.base.url system property by passing the following argument to the started JVM:

-Dwebdriver.base.url=https://www.petrikainulainen.net.

For example, if you are using IntelliJ IDEA, you can pass this argument to the started JVM by using the following "Run Configuration":

Let's move on and find out how you can write your page objects.

Writing Your Page Objects

During this blog post you will fix the tests which you wrote when you learned to write tests for web applications with TestProject OpenSDK and JUnit 5. If you want to fix your tests, you have to write these page objects:

  • The SearchPage class is a page object which allows you to interact with the search page.
  • The SearchResultPage is a page object which allows you to find information from the search result page.

Also, you have to write a data container class called SearchResult. This class contains the information of a single search result that's displayed on the search result page.

You can write these classes by following these steps:

First, you have write the SearchResult class. This class has one private property that contains the title of the search result. After you have written the SearchResult class, its source code looks as follows:

public class BlogPost {

    private final String title;

    public BlogPost(String title) {
        this.title = title;
    }

    public String getTitle() {
        return title;
    }
}

Second, you have to create the SearchResultPage class and implement it by following these steps:

  1. Add a final WebDriver field to the created class.
  2. Implement a package-private constructor which sets the value of the webDriver field by using constructor injection.
  3. Write a public method called findNoSearchResultsText(). This method finds the text that's shown on the search result page when no search results are found and returns the found text.
  4. Write a public method called findSearchResults(). This method finds the search results which are shown on the search result page and returns a List of BlogPost objects.

After you have written the SearchResultPage class, its source code looks as follows:

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

import java.util.ArrayList;
import java.util.List;

public class SearchResultPage {

    private final WebDriver webDriver;

    SearchResultPage(WebDriver webDriver) {
        this.webDriver = webDriver;
    }
    
    public String findNoSearchResultsText() {
        WebElement noSearchResultsElement = webDriver.findElement(
                By.cssSelector(
                        ".template-search .content .post_box .archive_content"
                )
        );
        return noSearchResultsElement.getText();
    }
    
    public List<BlogPost> findSearchResults() {
        List<BlogPost> searchResults = new ArrayList<>();

        List<WebElement> searchResultElements = webDriver.findElements(
                By.tagName("article")
        );
        for (WebElement currentElement: searchResultElements) {
            WebElement searchResultTitle = currentElement.findElement(
                    By.className("headline")
            );
            BlogPost searchResult = new BlogPost(searchResultTitle.getText());
            searchResults.add(searchResult);
        }

        return searchResults;
    }
}

Third, you have to create the SearchPage class and implement it by following these steps:

  1. Add a final field called pageUrl to the created class. This field contains the environment-specific URL of the search page.
  2. Add a final WebDriver field to the created class.
  3. Implement a constructor which sets the value of the webDriver field by using constructor injection and builds the environment-specific URL of the search page.
  4. Write a public method called open(). This method opens the search page and returns a new SearchPage object.
  5. Write a public method called findBlogPostsBySearchTerm(). This method takes the used search term as a method parameter, enters the search term to the search form, and submits the search form. After this method has submitted the search form, it returns a new SearchResultPage object.

After you have written the SearchPage class, its source code looks as follows:

import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;

public class SearchPage {

    private final String pageUrl;
    private final WebDriver webDriver;

    public SearchPage(WebDriver webDriver) {
        this.pageUrl = WebDriverUrlBuilder.buildFromPath("/blog/");
        this.webDriver = webDriver;
    }
    
    public SearchPage open() {
        webDriver.get(pageUrl);
        return new SearchPage(webDriver);
    }

    public SearchResultPage findBlogPostsBySearchTerm(String searchTerm) {
        WebElement searchField = webDriver.findElement(By.id("s"));
        searchField.sendKeys(searchTerm);
        searchField.sendKeys(Keys.ENTER);
        return new SearchResultPage(webDriver);
    }
}
You should consider splitting the SearchPage class into two page objects: the SearchPage class allows you to interact with the search page (including the search form) and the SearchForm class contains the methods which allow you to interact with the search form (the SearchPage invokes these methods). This change is useful if you want to write other page objects which have to interact with the search form.

Next, you have to make the required changes to your test class.

Making the Required Changes to Your Test Class

You can make the required changes to your test class by following these steps:

First, you have to add a searchPage field to your test class. This field contains a reference to a SearchPage object which allows you to interact with the search page. After you have added the searchPage field to your test class, its source code looks as follows:

import io.testproject.sdk.DriverBuilder;
import io.testproject.sdk.drivers.web.ChromeDriver;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeOptions;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("Search blog posts")
class BlogSearchTest {

    private static ChromeDriver driver;
    private SearchPage searchPage;

    @BeforeAll
    static void configureTestProjectOpenSDK() {
        driver = new DriverBuilder<ChromeDriver>(new ChromeOptions())
                .withCapabilities(new ChromeOptions())
                .build(ChromeDriver.class);
    }

    @Nested
    @DisplayName("When no search results are found")
    class WhenNoSearchResultsAreFound {

        @Test
        @DisplayName("Should display an empty search result page when no search results are found")
        void shouldDisplayEmptySearchResultPage() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("noresults");
            searchField.sendKeys(Keys.ENTER);

            WebElement noResultElement = driver.findElement(
                    By.cssSelector(
                            ".template-search .content .post_box .archive_content"
                    )
            );
            assertThat(noResultElement.getText()).isEqualTo("No results found.");
        }
    }

    @Nested
    @DisplayName("When one search result is found")
    class WhenOneSearchResultIsFound {

        @Test
        @DisplayName("Should display search result page that has one search result when one search result is found")
        void shouldDisplaySearchResultPageWithOneSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            List<WebElement> searchResults = driver.findElements(
                    By.tagName("article")
            );
            assertThat(searchResults).hasSize(1);
        }

        @Test
        @DisplayName("Should display search result page that has the correct search result when one search result is found")
        void shouldDisplaySearchResultPageWithCorrectSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            WebElement searchResult = driver.findElement(
                    By.tagName("article")
            );
            WebElement resultTitle = searchResult.findElement(
                    By.className("headline")
            );
            assertThat(resultTitle.getText())
                    .isEqualTo("Java Testing Weekly 22 / 2018");
        }
    }

    @AfterAll
    static void shutdownTestProjectOpenSDK() {
        driver.quit();
    }
}

Second, you have to write a setup method that's run before a test method is run. This method opens the search page and stores the returned SearchPage object in the searchPage field. After you have written the setup method, the source code of your test class looks as follows:

import io.testproject.sdk.DriverBuilder;
import io.testproject.sdk.drivers.web.ChromeDriver;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeOptions;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("Search blog posts")
class BlogSearchTest {

    private static ChromeDriver driver;
    private SearchPage searchPage;

    @BeforeAll
    static void configureTestProjectOpenSDK() {
        driver = new DriverBuilder<ChromeDriver>(new ChromeOptions())
                .withCapabilities(new ChromeOptions())
                .build(ChromeDriver.class);
    }

    @BeforeEach
    void openSearchPage() {
        searchPage = new SearchPage(driver).open();
    }

    @Nested
    @DisplayName("When no search results are found")
    class WhenNoSearchResultsAreFound {

        @Test
        @DisplayName("Should display an empty search result page when no search results are found")
        void shouldDisplayEmptySearchResultPage() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("noresults");
            searchField.sendKeys(Keys.ENTER);

            WebElement noResultElement = driver.findElement(
                    By.cssSelector(
                            ".template-search .content .post_box .archive_content"
                    )
            );
            assertThat(noResultElement.getText()).isEqualTo("No results found.");
        }
    }

    @Nested
    @DisplayName("When one search result is found")
    class WhenOneSearchResultIsFound {

        @Test
        @DisplayName("Should display search result page that has one search result when one search result is found")
        void shouldDisplaySearchResultPageWithOneSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            List<WebElement> searchResults = driver.findElements(
                    By.tagName("article")
            );
            assertThat(searchResults).hasSize(1);
        }

        @Test
        @DisplayName("Should display search result page that has the correct search result when one search result is found")
        void shouldDisplaySearchResultPageWithCorrectSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            WebElement searchResult = driver.findElement(
                    By.tagName("article")
            );
            WebElement resultTitle = searchResult.findElement(
                    By.className("headline")
            );
            assertThat(resultTitle.getText())
                    .isEqualTo("Java Testing Weekly 22 / 2018");
        }
    }

    @AfterAll
    static void shutdownTestProjectOpenSDK() {
        driver.quit();
    }
}

Third, you have to fix the test method which ensures that the search function is working as expected when no search results are found. When you fix this test method, you must ensure that your test method uses your new page objects. After you have made the required changes to your test class, its source code looks as follows:

import io.testproject.sdk.DriverBuilder;
import io.testproject.sdk.drivers.web.ChromeDriver;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeOptions;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("Search blog posts")
class BlogSearchTest {

    private static ChromeDriver driver;
    private SearchPage searchPage;

    @BeforeAll
    static void configureTestProjectOpenSDK() {
        driver = new DriverBuilder<ChromeDriver>(new ChromeOptions())
                .withCapabilities(new ChromeOptions())
                .build(ChromeDriver.class);
    }

    @BeforeEach
    void openSearchPage() {
        searchPage = new SearchPage(driver).open();
    }

    @Nested
    @DisplayName("When no search results are found")
    class WhenNoSearchResultsAreFound {

        @Test
        @DisplayName("Should display an empty search result page when no search results are found")
        void shouldDisplayEmptySearchResultPage() {
            SearchResultPage searchResultPage = searchPage
                    .findBlogPostsBySearchTerm("noresults");

            String noSearchResultsText = searchResultPage
                    .findNoSearchResultsText();
            assertThat(noSearchResultsText).isEqualTo("No results found.");
        }
    }

    @Nested
    @DisplayName("When one search result is found")
    class WhenOneSearchResultIsFound {

        @Test
        @DisplayName("Should display search result page that has one search result when one search result is found")
        void shouldDisplaySearchResultPageWithOneSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            List<WebElement> searchResults = driver.findElements(
                    By.tagName("article")
            );
            assertThat(searchResults).hasSize(1);
        }

        @Test
        @DisplayName("Should display search result page that has the correct search result when one search result is found")
        void shouldDisplaySearchResultPageWithCorrectSearchResult() {
            driver.get("https://www.petrikainulainen.net/blog/");

            WebElement searchField = driver.findElement(By.id("s"));
            searchField.sendKeys("oneresult");
            searchField.sendKeys(Keys.ENTER);

            WebElement searchResult = driver.findElement(
                    By.tagName("article")
            );
            WebElement resultTitle = searchResult.findElement(
                    By.className("headline")
            );
            assertThat(resultTitle.getText())
                    .isEqualTo("Java Testing Weekly 22 / 2018");
        }
    }

    @AfterAll
    static void shutdownTestProjectOpenSDK() {
        driver.quit();
    }
}

Fourth, you have to fix the test methods which ensure that the system under test is working as expected when one search result is found. When you fix these test methods, you must ensure that your test methods use your new page objects. After you have made the required changes to your test class, its source code looks as follows:

import io.testproject.sdk.DriverBuilder;
import io.testproject.sdk.drivers.web.ChromeDriver;
import org.junit.jupiter.api.*;
import org.openqa.selenium.chrome.ChromeOptions;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("Search blog posts")
class BlogSearchTest2 {

    private static ChromeDriver driver;
    private SearchPage searchPage;

    @BeforeAll
    static void configureTestProjectOpenSDK() {
        driver = new DriverBuilder<ChromeDriver>(new ChromeOptions())
                .withCapabilities(new ChromeOptions())
                .build(ChromeDriver.class);
    }

    @BeforeEach
    void openSearchPage() {
        searchPage = new SearchPage(driver).open();
    }

    @Nested
    @DisplayName("When no search results are found")
    class WhenNoSearchResultsAreFound {

        @Test
        @DisplayName("Should display an empty search result page when no search results are found")
        void shouldDisplayEmptySearchResultPage() {
            SearchResultPage searchResultPage = searchPage
                    .findBlogPostsBySearchTerm("noresults");

            String noSearchResultsText = searchResultPage
                    .findNoSearchResultsText();
            assertThat(noSearchResultsText).isEqualTo("No results found.");
        }
    }

    @Nested
    @DisplayName("When one search result is found")
    class WhenOneSearchResultIsFound {

        @Test
        @DisplayName("Should display search result page that has one search result when one search result is found")
        void shouldDisplaySearchResultPageWithOneSearchResult() {
            SearchResultPage searchResultPage = searchPage
                    .findBlogPostsBySearchTerm("oneresult");

            List<BlogPost> searchResults = searchResultPage.findSearchResults();
            assertThat(searchResults).hasSize(1);
        }

        @Test
        @DisplayName("Should display search result page that has the correct search result when one search result is found")
        void shouldDisplaySearchResultPageWithCorrectSearchResult() {
            SearchResultPage searchResultPage = searchPage
                    .findBlogPostsBySearchTerm("oneresult");

            BlogPost searchResult = searchResultPage.findSearchResults().get(0);
            assertThat(searchResult.getTitle())
                    .isEqualTo("Java Testing Weekly 22 / 2018");
        }
    }

    @AfterAll
    static void shutdownTestProjectOpenSDK() {
        driver.quit();
    }
}

You understand why you should remove duplicate code from your test suite, you can write good page objects, and you know how you can write test methods which use your page objects. Let's summarize what you learned from this blog post.

Summary

This blog post has taught you six things:

  • A page object hides the structure of an HTML page or fragment from your test methods and provides methods which allow your test methods to either interact with the HTML page or find information from it.
  • You should remove duplicate code from your test suite by using page objects because page objects help you to write tests which are easy to read, write, and maintain.
  • When you create page objects which represent an HTML page, you should divide the HTML page into sections and create one page object per section.
  • The methods of a page object should return either data container objects or other page objects.
  • Page objects shouldn’t contain any assertions.
  • You can pass environment-specific configuration to your test code by using JVM system properties.

P.S. you can get the example application of this blog post from Github.

0 comments… add one

Leave a Reply