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 assumes that:
- You are familiar with the TestProject OpenSDK
- You can create a new TestProject OpenSDK project
- You are familiar with JUnit 5
- You can integrate the TestProject OpenSDK with JUnit 5
- You can write tests for web applications with TestProject OpenSDK and JUnit 5
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).
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.
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:
- Read the base url of the system under test from a JVM system property called
webdriver.base.url
. - If no base url is found, throw a new
RuntimeException
. - 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:
- Add a
static buildFromPath()
method to theWebDriverUrlBuilder
class. This method takes the path template and the parameters which are referenced by the format specifiers found from the pathString
as method parameters. Also, this method returns the created URL address. - If the path is
null
, throw a newNullPointerException
. - Create the real path by using the
format()
method of theString
class. - Get the base URL.
- If the base URL doesn't end with the character: '/', append the character '/' to the base URL.
- If the path starts with the character: '/', replace that character with an empty string.
- 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:
- Add a
final WebDriver
field to the created class. - Implement a
package-private
constructor which sets the value of thewebDriver
field by using constructor injection. - Write a
public
method calledfindNoSearchResultsText()
. This method finds the text that's shown on the search result page when no search results are found and returns the found text. - Write a
public
method calledfindSearchResults()
. This method finds the search results which are shown on the search result page and returns aList
ofBlogPost
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:
- Add a
final
field calledpageUrl
to the created class. This field contains the environment-specific URL of the search page. - Add a
final WebDriver
field to the created class. - 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. - Write a
public
method calledopen()
. This method opens the search page and returns a newSearchPage
object. - Write a
public
method calledfindBlogPostsBySearchTerm()
. 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 newSearchResultPage
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); } }
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.