The Curious Case of Protractor and Page Synchronization
Protractor is an amazing tool but use it incorrectly and it will make your life miserable. This blog post is about how a simple setTimeout()
made my life miserable.
Protractor is an end-to-end test framework for Angular and AngularJS applications. It is a Node.js program built on top of WebDriverJS. It runs tests against your application running in a real browser, interacting with it as a user would.
Protractor wraps WebDriverJS which is Javascript Selenium bindings — in other words, Protractor interacts with a browser through the Selenium WebDriver. It provides a really convenient API and has some unique Angular-specific features, like Angular specific element locating strategies (by.model()
, by.binding()
, by.repeater()
), automatic synchronization between Protractor and Angular that helps to minimize the use of explicit waits here and there.
The Background —
Before we get to the problem, we need to understand how protractor deals with asynchronous nature of Javascript and provides a synchronous API.
Despite being asynchronous, protractor allows us to write synchronous tests. This is possible because of the WebDriverJS library which uses a promise manager to ease the pain of working with a purely asynchronous API. WebDriverJS maintains a queue of pending promises, called the control flow, to keep execution organized. For example, consider this test:
it('should find an element by text input model', function() {
browser.get('app/index.html#/form');
var username = element(by.model('username'));
username.clear();
username.sendKeys('Jane Doe');
var name = element(by.binding('username'));
expect(name.getText()).toEqual('Jane Doe');
// Point A
});
At Point A, none of the tasks have executed yet. The browser.get
call is at the front of the control flow queue, and the name.getText()
call is at the back. The value of name.getText()
at point A is an unresolved promise object.
Protractor provides two ways to handle asynchronous actions
- Promise Manager/Control Flow
- Async/Await
The Promise Manager/Control Flow Monster
Before performing any action, Protractor waits until there are no pending asynchronous tasks in your Angular application. This means that all timeouts and HTTP requests are finished. For Angular apps, Protractor will wait until the Angular Zone stabilizes. This means long-running asynchronous operations will block your test from continuing.
So, if you have an infinite timeout (usually used to refresh tokens), your tests will wait indefinitely.
In fabric8-ui, we have a piece of code that refreshes the JWT token
setupRefreshTimer(refreshInSeconds: number) {
....
this.clearTimeoutId = setTimeout(() =>
this.refreshToken(), refreshInMs
);
....
}
The above code worked as expected by triggering a new timer just when the existing token was about to expire. This meant the control flow queue would always have a setTimeout() call within it. As soon as the current timeout was about to expire, a new one was added to the queue. This meant that the Angular Zone would never stabilize and our tests would keep waiting indefinitely. Our tests would wait indefinitely and always fail with the following error
Failed: Timed out waiting for Protractor to synchronize with the page after 11 seconds. Please see https://github.com/angular/protractor/blob/master/docs/faq.md.
Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL
The easiest way to fix the above error was to run the setTimeout function outside the angular zone with the following code
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
// Changes here will not propagate into your view.
this.ngZone.run(() => {
// Run inside the ngZone to trigger change detection.
});
}, REALLY_LONG_DELAY);
});
But we decided to fix the underlying issue for once and for all by using Async/Await instead of Control Flow.
Slaying the Monster with Async/Await
The Async/Await way allows us to choose to when to wait for an action. Instead of waiting for angular to stabilize on every action, we can selectively wait for angular to stabilize on selected actions. Let’s look at an example
describe('angularjs homepage', function() {
it('should greet the named user', async function() {
await browser.get('http://www.angularjs.org');
await element(by.model('yourName')).sendKeys('Julie');
var greeting = element(by.binding('yourName'));
expect(await greeting.getText()).toEqual('Hello Julie!');
});
In the above example, protractor will wait only at lines which are awaiting for the promise to resolve. We can add “await” keyword to each operation that we want our program to wait for. Before you start using the Async/Await pattern with Protractor, you’ll have to disable the Control Flow (at least for now). Control Flow will soon be deprecated and Async/Await will be the default.
Protractor won’t wait for HTTP requests/async operations unless it is explicitly specified via await.
Control flow uses a queue for actions to wait upon while async/await waits only at the specified lines. We ended up fixing the Waiting for Protractor to synchronize issue by migrating to async/await from control flow. You can find our typescript based tests here.
Takeaways —
- Use Async/Await instead of Control Flow, unless you have a concrete reason to not use Async/Await.
- Wrap your timeouts and long duration async operations in ngZone.runOutsideAngular() if you plan to use Control Flow.
- The Control Flow will soon be deprecated and async/await will be the default (See this Github issue).