Readable test scripts


Better selectors

Webdriver-sync is a wrapper around the java webdriver client and out of the box has a very wordy selector syntax (last line):


    var wd = require("webdriver-sync");
    var caps = new wd.DesiredCapabilities["chrome"]();
    var driver = new wd["ChromeDriver"](caps);
    var By = wd.By;
    var elements = driver.findElements(By.tagName('html'));
                

Compared to established front-end frameworks such as jQuery, it's a lot of typing, so let's do something about that.

Selector types

What are the selector types in the By method?

  • className
  • cssSelector
  • id
  • name
  • tagName
  • xpath
  • linkText
  • partialLinkText

The first 5 are standard HTML element properties and easily resolve to a jQuery selector syntax (other than "name", which I never use as a selector).

Xpath selectors are a law unto themselves, but can be very useful once you get your head around the syntax.

The last 2 are not typical in other selector libraries but can be very useful for testing, so we will implement those as well.

cssSelector is almost all we need

cssSelector already covers us for class, id and tag selectors:


    // By tagName
    var tableBody = driver.findElement(By.cssSelector("tbody"));
    
    // By className
    var blueElements = driver.findElements(By.cssSelector(".blue"));
    
    // By id
    var silicon = driver.findElement(By.cssSelector("#element14"));
                

It also covers us for child and sibling selectors


    // div followed by a span child element
    driver.findElement(By.cssSelector("div span"));

    //div with immediate anchor child
    driver.findElement(By.cssSelector("div>a"));

    //div with adjacent legend
    driver.findElement(By.cssSelector("div+legend"));
                

Selectors for AngularJS elements

Angular relies heavily on custom elements. CSS selectors and pseudo classes have these covered too:


    // angular button with ng-click event
    driver.findElement(By.cssSelector("button[ng-click='openFileUploadDialog()']"));

    // angular button in 3rd table row built by ng-repeat
    driver.findElement(By.cssSelector("tr[ng-repeat='variation in model.variations']
                        :nth-child(2) button[ng-click='checkReRunModel(variation)']"));
                

Text content and Xpath selectors

I haven't found linkText to be useful and just use partialLinkText for links. Obviously this depends on how uniquely your links are worded.

Generalising the text selectors for any element means relying on Xpath:


    // By exact text content
    var abortButton = driver.findElement(By.xpath("//*[text()='Abort!']"));
    
    // By contains text
    var allTheThings = driver.findElements(By.xpath("//*[contains(text(),'Thing')]"));

    // parent element
    driver.findElements(By.xpath(".."))
                

Xpath selectors offer a flexible interface to navigate the DOM, but at a price in performance. Where possible, try to identify the equivalent CSS selector first as this will offer a 5-10x speed increase over Xpath. For text and parent selectors, Xpath is currently the only option.

Better selectors

So far all we've done is remove some of the redundant selector types and generalise the text ones. The syntax still isn't exactly concise.

This is where we look back and say "What would jQuery do?". Well, something like this:


    // By tagName
    var tableBody = $("tbody");
    
    // By className
    var blueElements = $(".blue");
    
    // By id
    var silicon = $("#element14");

    //By partial link text
    var nextPageLink = $("^Next");
    
    // By exact text content 
    // (note the nested quotes now)
    var abortButton = $("'Abort!'");
    
    // By contains text
    var allTheThings = $("*Thing*");
    
    // By parent element
    var listContainer = 
        $("ul.containedList").$("parent");

    // By partial attribute
    var visibleCalendar = 
        $("ul[ng-change='dateSelection()'][style*='display: block']");
                

OK so that's concise and consistent and reasonably easy to follow, so how is it done?


    function $by(selector) {
        //first character indicates the selection type
        var by = selector.slice(0,1);

        if (by.match(/(\.|#|\w)/)) {
            by = "cssSelector";
        }
        if (by === "^") {
            by = "partialLinkText";
            selector = selector.slice(1);
        }
        if (by === "*") {
            by = "xpath";
            selector = "//*[contains(text(),'" 
            + selector.slice(1, -1) + "')]";
        }
        if (by === "'") {
            by = "xpath";
            selector = "//*[text()=" + selector + "]";
        }
        if (by === "@") {
            by = "xpath";
            selector = "//*[" + selector + "]";
        }
        if (selector.slice(0,2) === "//") {
            by = "xpath";
        }       

        console.log("LOCATING ELEMENT " + selector + " BY " + by);
        return By[by](selector);            
    }
                

Note that this does not give us the $ method yet, we're reserving that for a general element handler, below.

General element handler

Selecting an element is only half the story, testing will involve reading its text, checking its properties, pushing its buttons, etc. So we extend our selector approach to dealing with elements generally.

What sorts of things will we do with elements?

  • Select single elements
  • Select multiple elements
  • Select child element(s)
  • Select parent element
  • Click element
  • Right-click element
  • Hover over element
  • Scroll to element
  • Read element value
  • Set element value
  • Trigger element event
  • Show element's dropdown menu
  • Enumerate element lists

Let's put all that together (extending the above $by method for parent selection):


function WD_findElement(selector, root, index) {
    if (!selector) {
        console.error("NO SELECTOR SPECIFIED FOR $()");
        return null;
    }
    //replace the verbose By syntax with sizzle-like succint selectors
    function $by(selector) {
        root = root || driver;
        var by = selector.slice(0,1);
        if (by.match(/(\.|#|\w|\[)/)) {
            by = "cssSelector";
        }
        if (by === "^") {
            by = "partialLinkText";
            selector = selector.slice(1);
        }
        //wildcard text content selector
        if (by === "*") {
            by = "xpath";
            selector = "//*[contains(text(),'" + selector.slice(1, -1) + "')]";
        }
        //literal text content selector
        if (by === "'") {
            by = "xpath";
            selector = "//*[text()=" + selector + "]";
        }

        if (by === "@") {
            by = "xpath";
            selector = "//*[" + selector + "]";
        }
        if (selector.slice(0,2) === "//") {
            by = "xpath";
            if (root != driver) {//sub-element needs to search from current element not doc root
                selector = "." + selector;
            }
        }       
        if (selector === "parent") {
            by = "xpath";
            selector = "..";
        }
        console.log("LOCATING ELEMENT " + selector + " BY " + by);
        return By[by](selector);            
    }
    //ultimately findElement returns a WebElement or array of WebElements
    //here we add some extra methods and properties useful for testing
    function augmentedElement(element, elementIndex) {
        element.selector = selector;
        if (element.length) {
            element = element[elementIndex || 0];
        }
        element["$"] = function subElement(sel, subindex) {
            return WD_findElement(sel, element, subindex);
        };
        //bit of syntactic sugar as getAttribute is a pain to type
        element["_"] = function element_getAttribute(attr) {
            return element.getAttribute(attr);
        };
        element.scrollTo = function scrollTo() {
            driver.executeScript("arguments[0].scrollIntoView(true)", element._instance);
        };
        element.set = function setValue(newValue) {
            console.log("SETTING VALUE " + (selector.match(/password/) 
                            ? "********" : ("" + newValue)));
            if ((element.getAttribute("type") !== "file") 
                && (element.getAttribute("nodeName") !== "SELECT")) {
                element.clear();
            }
            return element.sendKeys(newValue);
        };
        element.trigger = function trigger(action) {
            driver.executeScript("$(arguments[0]).trigger('" + action + "')", element._instance);   
        };
        element.dropdown = function dropdown() {
            driver.executeScript("$(arguments[0]).next().show()", element._instance);   
        };
        element.setAttribute = function dropdown(attr, value) {
            driver.executeScript("$(arguments[0]).attr('" + attr + "', '" + value + "')", 
                                    element._instance);   
        };
        element.hover = function hover(x, y) {
            if (arguments.length) {
                driver.getMouse()._instance
                    .mouseMoveSync(element._instance.getCoordinatesSync(), ~~x, ~~y);    
            } else {
                driver.getMouse()._instance
                    .mouseMoveSync(element._instance.getCoordinatesSync());
            }                           
        };
        element.coordinates = function coordinates () {
            //returns the on-page coordinates as a simple JSON object, e.g. {"y": 345, "x": 1237}
            return element._instance.getCoordinatesSync().onPageSync();
        };
        element.rClick = function rClick() {
            driver.getMouse()._instance.contextClickSync(element._instance.getCoordinatesSync());
        };
        element.clickAt = function clickAt(xOffset, yOffset) {
            var elementCoordinates = element.coordinates();
            browser.getMouse()._instance.clickSync(
                {y: elementCoordinates.y + yOffset, 
                x: elementCoordinates.x + xOffset});
        };
        element.isChecked = function () {
            return element.getAttribute("checked") === true;
        };
        element.forEach = element.map = function only(fn) {
            return fn(element, 0);
        };

        element.length = 1;             
        return element;
    }
    var element;
    if (typeof root != "object") {
         index = root;
         root = driver;
    }
    var indexSet = (typeof index === "number");
    var firstElement = indexSet && (index === 0);
    try {
        element = firstElement 
            ? [root.findElement($by(selector))] : root.findElements($by(selector));
    } catch (e) {
        return {
            length: 0
        };
    }
    var elementIndex = index || 0;
    if (element) {
        if ((!element.length) || (indexSet && indexSet > element.length)) {
            element.isDisplayed = function elementNotPresent() {
                return false;
            };
            element.getText = function elementNotPresentText() {
                return "";
            };
            return element;
        }
        if ((element.length === 1) || indexSet) {
            return augmentedElement(element, elementIndex);
        } else {
            return element.map(augmentedElement);
        } 
    } else {
        console.error(selector + " not found");
    }
    return element;
}
                

An example of what you would do with a map method is extracting the text of a list of elements:


    var legend = $(".nv-legendWrap text").map(function (barLegend) {
        return barLegend.getText();
    }).join();
                

Here I'm joining the text in the elements into a single string (for easy comparison with expected values).

You may have noticed that we still don't have a $ method - that will come when we modularise our scripts.

More readable selectors

Even with the concise selector syntax, it may not be obvious what the selector refers to if it is particularly complex.

e.g. what happens if I click this?


    $("//ul[contains(@class, 'navbar')]//a[starts-with(@href, '/user/show/')]");
                

This where I introduced the idea of locators to keep the scripts readable, by giving selectors more human-friendly aliases:


    locators = {
        "Signed In User Link": 
            "//ul[contains(@class, 'navbar')]//a[starts-with(@href, '/user/show/')]",
        "Other complicated selector": 
            "horrendous Xpath"
        //etc.
    }
                

Then I decided to have an alternative to things like $([locator resolved to selector]).click():


    function button(buttonorlink, index) {
        return $(locators[buttonorlink] || null, index);
    }
    function link(buttonorlink, index) {
        return button(buttonorlink, index);
    }
    function tab(tabName, index) {
        return button(tabName + " Tab", index);
    }
    function click(buttonorlink, checkStateOrIndex) {
        if (typeof checkStateOrIndex == "number") {//actually the button index
            return button(buttonorlink, checkStateOrIndex).click();
        }
        var clickable = button(buttonorlink);
        if (clickable) {
            //mung checked atribute to a Boolean
            if (arguments.length == 1 || (!!clickable.getAttribute("checked") != checkState)) {
                clickable.click();
            }
        }
    }
                

So eventually we get from:


    driver.findElements(By.xpath(
        "//ul[contains(@class, 'navbar')]//a[starts-with(@href, '/user/show/')]")).click();    
                

to:


    click("Signed In User Link");                
                

Then you also get the benefit of only having to change the selector in one place in your scripts when it changes.

Browser navigation

Navigating to a page

webdriver has a get() method to navigate to a URL, but that's a bit boring, so to save time only navigate if the page is different and log the timestamp and url for later troubleshooting:


    function WD_go(url) {
        if (driver.getCurrentUrl() != url) {
            console.log(new Date());
            console.log("NAVIGATING TO " + url);
            driver.get(url);
        }
    }                
                

Getting a JSON response

Sometimes you just want to test the data returned to a page, rather than the page itself. This is easy enough to do by running javascript in the browser instance (as we did for dropdown and other element methods above):


    function WD_getJSON(url) {
        var xhr = driver.executeScript(
            "xhr = new XMLHttpRequest();" +
            "xhr.open('GET', '" + url + "', false);" +
            "xhr.send();" +
            "return (xhr.status + '###' + xhr.responseText);"
        );
        xhr = xhr.split("###");
        return {status: xhr[0], responseText: xhr[1]};
    }
                

Checking headers

Similarly, we can request just the headers for a particular response (for instance you might want to check for a 404 or a particular content-type):


    function WD_getHeaders(url, specificHeader) {
        var xhr_headers = driver.executeScript(
            "xhr = new XMLHttpRequest();" +
            "xhr.open('GET', '" + url + "', false);" +
            "xhr.setRequestHeader('Accept-Encoding', 'gzip');" +
            "xhr.send();" +
            (specificHeader ? 
                "return xhr.getResponseHeader('" + specificHeader + "');" : 
                "return xhr.getAllResponseHeaders();"
            )
        );
        return xhr_headers;
    }
                

Waiting for elements

I started using webdriver-sync before the WebDriverWait class was implemented and ended up writing my own waitFor method. This is used when there may be some delay in rendering an element. It has a default timeout that can be overriden.


    function waitFor(msg, selector, index) {
        var waitTime = 60;
        //no point waiting for longer than a minute because request will have timed out

        //arguments can be overriden depending on type
        //should probably use a parameters object and keep it tidy
        if (typeof msg == "number") {
            waitTime = arguments[0];
            msg = arguments[1];
            selector = arguments[2];
            index = arguments[3] || null;
        }
        //selector could be a bare selector or a locator (see above)
        if (locators[msg]) {
            selector = locators[msg];
            msg = msg.toUpperCase();
        }

        var foundElement, elementList;
        console.log("WAITING " + waitTime + " SECONDS FOR " + msg); 
        while (!(foundElement = $(selector)).length) {
            Delay(1e3, true);
            --waitTime;
            if (!waitTime) {
                if (typeof arguments[3] !== "boolean") {
                    log.error("TIMED OUT");
                }
                return false;
            }
        }
        //handle single elements
        if (foundElement.length === 1) {
            while (!(foundElement = $(selector)).isDisplayed()) {
                console.log("WAITING FOR " + msg + " TO APPEAR");
                Delay(1e3);
            }
            return foundElement;
        }
        if (typeof index === "number") {
            while (!(foundElement = $(selector)[index]).isDisplayed()) {
                console.log("WAITING FOR " + msg + " TO APPEAR");
                Delay(1e3);
            }
            return foundElement;
        }

        //found a list of elements
        elementList = foundElement;
        elementList.every(function (el) {
            if (el.isDisplayed()) {
                foundElement = el;
                return false;
            }
            console.log("WAITING FOR " + msg + " TO APPEAR");
            Delay(1e3);
            return true;
        });
        return foundElement;
    }
                

waitFor() will post a message to the log saying which element is being waited for and wait up to a minute (or a specified timeout value) before abandoning. There are a number of ways waitFor can be called:


    //using a locator (and default timeout)
    waitFor("Utility Power Actuals Link");

    //using a selector and index
    waitFor("ACTUALS BUTTON", "'Sub-metering actuals'", 2);

    //using a specific timeout
    waitFor(20, "VARIANCE CHART", ".barchart");         
                
Next: Modular scripts