JUnit 4 and Selenium – Part Three: Parallelism and OnDemand

This is the third and final part of how to do Selenium testing in parallel. Today we get into the meat of things with parallel execution, both locally and in OnDemand. JUnit 4 doesn't ship with a true parallel solution, but Harald Wellman wrote a blog post on running parameterized JUnit tests in parallel, which does just about everything we want. But instead of having a fixed thread pool side, I modified it to use a dynamically sized one. With this new class in your project, we only need to change the @RunWith line to use our new parallel, parameterized runner.

@RunWith(Parallelized.class)

Now the execution profile is as follows: For each item in the @Parameters Collection, launch a new thread to execute the current test method in parallel. Have two browser strings? Then two threads run in parallel. Have eight browser strings? Well, that is eight threads running in parallel. This means that however long it takes run a single browser through all your scripts, that's how long it will take to run against any number of browsers. Win! Well, sure, but only if you have all those browsers on your machine or have an Se-Grid installation at your disposal. Most people do not have either luxury, so that is where Sauce OnDemand comes into the mix. Hosted in the cloud, you don't need to have the browsers on your machine or even behind the firewall. With some minor modification to our example class, we can have a script that runs locally or in the OnDemand cloud.

package com.saucelabs.cc;

import java.util.Collection;
import java.util.List;
import java.util.LinkedList;
import java.util.Arrays;

import java.io.InputStream;
import java.util.Properties;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;

import com.thoughtworks.selenium.DefaultSelenium;
import com.thoughtworks.selenium.Selenium;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.FieldNamingPolicy;

import com.saucelabs.junit.Parallelized;
import com.saucelabs.ondemand.ConnectionParameters;

@RunWith(Parallelized.class)
public class TestParallelOnDemand {
    private Selenium selenium;
    private String browser;
    private String browserVersion;
    private String os;
    public static Properties browserProps = new Properties();
    private Properties parallelProps = new Properties(); 
    private String json;

    public TestParallelOnDemand(String os, String browser, String version) throws Exception {
        super();
        this.browser = browser;
        this.browserVersion = version;
        this.os = os;

        InputStream is = this.getClass().getResourceAsStream("/parallel.properties");
        parallelProps.load(is);

        if (parallelProps.getProperty("ondemand").equals("true")) {
          Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_DASHES).create();
          ConnectionParameters od = new ConnectionParameters();
          od.setBrowser(this.browser);
          od.browserVersion = this.browserVersion;
          od.jobName = this.getClass().getName();
          od.os = this.os;
          this.json = gson.toJson(od);
        }
    }

    @Parameters
    public static LinkedList browsersStrings() throws Exception { 
      LinkedList browsers = new LinkedList();

      InputStream is = TestParallelOnDemand.class.getResourceAsStream("/browser.properties");
      browserProps.load(is);

      String[] rawBrowserStrings = browserProps.getProperty("browsers").split(",");
      for (String rawBrowserString : rawBrowserStrings) {
        if (rawBrowserString.indexOf(";") != -1) {
          String[] browserParts = rawBrowserString.split(";");
          browsers.add(new String[] { browserParts[0], browserParts[1], browserParts[2] });
        } else {
          browsers.add(new String[] { rawBrowserString, "", "" });
        }
      }
      return browsers;
    }

    @Before
    public void setUp() throws Exception  {
        if (parallelProps.getProperty("ondemand").equals("true")) {
          selenium = new DefaultSelenium("saucelabs.com", 4444, this.json, "http://saucelabs.com");
        } else {
          selenium = new DefaultSelenium("localhost", 4444, this.browser, "http://localhost:3000");
        }
        selenium.start(); 
        selenium.setTimeout("90000");
        selenium.windowMaximize();
    }

    @Test
    public void testSauce() throws Exception {
        this.selenium.open("/");
        assertEquals("Cross browser testing with Selenium - Sauce Labs", this.selenium.getTitle());
    }

    @After
    public void tearDown() throws Exception {
        selenium.stop();
    }
}

So what has changed? The @Parameters are loaded differently again. When the script was only ever going to run locally, the browser string provided enough information. But since it's also going to be run in OnDemand, there needs to be some extra information for the OS and browser version. Using the same pattern as part two, the browser string information has been moved from the test code and into an external properties file.

# order is important! - OS/browser/version
browsers=Windows 2003;*firefox;3.6.,Windows 2003;*googlechrome;3.,

OnDemand integrates with Selenium scripts by sending a JSON string with the configuration information to their server, which is why there is a new decision in the @Before method. An important design pattern when creating scripts that run locally or in some other environment is to add a switch in the script to determine where to go. You don't want to have to modify code itself in order to run between different environments. The JSON itself is an interesting challenge. Just as changing the source code didn't make sense when parameterizing the individual browser strings, hard coding the JSON doesn't make much sense either. To build the JSON in code, the Gson library was used. Here is the ConnectionParameters class that Gson is building the connection information from.

package com.saucelabs.ondemand;

import java.io.InputStream;
import java.util.Properties;

public class ConnectionParameters {
  // hyphenated strings like access-key and browser-version need to be camel-case
  public String username;
  public String accessKey;
  public String os;
  private String browser;
  public String browserVersion;
  public String jobName;

  // gson does not convert transient fields
  private transient String propertiesFile = "/ondemand.properties";
  private transient Properties ondemandProperties = new Properties();

  public ConnectionParameters() throws Exception {
    InputStream is = this.getClass().getResourceAsStream(propertiesFile);
    ondemandProperties.load(is);
    this.username = ondemandProperties.getProperty("username");
    this.accessKey = ondemandProperties.getProperty("access-key");
  }

  // ondemand browser strings cant start with the *
  public void setBrowser(String browser) {
    if (browser.startsWith("*")) {
      this.browser = browser.substring(1);
    }
  }

  public String getBrowser() {
    return this.browser;
  }
}

It should also be no surprise by now that the username and access key is loaded from an external file, as that can change and we wouldn't want to recompile for something like that. The only other thing of interest is the getter/setter for the browser string. Recall that the string can be used by either the local Se-RC server or OnDemand. Currently, OnDemand does not like having the * at the beginning, so we take care of that behind the scenes from the script. With this bit of infrastructure in place, we can run our scripts against any OS/Browser/Version combination that OnDemand currently supports or you have handy on your own hardware, and it will automatically scale execution to run each method in parallel. The example used here is a little extreme as the test itself is really only two lines, but in a real implementation scenario I would:

  • Create a custom formatter for Selenium IDE (or Sauce IDE) so when you export the recorded script, it has everything your particular framework requires
  • Create a class hierarchy for the scripts to move all the non @Test methods out of the class to make it a bit tidier

And so concludes the series on how to run Selenium scripts in parallel using JUnit 4. To see the full project, including the Maven pom, check out Parallel Test Examples in the Github repo.

Written by

Adam Goucher

Topics

SeleniumUnit Testing