How to make your script better in Nightwatch.js
Congratulations on getting to part 4 of the “A beginner’s guide to test automation with Javascript (Nighwatch.js)” blog series! This part will help you to improve your test script (and make it cooler). We’ll explain different style suggestions and the overall script structure that we follow in Loadero to bring your script to a new level.
Check out our previous parts to catch up:
- part 1: Introduction to Nightwatch.js
- part 2: The most useful Nightwatch.js commands
- part 3: Callback functions and command queue in Nightwatch.js
In this blog post, you will learn the following:
The code used in this article can be found in Loadero’s public GitHub examples repository here.
Prerequisites
- Code editor of your choice (in Loadero we prefer Visual Studio Code).
-
Node.js (the latest version is preferred, in this example
v14.15.0
will be used). - Google Chrome and Firefox browsers.
Consistent code style
When it comes to JavaScript code (and TypeScript), we love Airbnb’s JavaScript Style Guide and follow it almost fully. Even though it is quite a long read, it is very well written and has great examples.
We also suggest using the Prettier formatter plugin for your IDE. It follows Airbnb code style for the most part, though we have some exceptions and they are documented here in .prettierrc
file:
{
"tabWidth": 4,
"proseWrap": "always",
"arrowParens": "avoid",
"trailingComma": "none",
"singleQuote": true,
"printWidth": 100,
"quoteProps": "consistent"
}
Setting concrete code style guidelines and following them makes it easier to share the code in the team and reduces the time for readers to understand the code.
Arrow functions
Even though there are cases when you must use regular functions (especially if you need to use context-based this
), we believe arrow functions should be used wherever it is possible. There are a number of benefits of using them and one of the biggest is their terseness.
They are ideal when using callbacks:
// Regular function
client.getText(".some-element", function (result) {
console.log(result.value);
});
// Arrow function
client.getText(".some-element", result => console.log(result.value));
Destructuring
You may have noticed in the previous parts that we were using ({ value })
in many of our callback functions. This is another great way of making your code shorter and actually more readable.
Since Nightwatch.js passes in callback function object that in successful response case consists of sessionID
, status
and value
properties, we can destructure them in the function parameters (check out part 3 of these series to refresh your knowledge about Nightwatch.js callbacks). When the object is destructured, its properties are extracted and passed to the function as arguments. This allows to not create new variables inside the function body
// Bad
client.getText(".some-element", (result) => {
const value = result.value;
console.log(value);
});
// Better
client.getText(".some-element", (result) => {
console.log(result.value);
});
// The best
client.getText(".some-element", ({ value }) => {
console.log(value);
});
Destructuring is a pretty powerful JavaScript feature and can be used not only for objects that are passed into functions but also for arrays and can be used for variable assignments. To learn more about it, check out this article.
Element selector usage
You might have noticed that in the previous parts we were using element selectors, e.g., #search_form_input_homepage
. In a nutshell, these strings allow finding specific HTML element(s) on any page, retrieving them and doing some actions with them, for example, clicking on them or entering some text. Selectors, frankly, make the web UI automation possible – without them, there would be no precise way of interacting with site elements.
Selectors are quite a big topic to be discussed and deserve their own article. Here, we will discuss them briefly to understand their global differences.
CSS selectors
CSS selectors are generally quite succinct and often are enough to locate specifically one element. There are multiple strategies how you can do that but in Loadero we mainly use classes, IDs, attribute selectors, and pseudoselectors. This is done mainly due to their shortness.
We suggest using primarily ID because they are unique. If that’s not possible, try using classes. We also recommend using their shorthands instead of element attributes, i.e., .classname
and #ID
instead of [class=classname]
and [id=ID]
.
Tip: We suggest not providing element type (
div
,input
, etc.) and only element’s attributes. This is mainly because in many frontend frameworks like React it is simple to change element type, for example, from<p />
to<span />
unlike element attribute which most likely will require changes in the CSS file. These changes can potentially break your automation.
XPath selectors
Sometimes providing a CSS selector is not possible, e.g., when locating an element’s parent, or is way more complicated than using some XPath function such as text()
. In cases like this, XPath selector can be the next option after CSS selectors.
When it comes to XPath selectors, the same rules apply to CSS selectors – try to make them as short as possible, whilst keeping readability.
The biggest disadvantage of using XPath selectors is that their engines are browser-dependent and therefore may have inconsistencies. Also, generally, XPath selectors are slower than CSS and for bigger tests may become a hurdle.
Web element ID
Web element ID locator strategy is different from CSS and XPath mainly because it is generated by the HTML rather than the website developer and the ID changes in every browsing context (and on every refresh). Nevertheless, it is used all the time in the internal workings of WebDriver.
To get a web element ID, you have to use protocol-level Nightwatch.js functions that return that element’s ID – .element()
(check its documentation here). Similarly, you can use .elements()
that returns an array of element IDs (check the documentation here). You still need to pass CSS or XPath selector to .element()
and .elements()
but you won’t have to locate the element repeatedly because you would have that element’s web element ID.
Once you have the element ID, you can use protocol action functions that accept web element ID rather than CSS or XPath selector. Nightwatch.js offers a variety of them and you can do most of the interactions that you can with CSS/XPath selectors.
Tip: Keep in mind that these functions may be flaky since there is a higher chance of getting a stale element, especially if the website is dynamic. We suggest making sure whether you really need to use protocol-level functions and perhaps regular functions are sufficient.
Code organization
Sometimes your script becomes quite big and hard to manage. For cases like these, we have come up with guidelines to increase abstraction via the usage of functions, selector objects and extracting hardcoded values into variables. This improves readability and traceability immensely. Let’s dive in, shall we?
For the following examples, we will use the script of logging into GitHub, here’s the base script.
module.exports = {
test: client => {
client
.url('https://github.com/')
.waitForElementVisible('.application-main', 10 * 1000)
.waitForElementVisible('[href="/login"]', 10 * 1000)
.click('[href="/login"]')
.waitForElementVisible('#login', 10 * 1000)
.waitForElementVisible('#login_field', 10 * 1000)
.setValue('#login_field', 'test@example.com')
.waitForElementVisible('#password', 10 * 1000)
.setValue('#password', 'password123')
.waitForElementVisible('.js-sign-in-button', 10 * 1000)
.click('.js-sign-in-button');
}
};
Subfunctions
Functions generally serve 2 purposes. Firstly, they serve as an abstraction layer so you can skim over the code and understand what it is doing instead of reading every line. Think of array sorting function – you don’t really care how it works, you are mainly interested in what it does on a high level and what it returns. Secondly, they reduce the need to write repetitive code. Both of these usages can be utilized in our code to simplify our script.
Let’s start by refactoring our code into 2 logical blocks, i.e., site preparation for further actions and signing in. We can do that by creating 2 functions prepare()
and signIn()
.
module.exports = {
test: client => {
const prepare = () => {
client
.url('https://github.com/')
.waitForElementVisible('.application-main', 10 * 1000)
.waitForElementVisible('[href="/login"]', 10 * 1000)
.click('[href="/login"]');
};
const signIn = () => {
client
.waitForElementVisible('#login', 10 * 1000)
.waitForElementVisible('#login_field', 10 * 1000)
.setValue('#login_field', 'test@example.com')
.waitForElementVisible('#password', 10 * 1000)
.setValue('#password', 'password123')
.waitForElementVisible('.js-sign-in-button', 10 * 1000)
.click('.js-sign-in-button');
};
prepare();
signIn();
}
};
Now, we can clearly see at the end of the script that this code does some kind of preparation and logins. It should be way easier to understand code functionality than reading every line like in the previous version.
As you can see there are many repetitive .waitForElementVisible().click()
and .waitForElementVisible().setValue()
.These can be extracted into separate functions. In fact, we can also utilize default parameters because for .waitForElementVisible()
we always pass 10 * 1000
. Let’s do that!
module.exports = {
test: client => {
// Utility functions
const waitAndClick = (selector, waitTime = 10 * 1000) => {
client.waitForElementVisible(selector, waitTime).click(selector);
};
const waitAndSetValue = (selector, value, waitTime = 10 * 1000) => {
client.waitForElementVisible(selector, waitTime).setValue(selector, value);
};
// Main functions
const prepare = () => {
client.url('https://github.com/').waitForElementVisible('.application-main', 10 * 1000);
waitAndClick('[href="/login"]');
};
const signIn = () => {
client.waitForElementVisible('#login', 10 * 1000);
waitAndSetValue('#login_field', 'est@example.com');
waitAndSetValue('#password', 'password123');
waitAndClick('.js-sign-in-button');
};
// Main flow
prepare();
signIn();
}
};
Even though the code now has become longer than in the base version, it is more traceable and less repetitive, hence easier to read and understand. This might not be as visible in such a simple script but when your script contains multiple user flows and even more actions, this becomes crucial and indeed helpful.
Selector objects
In a big script having hardcoded selectors is hard to manage. At some point, you stop understanding which selector is responsible for which element, especially if the selector is not intuitive. Another problem emerges when you use the same selector in multiple functions and the selector changes. Then you have to update all of its usages. For cases like these, we suggest grouping elements in selector objects.
Tip: Ideally, page objects should be used but since Loadero doesn’t support them and Nightwatch.js implementation doesn’t allow easy traceability, we suggest using JavaScript objects inside the test function to manage selectors. Let’s create selectors object with 2 child objects (one for prepare section, the other for signIn). This object will host all selectors used in the script. Each property will have some meaningful name to improve our abstraction (again).
module.exports = {
test: client => {
// Element selectors object
const selectors = {
landing: {
container: '.application-main',
signInButton: '[href="/login"]'
},
signIn: {
container: '#login',
loginInput: '#login_field',
passwordInput: '#password',
signInButton: '.js-sign-in-button'
}
};
// Utility functions
const waitAndClick = (selector, waitTime = 10 * 1000) => {
client.waitForElementVisible(selector, waitTime).click(selector);
};
const waitAndSetValue = (selector, value, waitTime = 10 * 1000) => {
client.waitForElementVisible(selector, waitTime).setValue(selector, value);
};
// Main functions
const prepare = () => {
client
.url('https://github.com/')
.waitForElementVisible(selectors.landing.container, 10 * 1000);
waitAndClick(selectors.landing.signInButton);
};
const login = () => {
client.waitForElementVisible(selectors.signIn.container, 10 * 1000);
waitAndSetValue(selectors.signIn.loginInput, 'est@example.com');
waitAndSetValue(selectors.signIn.passwordInput, 'password123');
waitAndClick(selectors.signIn.signInButton);
};
// Main flow
prepare();
login();
}
};
Let’s improve another tiny thing. We can extract selectors
child objects into variables in each of the main functions using destructuring, that way we’ll have fewer properties to call when passing selectors to functions.
module.exports = {
test: client => {
// Element selectors boject
const selectors = {
landing: {
container: '.application-main',
signInButton: '[href="/login"]'
},
signIn: {
container: '#login',
loginInput: '#login_field',
passwordInput: '#password',
signInButton: '.js-sign-in-button'
}
};
// Utility functions
const waitAndClick = (selector, waitTime = 10 * 1000) => {
client
.waitForElementVisible(selector, waitTime)
.click(selector);
};
const waitAndSetValue = (selector, value, waitTime = 10 * 1000) => {
client
.waitForElementVisible(selector, waitTime)
.setValue(selector, value);
};
// Main functions
const prepare = () => {
const { landing } = selectors;
client
.url('https://github.com/')
.waitForElementVisible(landing.container, 10 * 1000);
waitAndClick(landing.signInButton);
};
const login = () => {
const { signIn } = selectors;
client.waitForElementVisible(signIn.container, 10 * 1000);
waitAndSetValue(signIn.loginInput, 'test@example.com');
waitAndSetValue(signIn.passwordInput, 'password123');
waitAndClick(signIn.signInButton);
};
// Main flow
prepare();
login();
}
};
Extracting hardcoded values into variables
Another thing that you may have noticed is that the repeated usage of 10 * 1000
. What’s more, website’s URL and account credentials could be extracted as variables, hence increasing the easy of future editing. Let’s apply these suggestions!
module.exports = {
test: client => {
// GitHub's URL
const url = 'https://github.com/';
// Default timeout for `.waitForElementVisible()`
const timeout = 10 * 1000;
// Element selectors object
const selectors = {
landing: {
container: '.application-main',
signInButton: '[href="/login"]'
},
signIn: {
container: '#login',
loginInput: '#login_field',
passwordInput: '#password',
signInButton: '.js-sign-in-button'
}
};
// Account credentials
const credentials = {
email: 'test@example.com',
password: 'password123'
};
// Utility functions
const waitAndClick = (selector, waitTime = timeout) => {
client.waitForElementVisible(selector, waitTime).click(selector);
};
const waitAndSetValue = (selector, value, waitTime = timeout) => {
client.waitForElementVisible(selector, waitTime).setValue(selector, value);
};
// Main functions
const prepare = () => {
const { landing } = selectors;
client.url(url).waitForElementVisible(landing.container, timeout);
waitAndClick(landing.signInButton);
};
const login = () => {
const { signIn } = selectors;
client.waitForElementVisible(signIn.container, timeout);
waitAndSetValue(signIn.loginInput, credentials.email);
waitAndSetValue(signIn.passwordInput, credentials.password);
waitAndClick(signIn.signInButton);
};
// Main flow
prepare();
login();
}
};
The final script is available in our GitHub repository here.
Summary
Today you’ve learned how to rewrite your automation script to make it more efficient, readable and most importantly – scale well for larger tests. Remember, that these are only our suggestions that are based on our current experiences. You can always adapt them to fit your project requirements and personal preferences.
We hope that you enjoyed reading this series of blog posts and learned something useful. If you have any comments or suggestions, share them with us, we are very curious to know your opinion. Happy testing!
Top comments (0)