Test Automation Revisit

Simplest steps to automate API & UI tests

Krinder

Selenium RestAssured CircleCI ready Java + Maven test framework

I am writing these for those who got the java bug and weren’t cured (yet) by JS or Python, and want to keep automation suite growing with flexibility.

The repository of this skeleton project is shared through GitHub krinder repo. The class diagram looks like below.

The framework comes with a few sample tests to demonstrate what the code would look like to test Wikipedia page at basic level on both Web and API interface. I have it built and run in CircleCI project to track test stability (and receive an email when some tests fail)

CircleCI Test Insights

The aim of Krinder repo was to make it usable right away (after following its README file), but I will write on some shortcuts to make a similar project from scratch.


Start from installing IntelliJ and JDK

brew install openjdk@19

Above command might be many engineer’s preferred way on a Mac, but I find it cumbersome to configure IntelliJ with existing JDK. IntelliJ offers to install a JDK and it works most seamlessly from my experience.

Openjdk-19 was installed through IntelliJ

IntelliJ also offers to create a new Maven project from an archetype, but after installing maven (brew install maven) we can always follow maven’s quickstart guide. After creating the pom.xml, I recommend raising the target java version to 1.8 (or newer).

Once we open the project as an existing maven project, we can run a sample code AppTest.java through maven.

> mvn test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.193 s - in dev.changgull.AppTest

Replacing JUnit with TestNG

TestNG provides unique integration test oriented features compared to JUnit. To name a few:

  • Dependency control at various level
  • DataProvider to repeat the same test code per data and produce test result report on individual run instances
  • Test group membership control and layered suite files

To add TestNG artifact to the dependency of this project, we can simply search “testng” from the maven repository and copy the dependency block to paste into the pom.xml’s dependencies section.

After the dependency is added, right click on the pom.xml > maven > reload project, then replace import statements and Assert statements to use TestNG’s.

Code Structure

Now we have the very basic skeleton, we can clean up the house and build basic blocks. We can delete src/main and start writing basic building blocks in src/test.

Test code would benefit from common properties and methods inherited from a Base class. Following list could be a good set of requirements for a Base class.

  • A static java properties to handle common data accessed globally, and a set of handler methods
  • Method to instantiate a logger

All test classes would benefit from having a BaseTest class as an ancestor, handling functions below

  • Loading properties file
    • We can prepare multiple cascaded properties files to mimic namespace in a language. For example, default.properties file will be loaded first setting non-environment-specific global data. After that, an environment specific properties can be passed on through maven -D option (system property) to add new or override existing values.
    • This is very useful when flexibly choosing a target environment at local debugging situation or in a continuous integration script.
  • Handling system property variables set through command line
    • Target environment
    • WebDriver options (headless, remote, size of window, etc)
public class BaseTest extends Base {
    @Parameters({"env", "browserOptions", "chromeDriverPath"})
    @BeforeSuite(alwaysRun = true)
    public void beforeSuite(@Optional("stage") String env
            , @Optional("") String browserOptions
            , @Optional @Nullable String chromeDriverPath) {
        loadProperties("default.properties");
        loadProperties(env + ".properties");
        setProperty("browser.options", browserOptions);
        if (chromeDriverPath != null) setProperty("chromeDriverPath", chromeDriverPath);
    }
}

Having this feature defined under @BeforeSuite is useful in two ways. It will guarantee a test class will have the given properties set before getting into the meat of the test. Also, because it’s run at Suite level, it will not waste processing time by repeating the same values set over and over within a run.

BasePage for UI test

When a multiple use cases are covered by test classes, same page elements can be accessed in many places in the test code base. A login page will be used by many test scenarios for example. A page class extending Krinder’s BasePage can be used in a test class as following.

@Test
void searchWikipedia() {
    page = new WikiHomePage()
            .openPage()
            .verifySearchBoxExists()
            .search("testng");
}

@Test(dependsOnMethods = "searchWikipedia")
void verifyWikipediaContent() {
    new WikiContentPage()
            .continueFrom(page)
            .verifyTitle("TestNG")
            .verifyExternalLinkUrl("http://testng.org/doc/");
}

In this example, a two separate Page objects were used. These page objects can be used in other tests, reducing the efforts to maintain the page accessors when the product code is refactored, leaving the test page code obsolete. Another feature exhibited above is method chaining.

Method Chaining

Method chaining comes with some benefit and some impediments in test code writing. It helps in readability of the test code. As development lifecycle gets shorter and shorter, it would be too much to maintain a written test case and the code at the same time. Since it will be a healthy practice to have product managers review the test case to make sure the features designed are tested to cover core business use cases, it will be quite useful to have product managers review the test code.

One downside of the method chaining is that a dynamically created test data that will be later used can’t be easily retrieved. Without method chaining we can do something like this:

@Test
void testWeatherApi() {
  Weather w = weatherPage.getCurrentWeather();
  if (w == Weather.SUNNY) {
    weatherPage.verifyIcon(Icons.SUNNY);
  else if (w == Weather.CLOUDY) {
    weatherPage.verifyIcon(ICONS.CLOUDY);
  } ...
}

With method chaining, the verification steps can be obscured by a method like weatherPage.verifyTheIconMatchesWithCurrentWeather()

We can workaround with this downside by using setProperty(), getProperty() to persist some intermediary data among interdependent tests.

Java Generics

A page class would inherit many useful methods such as openPage() or continueFromPage(BasePage page). To have these method chained to that specific page class’s methods, it should return the descendant class type instance, not the BasePage class. Java Generics comes into help here.

public class BasePage<T> extends Base {

    public T openUrl(String url) {
        if (getDriver() == null) initChromeDriver();
        getLogger().info("Opening url: " + url);
        getDriver().get(url);
        PageFactory.initElements(getDriver(), this);
        return (T) this;
    }

    public T openPage() {
        return openUrl(getUrl());
    }
...
}

BasePage class would be defined with generic class type <T>, and methods would return itself casted to the generic class T. A child class would then extend with T replaced with its own name.

public class WikiHomePage extends BasePage<WikiHomePage> {
    @FindBy(xpath = "//input[@id='searchInput']")
    WebElement inputSearch;

    public WikiHomePage() {
        setUrl(getProperty("url.wikipedia.home"));
    }
...
}
Handling Selenium WebDriver

Selenium WebDriver can be used in three different operational models.

  1. Open the target browser in GUI. A test developer running the test while watching the browser responding to the test can be an example.
  2. Open the target browser in headless mode. Because it does not require GUI supported by the operating system, this operation mode is very useful in running the test in Docker container in CI environment.
  3. Open the target browser in GUI of a remote system. We can setup a network of selenium grid nodes, running different target user agents, but virtual client platforms such as BrowserStack and LambdaTest could be much more business viable solution in terms of extensibility and scalability.

When the team size is small, it will be a recommended model to develop through 1, then run CI through 2. As the business requirements get bigger and more user-agent coverage is expected, operating CI through 3 will be inevitable. Developing tests to be compatible in both GUI and headless mode can be also challenging depending on the tech stack of the front-end development.

BaseApi for Rest API Test

Actually RestAssured’s classes including RequestSpecification are designed with all readability considerations such as method chaining discussed above, so we don’t have to handle a lot of things in BaseApi class. I still think having this buildRequest method in BaseApi can be useful.

protected void buildRequest(String baseUri) {
    RestAssured.defaultParser = Parser.JSON;
    Header header = new Header("User-Agent", "Krinder-by-changgull.dev");
    setRequest(given().header(header).baseUri(baseUri));
}

A sample test can look like below, though the readability can be improved by using hamcrest.Matchers. From writing many Rest API tests, I find it much more flexible to use JsonPath than individually verifying through Matchers.

void verifyApiSummary() {
    JsonPath p = new WikipediaApi()
            .getRequest()
            .get("page/summary/TestNG")
            .then()
            .statusCode(200)
            .extract()
            .jsonPath();

    Assert.assertEquals(p.getString("title"), "TestNG");
    Assert.assertTrue(p.getString("extract").contains("Cédric Beust"));
}

For the folks that need a quick start, just install JDK, maven, clone the Kinder repo, cd to the repo, download chromedriver using the script, and run maven test. The script downloadChromeDriver.sh assumes your system has curl.

git clone git@github.com:changgull/krinder.git
cd kinder
./downloadChromeDriver.sh
mvn test