logo logo

Design Patterns in Test Automation

Design Patterns in Test Automation Framework

This article highlights the design patterns & architectural considerations to take into account when building a Selenium-based / Appium-based Test Automation Framework. The examples are purposely shown in various programming languages to highlight that this thought process is applicable for any programming language you use for building an Automation Framework.

Table of Contents – Design Patterns in Test Automation

  1. Selenium WebDriver 101
  2. Why do we need a Test Automation Framework?
  3. Criteria for building a Test Automation Framework
  4. Design Patterns in Test Automation Framework
  5. Characteristics of a good Page-Object Pattern implementation
  6. Challenges of using Page-Object Pattern
  7. Solution: Business-Layer Page-Object Pattern
  8. Test Automation Framework Architecture

Selenium WebDriver 101

Let’s start by quickly understanding what WebDriver is.

Selenium is one of the most popular web-automation tools. Surprisingly, a lot of people actually get confused about what Selenium really is.

Here is the official version of what Selenium is: https://www.selenium.dev/documentation/en/

WebDriver is the core of Selenium. It is an interface that uses browser automation APIs provided by browser vendors to simulate user behaviour and interaction by controlling the browser in the form of running tests.

WebDriver drives a browser either locally or on a remote machine using the Selenium server. Selenium WebDriver refers to both the language bindings and the implementations of the individual browser controlling code. This is commonly referred to as just WebDriver.

The Selenium Browser Automation Project also lists a quick example of how to quickly get started with using WebDriver.

Below is another example of how this code can look like:

var webdriver = require('selenium-webdriver'),
    test = require('selenium-webdriver/testing'),
    assert = require('assert');

test.describe("Google Search", function() {

    test.it("should take user input correctly", function() {

        var driver = new webdriver.Builder().
            withCapabilities(webdriver.Capabilities.chrome()).build();
        driver.manage().timeouts().implicitlyWait(1000);

        driver.get("http://localhost:8082/community-app");
        var element = driver.findElement(webdriver.By.id("uid"));
        element.sendKeys("mifos");
        var value = element.getAttribute("value");
        value.then(function(value) {
            assert.equal(value, "mifos");
        });

    });

});

Why do we need a Test Automation Framework?

As seen in the above example, it is easy to get started using WebDriver to simulate user behavior.

However, typically, one uses WebDriver to automate many different user scenarios / interactions in order to get quick validation if the functionality in the product-under-test is working correctly, as expected. Some of these products may have a small-life, however, most of these would be around for a long time.

As a result, the team testing this product would have a lot of user scenarios that they would want to be automated. I have seen cases where the team has 1000s of tests identified and automated for their product-under-test.

In such cases, continuing to implement tests in similar ways as shown in the simple, getting-started example will quickly turn into a nightmare for various reasons of code quality, maintainability, extensibility and scalability.

You may set up some structure for your tests, and build some sort of a framework, but that will still be limiting as you scale.

Test implementation in Ruby

In the above example, of test implementation in Ruby, the home_page.rb file ended up being 1000+ line long, which made it very difficult to quickly understand what this class ended up doing, and what functionality was implemented here.

The architecture of such a framework looks as simple as this, which could also be referred to as non-existent:

No Design Patterns Used

We need a better, and structured approach to implementing the test scenarios!


Criteria for building a Test Automation Framework

Writing code is easy, but writing good code is not as easy. Here are the reasons why I say this:

  • “Good” is subjective.
  • “Good” depends on the context & overall objective.

Similarly, implementing automated test cases is easy (as seen from the getting started example shared earlier). However, scaling this up to be able to implement and run a huge number of tests quickly and efficiently, against an evolving product is not easy!

I refer to a few principles when building a Test Automation Framework. They are:

Design Patterns in Test Automation Framework

Test Automation (using tools like Selenium / Appium) are essentially development activities. One needs to have good programming knowledge to be able to implement tests that are robust, stable, efficient, fast, maintainable and extensible!

There are many Design Patterns available for Software Development (as seen here). Since Test Automation is also a Development activity – The understanding of Design Patterns is critical in Test Automation as well.

Below are some commonly used Design Patterns one would use, directly or indirectly, in building a Test Automation Framework:

Characteristics of a good Page-Object Pattern Implementation

A good page-object pattern implementation would have the following characteristics:

  • Pages are well identified.
  • Use Composite Pattern to compose complex pages and also allow reuse of snippets of pages across the product.
    • Ex: If Search functionality is common across majority of the pages in your product, then:
      • Define a SearchPage object.
      • In other pages, ex: HomePage, LoginPage, ProductPage, etc, use the Composition Pattern to include SearchPage as part of its definition.
      • Example:
      • Composite Pattern
  • Pages do not have any assertions in them.
  • Pages do not have getters-setters in the pages (or in any class in your code). This is a bad practice and it breaks Encapsulation.
  • Pages should have logical methods that do action on the page, or gets information from the page.
  • Pages should have no business logic in the Page-Object method implementation, except handling cases like:
    • If ex: there are Country & State dropdown controls on the page, and the State values get populated based on which Country is selected, then the method selecting the Country should wait for the items in the State dropdown to get populated.
  • Each Page object should have an explicit return type, i.e. the Page object method SHOULD NOT return void.
    • Ex: On successful login, the method should return the HomePage object.
    • On unsuccessful login, the method should return the same LoginPage object.
    • There would be cases when you are getting information from the page itself. In such cases, the methods should return specific datatypes.
      • Ex: isLoggedIn should return boolean.
      • Ex: getLoggedInUserName should return String.
      • Ex: getRegisteredAddressInProfile should return a custom object Address, or any other data structure that makes sense in the context of the product-under-test.

The architecture of such a framework evolves as below:

Good Page-Object Pattern Implementation

Challenges of using Page-Object Pattern

Page Objects help implement tests easily, and keep the code well structured. This allows easy updates in the Page Objects based on evolving product functionality.

However, there is a challenge.

In the Test implementation methods, ex: if you are using JUnit / TestNG as the test runner, then in the @Test methods, you now need to orchestrate the Page object interactions and also add your assertions.

Below is an example of 2 tests:

Test 1:

@Test
public void shouldRevertToOriginalContentAfterClickingCancel() {

    PatientInformation patientInfomation = new PatientInformation(firstName, middleName, lastName, gender);
    RegistrationPage registrationPage = new RegistrationPage();
    PatientChartPage patientChartPage = registrationPage.registerPatientWithBasicInformation(patientInfomation);

    String originalFirstName = patientChartPage.getFirstName(); 
    assertThat(originalFirstName, is(firstName)); 

    patientChartPage.updateName(newFirstName, newLastName);
    patientChartPage.updateAddressLine(newLine1, newLine2);
    patientChartPage.updateCity(newCity);
    patientChartPage.updateState(newState);
    patientChartPage.updateZip(newZip);
    patientChartPage.cancelUpdate();

    String updatedFirstName = patientChartPage.getFirstName();
    assertThat(updatedFirstName, is(originalFirstName));
}

Test 2:

@Test
public void shouldUpdateExistingPatientWithNewInformationAfterClickingSave() throws InterruptedException {

    PatientInformation expectedPatientInformation = new PatientInformation(firstName, middleName, lastName, gender);
    RegistrationPage registrationPage = new RegistrationPage();

    registrationPage.registerPatientWithBasicInformation(expectedPatientInformation);

    String originalFirstName = patientChartPage.getFirstName(); 
    assertThat(originalFirstName, is(firstName)); 
    
    patientChartPage.updateName(newFirstName, newLastName);
    patientChartPage.updateAddressLine(newLine1, newLine2);
    patientChartPage.updateCity(newCity);
    patientChartPage.updateState(newState);
    patientChartPage.updateZip(newZip);
    patientChartPage.saveUpdate();

    Driver.get().navigate().refresh();

    assertThat(patientChartPage.getFirstName(), is(newFirstName));
    assertThat(patientChartPage.getLastName()), is(newLastName));
    assertThat(patientChartPage.getCity(), is(newCity));
    assertThat(patientChartPage.getState(), is(newState));
    assertThat(patientChartPage.getAddressLine1()), is(newLine1));
    assertThat(patientChartPage.getAddressLine2()), is(newLine2));
    assertThat(patientChartPage.getZip()), is(newZip));
}

Here are the challenges of this implementation:

  • The intent of the test is lost in the details of the interaction of page objects and assertions.
  • The @Test methods become quite unreadable.
  • There is a lot of duplication in both the test implementations.

As you implement more variants of test scenarios, you will encounter there would be many instances of test implementations needing slight tweaks to the implemented test to verify variants of the scenarios. In such cases, you would end up copying most of the implementation from 1 test into the others – which is wasted effort, code duplication and makes it difficult to update the tests as the product functionality evolves.

In some cases, you may be able to extract common methods in the same class to reuse the implementation, but that seems like an ad-hoc way of implementation.

That is the reason I do not like to use the Page Object Pattern directly in my Test implementation for larger implementations.


Solution: Business-Layer Page-Object Pattern

It is very important to have the Test specification be crisp and clear. This way the intent of the test is understood very easily and the reader knows exactly what is expected from the product-under-test as a result of running that particular test.

This is the area the Business-Layer Page-Object Pattern helps. Below is how the implementation flow is using this pattern:

  1. Tests should talk business language only! No granular operations / UI interactions.
  2. The Test implementation is an orchestration of corresponding business operations as it is simulating a deterministic, specific scenario.
  3. Business operations are implemented in a layer between the tests & page objects – called BusinessLayer. This allows for better reuse of implementation.
  4. Implementation of an business operation is an orchestration between other business operations, and / or an orchestration of page objects.
  5. The business operation does the assertions of expectations, NOT page-objects.
  6. Each business operation / page object has a valid return type (never void). Each operation (in business or page object) being successful means there are a defined number of methods / operations the product can now do (as you are driving the product under test to do your bidding).

Let’s look at some examples of how this can be implemented.

Test Automation Framework Architecture

The overall architecture of the Test Automation Framework that allows me to implement, evolve and scale in an easy fashion is shown below:

Business-Layer Page-Object Pattern

Below is my folder structure for my Test Automation Framework:

Test Automation Framework Structure

Here is the Test method – clearly highlighting the intent of the test:

@Test(groups = {"a-addcash", "a-demo2", "a-RcRegression"})
public void RepeatDepositAddCashTest() {
    log.info("Running RepeatDepositAddCashTest");
    PlayerUtil playerUtil = new PlayerUtil(getTestExecutionContext());
    HashMap<RegistrationDetails, String> userData = playerUtil.getUserWithModValue(7, 4);
    String userName = userData.get(RegistrationDetails.USERNAME);
    String password = userData.get(RegistrationDetails.PASSWORD);
    playerUtil.saveAddressDetailsOfUser(userName, password);
    new LoginBL().loginUsing(userName, password)
            .startAddcash()
            .addCashUsingNetBanking(100, "Allahabad", "test", "test")
            .verifyOrderID()
            .verifyModeOfPayment("NETBANKING")
            .verifyGamesLobbyLink()
            .done();
}

Here are some sample Business Layer methods:

public FeedbackOverlayBL verifyLoveThisAppThankYouPage() {
    return new FeedbackOverlayBL()
            .verifyLoveThisAppCTA()
            .verifyThankYouPageInLoveThisApp();
}

public LobbyBL goToFeedBackFormAndCancel() {
    return new FeedbackOverlayBL()
            .goToFeedbackForm().cancelFeedbackForm();
}

public AddCashLimitsBL addAmountForAddcashLimits(int amount) {
    ChooseAmountPage.getInstance()
            .addAmount(amount);
    return new AddCashLimitsBL();
}

public LobbyBL goToCashGames() {
    LobbyPage.getInstance().clickCashGamesButton();
    return this;
}

Here are some sample Page-Object methods:

public PaymentModePage addAmount(int amount) {
    WebElement addCashEnterAmountElement = driver.findElementByAccessibilityId(addCashEnterAmount);
    waitForElementToBePresent(addCashEnterAmountElement);
    addCashEnterAmountElement.clear();
    addCashEnterAmountElement.sendKeys(String.valueOf(amount));
    hideKeyBoard();
    eyes.checkWindow("AddAmount");
    driver.findElementByAccessibilityId(addCash).click();
    return PaymentModePage.getInstance();
}

public boolean isPromoCodeApplySuccessful(String promoCode) {
    log.info("Verifying promocode is successfully applied with name: " + promoCode);
    waitForElementToBePresent(driver.findElementByAccessibilityId(appliedPromocodeNameId));
    eyes.checkWindow("isPromoCodeApplySuccessful");
    return driver.findElementByAccessibilityId(appliedPromocodeNameId).getText().equalsIgnoreCase(promoCode);
}

References:

Comments

42 6 comments

Leave a Reply

FacebookLinkedInTwitterEmail