4 Comments

Asynchronous Testing with Protractor’s ControlFlow

Protractor is an end-to-end testing framework for AngularJS applications that uses a real browser, just as a real user would. It is built on top of NodeJS and WebDriverJS and taps into the internals of Angular to know when Angular is done processing and updating bindings.

Protractor LogoThe joy of Protractor and WebDriverJS is that we can write our tests in a synchronous style, and still have the added benefits of asynchronous code.

We are currently using Protractor for testing a Rails and Angular application that we’re developing here in the Detroit office. We were faced with a problem: “How do we write our own functions which are asynchronous but appear in the same synchronous style that Protractor tests are written in?” This is especially handy for performing REST requests for seeding data into the test database using something like the rails Hangar gem.

The ControlFlow

This required diving into the internals of how Protractor and WebDriverJS handle asynchrony. WebDriverJS uses an object called theControlFlow, which coordinates the scheduling and execution of commands operating on a queue.

Any time an asynchronous command is invoked, it’s put in the queue where it will wait for all previous commands to complete prior to execution. The ControlFlow allows us to write code like this:

driver.get(“http://www.google.com”);
driver.findElement(webdriver.By.name('q')).sendKeys('webdriver');
driver.findElement(webdriver.By.name('btnG')).click();
driver.getTitle().then(function(title) {
  console.log(title);
});

That really performs in a synchronous manner such as this:

driver.get(“http://www.google.com”).
    then(function() {
      return driver.findElement(webdriver.By.name('q'));
    }).
    then(function(q) {
      return q.sendKeys('webdriver');
    }).
    then(function() {
      return driver.findElement(webdriver.By.name('btnG'));
    }).
    then(function(btnG) {
      return btnG.click();
    }).
    then(function() {
      return driver.getTitle();
    }).
    then(function(title) {
      console.log(title);
    });

Behind the scenes of WebDriver, each call that interacts with the browser, such as get(), findElement(), sendKeys(), and click(), is being scheduled and pushed onto the WebDriver ControlFlow, allowing us to not have to worry about using .then() on the resulting promises (unless we specifically want the result returned by the call).

Using the WebDriverJS Promises

The ControlFlow queues functions based on promises. The following code exhibits how to take a callback based function and wrap it into WebDriver’s Promise API. We will be using the Restler NodeJS library.

To create a WebDriver/Protractor deferred using their promise API:

deferred = protractor.promise.defer()

Then fullfill or reject the promise based on Restler’s events and return the deferred promise:

restler.postJson(url, requestData, options).once('success', function() {
  return deferred.fulfill();
}).once('error', function() {
  return deferred.reject();
}).once('fail', function() {
  return deferred.reject();
});
 
deferred.promise;

Pushing the Promise Onto the ControlFlow

To solve our problem of invoking a synchronous REST request, we have to interact with WebDriver’s ControlFlow.

To get the instance of the ControlFlow that is shared with protractor:

var flow = browser.controlFlow()

Then we can use the ControlFlow’s execute() function, assuming we created a based on the above code that wraps restler’s callbacks and returns a WebDriverJS promise:

flow.execute(restFunction)

Chaining Promises and Getting Results

The good news is that ControlFlow’s execute() function returns a new promise that will be fulfilled when our original promise is executed by the scheduler. We can use use the all() function, which returns a new promise once all of the control flow’s promises are fulfilled:

var allPromise = protractor.promise.all(flow.execute(restFunction1), flow.execute(restFunction2))

When allPromise is fulfilled, it will return an array of the results of all the promises passed to all() as arguments.

Ideally the implementation of your helper functions would abstract the intermediary promises that are being passed to the ControlFlow and purely operate based upon the promises returned from the ControlFlow’s execute function.