A brief history
I have previously written here about how test ids can help with UI automation and that using custom locators enable more readable code. Something I didn’t tackle was the chaining of elements. Sometimes, you wanted to get a nested element where there are test ids that are the same. In our previous case, the locator will get the first element it finds and this may not be the one we require.
Consider this example:
<div class="near-leg" test-id="near-leg">
<div class="buy" test-id="amount-input">
<div class="sell" test-id="amount-input">
<div class="far-leg" test-id="far-leg">
<div class="buy" test-id="amount-input">
<div class="sell" test-id="amount-input">
We could easily determine which amount input we want in two ways.
- Get the first and last of an element.all.
- Chain elements using the containing element.
The first would look something like:
var nearLegBuy = element.all(by.css('.buy').first();
var nearLegSell = element.all(by.css('.sell').first();
var farLegBuy = element.all(by.css('.buy').last();
var farLegSell = element.all(by.css('.sell').last();
The second would look something like:
var nearLegBuy = element(by.css('.near-leg')).element(by.css('.buy'));
var nearLegSell = element(by.css('.near-leg')).element(by.css('.sell'));
var farLegBuy = element(by.css('.far-leg')).element(by.css('.buy'));
var farLegSell = element(by.css('.far-leg')).element(by.css('.sell'));
Great. They work. What’s the problem?
This is a simple example. Consider what happens when the complexity of the app increases. These objects my be used in many places, the nesting could be multiple levels, there could be many more repeatedly named objects. Thus, the possibility of making mistakes when adding new objects increases. The time taken to debug increases. The lack of reusable content makes updating, maintaining and extending that much more difficult.
How do we make that easier?
Firstly by using a custom locator.
by.addLocator('testId', function(value, parentElement){
parentElement = parentElement || document;
var nodes = parentElement.querySelectorAll('[test-id]');
return Array.prototype.filter.call(nodes, function(node){
return (node.getAttribute('test-id') === value;
])[0];
;
For the first example, it looks fairly similar, just replacing the css locator with our testId locator.
However, for the second example, if we try to chain the elements this doesn’t work. The locator will only return the first instance.
What can we do?
We could amend the custom locator to return all instances, rather than the first one in the array. Then, providing we know which one we want, we could select it. That could get messy. And doesn’t enable easy reuse. And if the order changes for some reason, well, there’s all our objects broken.
Instead, using a helper function, we can utilise our custom locator and chain as many elements as we like.
Here’s the code:
var findTestId = function findTestId(){
this.findElements = function() {
var currentArgument = arguments;
return function() {
var myElement = element(by.testId(currentArgument[0]));
for (var i =1; i < newArgument.length; i++) {
myElement = myElement.element(by.testId(currentArgument[i]));
}
return myElement;
};
};
};
So now we can use:
var nearLegBuy = findTestId.findElements('near-leg','buy'));
var nearLegSell = findTestId.findElements('near-leg','sell'));
var farLegBuy = findTestId.findElements('far-leg','buy'));
var farLegSell = findTestId.findElements('far-leg','sell'));
This is now easier to read, maintain and extend. If we add another container or another leg with buy and sell fields, updating these will require less work.
Sometimes, examples like this don’t manifest until you start adding complexity. Sometimes, you may not need this and the first options will do. But if you don’t have test ids in your app and you are automating, well, there is your starting point.