Selenium testing with Nightwatch.js

We love automated testing over at Futureproofs. Up until recently we’d been using the Selenium IDE to write and execute browser tests.

The IDE worked well enough initially, but as the system became ever more complex and the person we were leaning on to maintain them found his time tied up, we needed another solution. Our webapp is written in AngularJS so it made sense to use something written in javascript; we looked at protractor but ultimately decided that the tests should be agnostic and not tied specifically to AngularJS. We settled on NightwatchJS.

Nightwatch wraps all of the familiar Selenium commands but in JS, adding into the mix:

  • Reusable “page objects”
  • Support for xPath and CSS selectors
  • jUnit reporting
  • Support for cloud services (like Saucelabs and Browserstack)

What follows is a high level summary of the approach we took…

The setup

To get nightwatchJS working we needed to install a few dependancies, nothing too complex:

Selenium server

#> sudo npm install selenium-standalone@latest -g
#> sudo selenium-standalone install

NightwatchJS

#> sudo npm install nightwatch -g

Running Tests

Make sure the selenium service is running (separate terminal):

#> selenium-standalone start

We can run all tests by running:

#> nightwatch

We can run tests with specific tags by using:

#> nightwatch --tag=<tagname>

We can run tests with multiple tags by using:

#> nightwatch --tag=<tagname> --tag=<tagname2>

We can run tests with tags but exclude other tests with tags by using:

#> nightwatch --tag=<tagname> --skiptags=<tagname2>

We can run groups of tests (by folder):

#> nightwatch --group=<group>

We can run tests against specific environments:

#> nightwatch --environment=<env> --group=<group>

Or specific tests using:

#> nightwatch --group=/group/name.js

Page Objects

NightwatchJS page objects are a useful for representing:

  • A URL
  • DOM Element selectors
  • Common grouped repeated tests (commands)

that are directly related to a specific page. The main advantage of this is if I decided to change my HTML structure going forward and my tests use the commands/element selectors defined in a page object, I only need change the page object. Here is a simple example from our signup page:

'use strict';

var signupCommands = {
    signup: function (name, email, password, passwordConfirmation, howHeardAboutUs) {
        this.setValue('@nameInput', name);
        this.setValue('@emailInput', email);
        this.setValue('@passwordInput', password);
        this.setValue('@passwordConfirmationInput', passwordConfirmation);
        this.setValue('@howYouHeardAboutUsTextArea', howHeardAboutUs);
        this.click('@acceptTermsRadio');
        this.click('@createAccountButton');
        return this;
    },
};

module.exports = {
    url: function () {
        return this.api.globals.webApp.url + 'signup';
    },
    commands: [signupCommands],
    elements: {
        nameInput: {
            selector: '#name'
        },
        emailInput: {
            selector: '#email'
        },
        passwordInput: {
            selector: '#password'
        },
        passwordConfirmationInput: {
            selector: '#passwordConfirmation'
        },
        howYouHeardAboutUsTextArea: {
            selector: '#howYourHeardAboutUs'
        },
        acceptTermsRadio: {
            selector: '/html/body/div/input',
            locateStrategy: 'xpath'
        },
        createAccountButton: {
            selector: '/html/body/div/button',
            locateStrategy: 'xpath'
        }
    }
};

An example test that makes use of the command and elements above, might look like: “` module.exports = { ’@tags’: [‘signup’], //’@disabled’: true, // This will prevent the test module from running.

'I can successfully create an account to the app.': function (client) {
    var signupPage = client.page.signup(),
        email = 'random-user+' + new Date().getTime() + '@mailinator.com',
        password = 'random-password-123',
        name = 'Random User ' + new Date().getTime();

    signupPage.navigate();   //Uses page URL (built-in functionality)
    signupPage.signup(name, email, password, password, '');
}

}; ”`

Custom Commands

Custom commands are similar to commands within page objects except they can be ran directly from the main client object and because they aren’t targeting a specific pages they are better suited for more generic functionality. For example we make heavy use twitter-bootstrap modals and so have commands that can manipulate them:

exports.command = function (duration, callback) {
    var self = this;
    duration = typeof duration !== 'undefined' ? duration : 1000;

    this.useXpath().waitForElementNotPresent('//*[contains(concat(" ", @class, " "), "modal-dialog")]', duration);

    if (typeof(callback) === 'function') {
        callback.call(self);
    }

    return this;
};

Custom Assertions

While commands are useful for making something happen (a click, a wait, setting a value for example). Assertions are used to “assert” things, i.e to test conditions. Following on from the command example, here is an example of an assertion:

var util = require('util');

exports.assertion = function (buttonText, msg) {

    var MSG_ELEMENT_NOT_FOUND = 'Testing if button with text <%s> is visible. Element could not be located.';

    this.message = msg || util.format('Testing if button with text <%s> is visible.', buttonText);
    this.expected = true;

    this.pass = function (value) {
        return value === this.expected;
    };

    this.failure = function (result) {
        var failed = result === false || result && result.status === -1,
            selector = '//div[contains(concat(" ", @class, " "), "modal-content")]//*[self::button or self::a]' +  '[translate(normalize-space(text()),"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="' +  buttonText.toLowerCase() + '"]';

        if (failed) {
            this.message = msg || util.format(MSG_ELEMENT_NOT_FOUND, selector);
        }
        return failed;
    };

    this.value = function (result) {
        return !!result.value;
    };

    this.command = function (callback) {
        var xpath = '//div[contains(concat(" ", @class, " "), "modal-content")]//*[self::button or self::a]' +  '[translate(normalize-space(text()),"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="' +  buttonText.toLowerCase() + '"]';

        return this.api.useXpath().isVisible(xpath, callback);
    };

};

It determines if the modal button is present (based on its text value) and whether it is visible or not.

Sample Folder structure

Most things are customisable from the NW config file and so we where able to create the following (neat) folder structure.

./nightwatch
├── assertions
│   ├── alertMessageContains.js
│   ├── modalBodyContains.js
│   ├── modalButtonDisabled.js
│   ├── modalButtonNotDisabled.js
│   ├── modalButtonVisible.js
│   ├── modalHeaderContains.js
├── commands
│   ├── clickModalButton.js
│   ├── injectJavascript.js
│   ├── loginAsUser.js
│   ├── waitForAngular.js
│   ├── waitForModalClosed.js
│   └── waitForModalOpen.js
├── files
│   ├── attachment.jpg
│   ├── cover.jpg
│   └── book.pdf
├── globals.js
├── pages
│   ├── account.js
│   ├── dashboard.js
│   ├── home.js
│   ├── login.js
│   ├── logout.js
└── tests
    ├── account.js
    ├── dashboard.js
    ├── home.js
    ├── login.js
    └── setup.js

Sample Configuration

You can see in the sample configuration below we’re able to specify which browsers we run our tests against, custom config for different environments, folder structures, selenium properties, failed test screenshots and a host of other v.useful stuff.

module.exports = {
    'src_folders' : ['nightwatch/tests'],
    'output_folder' : '../coverage',
    'custom_commands_path' : './nightwatch/commands',
    'custom_assertions_path' : './nightwatch/assertions',
    'page_objects_path' : './nightwatch/pages',
    'globals_path' : './nightwatch/globals.js',
    'selenium': {
        'start_process': false,
        'server_path': '',
        'log_path': '',
        'host': '127.0.0.1',
        'port': 4444
    },
    'test_settings': {
        'default': {
            'screenshots': {
                'enabled': true,
                'on_failure': true,
                'on_error': false,
                'path': '../coverage'
            },
            'selenium_port': 4444,
            'selenium_host': 'localhost',
            'silent': true,
            'launch_url': 'http://www.google.com/',
            'desiredCapabilities': {
                'browserName': 'firefox',
                'javascriptEnabled': true,
                'acceptSslCerts': true
            },
            'globals': {
                'webApp': {
                    'url': 'http://www.google.com/'
                },
                'supportEmailAddress': 'support@google.com'
            }
        },
        'dev': {
            'selenium_port': 4444,
            'selenium_host': 'localhost',
            'launch_url': 'http://dev.google.com/',
            'globals': {
                'webApp': {
                    'url': 'http://dev.google.com/'
                },
                'supportEmailAddress': 'support+dev@google.com'
            }
        }
};