Building Tests /

Browser automation using elements

Controlling the page using element interactions.

Previous: Elements and Locators Next: Assertions

Now that we know how to find something on a page we can start to automate the process of using a website in order to test it. Selenium WebDriver exposes a handful of interactions to your test code - you can move the mouse around, click on things, enter text, and change form elements. You can even use touch actions for testing how your website works on mobile devices.

The process of actually building a script that launches a webpage, interacts with it in a deterministic way, and then tests to make sure the state is what you're expecting it to be can be somewhat laborious. You'll probably be a little surprised how many individual interactions are necessary to do even simple things like filling in a form. This is actually quite a good thing - it can show you where your UX workflow could be improved.

Quick Plug You may have seen the footer of this book advertises a Chrome extension called Lantern. This is an application that intergrates with your browser to help you build user test scripts and automated end-to-end tests. In essense it records your actions on a page in a way that can then be exported in different testing languages, or in plain English for instructions to give to someone doing manual testing. Check it out. It makes building tests much, much easier.

Once your page has loaded in a test you can start to interact with it by locating elements and sending them instructions. The main way to do this for navigation is with click(). The click() method triggers an element's click event, which then makes the browser do things like navigating to a new page, or triggering a button action, or just focusing on a form input.

The process of using the click() method in a test is very simple.

describe('Navigate to the account page', function(){
 
    beforeEach(function(){
        browser.get('http://testdomain.dev/');
    });
 
    it('Clicks the account link', function(){
        element(by.css('a#account')).click();
        expect(browser.getTitle().toEqual('Your Account');
    })
})

In this test group we're testing navigating to the account page. Before the test runs the beforeEach callback loads the 'http://testdomain.dev/' URL. When the get has completed the test runs. It locates the anchor that has an ID of 'account' and click() triggers it's click event. As it's a link that loads the account page. When the page has loaded expect() calls the browser object's getTitle() method to get the page title and that's compared to 'Your Account' in the toEqual() assertion.

If you wanted to you could compile a vast list of hundreds of actions to load a website and click() through layers and layers of navigation. I wouldn't recommend doing that though. It'd become very fragile very quickly.

If you want to use a pageobject to interaction with the website you're testing, and you should, then the code would need to be split across two files - the pageobject for the initial page (where we click on the anchor) and the spec where we test the account page title.

The pageobject for the index page;

var indexpage = function () {
    browser.get('http://testdomain.dev/');
};
 
indexpage.prototype = Object.create({}, {
    navigateToAccount: {
        get: function () {
            return element(by.css('a#account')).click();
        }
    }
});
 
module.exports = indexpage;

We'll put the pageobject code in a file called 'index.page.js'. The new test would then use the indexpage pageobject instead of interacting with the page directly.

var indexpage = require('./index.page.js');
 
describe('Navigate to the account page', function(){
 
    beforeEach(function(){
        browser.get('http://testdomain.dev/');
    });
 
    it('should greet the named user', function () {
        indexpage.navigateToAccount();
        expect(browser.getTitle().toEqual('Your Account');
    });
 
});

As the indexpage pageobject automatically makes the browser object get the index page of testdomain.dev we no longer really need to call the beforeEach callback for this test, but if we had several tests the browser wouldn't be reset to that page between tests as the pageobject is only instantiated once. Consequently we've left it in.

Your website might include animations or effects that mean a locator looking for the menu as soon as the page loads would fail due to a timeout. You can tell the browser to wait for a specified length of time if you want.

navigateToAccount: {
    get: function () {
        browser.driver.sleep(2000);
        return element(by.css('a#account')).click();
    }
}

The new code tells the browser driver to sleep for 2 seconds (2000 milliseconds). That's great, but it means you're always going to have to wait for that length of time every time the test runs. That can be a pain. It's better to make the test runner wait until an element is on the page, and visible, and even clickable instead. That might be less time than 2 seconds, so your test would only need to wait for the minimum amount of time before it can continue.

navigateToAccount: {
    get: function () {
        var EC = protractor.ExpectedConditions;
        var el = element(by.css('a#account'));
        browser.wait(EC.elementToBeClickable(el), 10000);
        return el.click();
    }
}

The second parameter that we pass to the browser.wait() method is a timeout - if it takes more than 10 seconds for the element to appear and be clickable then the method will fail, and consequently so will the test. If your tests rely on third party webservices then you should make this value the maximum amount of time your users would be willing to wait.

Navigating to a page is great, but once we get there we need to actually do things. If we're not just reading information on the page then we're probably filling in a form.

For the most part interaction with forms is a matter of clicking and sending text to an input element, or selecting an option from a dropdown. However, when testing especially, it's important to check that things like formating and validation rules work correctly. It isn't enough to send well-formed input data that you would expect to validate properly, you also have to send badly formed data so you know your validation code rejects bad content too.

When you interact with a form in a test there isn't any need to click() the element to focus it first. Focusing is implied by sending text to it. However, if your form had a click event or an onfocus event listener then you would need to call click() to trigger those events.

element(by.css('input#email')).sendKeys("chris@ooer.com").sendKeys(protractor.Key.TAB);

This interaction should be quite obvious. We're sending an email address to the input element, and then sending a tab key separately. This is strictly unnecessary. We don't need to send the tab key, or we could send it as part of the email string, but I find sending it separately is more readable and makes it simpler to change the input data later. It doesn't matter what sort of form input you're sending text too - text, password and textarea elements are all used in the same way. This even works with a file input element, although you will need to make sure you're using an absolute path to where the file resides on a drive that is visible to the browser that's running the test.

Sending the tab key will trigger any onblur event listener that's attached to the input element.

Next we can try sending an invalid input. In forms that use inline validation that's triggered by a keyup event or an onblur event you would expect the validation message to be displayed as soon as the test runner leaves the input.

element(by.css('input#email')).sendKeys("chris@invaliddomain").sendKeys(protractor.Key.TAB);
expect(element(by.css('input#email')).getAttribute('class')).toHave('ng-dirty');
expect(element(by.css('div#emailValidationMessage')).isDisplayed()).toBe(true);

There are several new concepts introduced here. The first is the toHave() assertion. This checks whether on not a string is contained within the value returned by element(). In this case that's the classList for the element. The next thing we check is whether or not the div with an ID of 'emailValidationMessage' is displayed. This uses the isDisplayed() method on the element to get a boolean that tells us whether or not the element is visible on the page, and then we use the toBe assertion to compare that with a true value. In other words, if the input email address is not valid we expect the div that contains a message telling the user to check it to be visible.

Testing a select element is also quite simple once you understand that you're actually interacting with an option tag rather that its select element parent.

element(by.cssContainingText('option', 'My Option')).click();

by()'s method cssContainingText() expects two parameters. The first is a CSS locator to matches elements in the page. The second is then used as a filter to find any elements that have that string as their text. This means we're finding all the option tags on the page, and then filtering out any of them that don't have 'My Option' as their text.

There is a caveat to this approach. If you had two dropdowns each with an option that had the same text then this method would only find and click() the first. The solution to that issue is simply to make the CSS locator more verbose.

element(by.cssContainingText('select#two > option', 'My Option')).click();

This would click() on the option with text of 'My Option' in the select that has an ID of 'two'.

Interacting with checkboxes and radio buttons is a simple matter of using click() on them.

element(by.css('input#mycheckbox')).click();
element(by.css('input#myradiobutton')).click();

This is another reason to give your elements good IDs or classes. Finding elements on the page can be a pain otherwise.

Sometimes you'll find that you want to do the same interactions on a page over and over again. It's common to repeat an action using slightly different inputs in testing. Fortunately, as tests are code, you can very easily define your own functions to reduce the amount of code duplication.

Where your functions should live is really up to you. You can put them in the test file if you want, but that means they're only visible to those tests, so if you want to reuse them elsewhere you'd need to repeat the code in another spec file. One good alternative is to put them in the pageobject file for the page you're interacting with. Even then though this can be limiting. It's not uncommon to have an element that you reuse throughout a website, so really you should put the interaction abstraction code for it in its own file that you can import into the pageobjects for all the pages that use the element.

TODO: example of a reusable interaction function

It's also possible to do more complex interactions with a browser using a test runner. You can simulate touch actions such as tap, double tap and swipe. You can upload a file. You can even take a screenshot (which is handy for using with pdiff).

Touch actions with Selenium are rather complicated because although the API is available in most desktop browsers you really want to test them on a device. This means setting up Appium or Selendroid, and those are quite difficult to get working in a robust and reproducable way. Still, it's worth the effort if device testing is something you need to do regularly.

To test a touch action we need to locate the element on the page and use one of the touchAction methods exposed by the on it. The workflow for this is slightly different to the previous element interactions where we called the method on the element. With touchactions we pass the element to the touchactions() method that relates to the action that we want to perform.

var el = element(by.css('button#touchme'));
browser.driver.touchActions()
     .tap(el)
     .perform();

If we wanted to perform a double tap we'd need to use the doubletap() method, and if we wanted to perform a long press we'd use longpress().

TouchActions also enable us to use a device's touchscreen features directly without needing an element first. If we imagine a 'slide to unlock' feature, where we need to test a tap on the screen in a specific location, moving the 'finger' to a new location, and then releasing it we can do that.

browser.driver.touchActions()
     .tapAndHold({x: 20, y: 300})
     .move({x: 200, y: 300})
     .release({x: 200, y: 300})
     .perform();

Here we've told the test runner to simulate a tap on the pixel located at 20,300, move to 200,300, and then release at that point. Once that action is completed we could test elements on the page to check that the 'slide to unlock' feature has worked correctly.

The only remaining touchactions to test are flicking the screen and scrolling. There are two flick methods, flick() and flickElement(). flick() simulates a flick action anywhere on the screen with a specified x and y speed. flickElement() simulates a flick action on an element to a specified location on the screen with a given speed.

browser.driver.touchActions()
    .flick({xspeed: 20, yspeed: 25})
    .perform();
 
var el = element(by.css('button#touchme'));
browser.driver.touchActions()
    .flickElement(el, {x: 2, y: 4}, 10)
    .perform();

Lastly, we can take screenshots during the testing process. This isn't strictly an element interaction, but it is incredibly helpful to take a screenshot so we should investigate the process. It's very simple.

browser.takeScreenshot().then(function (screenshot) {
    //code to write the screenshot to a file 
});

By combining screenshot taking with an afterEach callback we can take a screenshot whenever a test fails.

afterEach(function() {
    var pass = jasmine.getEnv().currentSpec.results().passed();
    if (!pass) {
        browser.takeScreenshot().then(function(screenshot) {
            //code to write the screenshot to a file 
        };
    }
});

This is really useful.

Previous: Elements and Locators Next: Assertions