Tips for writing Page Objects and Component Objects
1. Implementing class properties should only return strings
Any logic related to the browser happens inside the spec file. This keeps our spec files readable and the Page / Component Objects simple and predictable.
All implementing classes should only return strings: DashboardPage.menuButton
would return '[data-test="menu-button"]
(see point 3 below). In the spec file this selector can be used to create a WebdriverIO Element: const menuBtn = await browser.$(DashboardPage.menuButton);
.
2. No new methods in implementing classes
Just like the previous point, all logic should happen in the spec file. This means that no methods should be written in implementing classes. Even though it might seem tempting to do (for example to prevent duplication), we value readability of the spec file and predictability of Page / Component Objects more.
3. Use data-attributes as selectors
In a large app refactors are common. It can happen that the structure of your HTML changes and you want the selectors in your Page Objects to be as resilient as possible to changes.
Let’s consider an example where you have a wrapper element:
. For SEO reasons might want to use a semantic
// wdio.conf.js
exports.config = {
specs: ['spec/**/*.spec.ts'],
framework: 'jasmine',
jasmineNodeOpts: {
defaultTimeoutInterval: 60000,
grep: '#happy-flow',
invertGrep: false,
},
// ... other config
}
// ./spec/grep-example.spec.ts
describe('various tests', () => {
it('I will be executed #happy-flow', () => {});
it('I will be filtered', () => {});
});
However, even when there is no match on the grep, Webdriver.io creates a browser instance. This does not scale with hundreds of spec files. Starting up a new window takes time and if you need to execute preparation tasks (logging in, loading base URL, etc.) it adds up. Additionally, some WebDrivers are slow and, in our case, testing on real devices is even slower.
We needed to optimize. The biggest bottleneck was testing on real devices. We opted for an approach where we would only run #happy-flow
tests on mobile. All combinations look like this:
We knew that only a small subset of all spec files would match the #happy-flow
tests. A possible approach could be to split up our tests in multiple files (e.g. *.spec.ts
, *.happy-flow.spec.ts
, *.mobile.spec.ts
), but that would cause bad developer experience and also duplicate expensive actions such as preparation steps, navigating to URL, clean-up, etc.).
We decided to create our own grep script, which reads all possible spec files in memory, executes a regex based on our hashtags and returns an array of filenames that have a spec file that matches our requirements.
// ./wdio.conf.js
const argv = require('yargs').argv;
const {
getFilteredSpecFiles,
getRegularExpression } = require('./getSpecFiles');
const cmdOpts = {
'happy-flow': argv.happyFlow,
target: argv.target,
};
const SPECS = getFilteredSpecFiles(['spec/**/*.spec.ts'], cmdOpts);
exports.config = {
specs: argv.spec || SPECS,
framework: 'jasmine',
jasmineNodeOpts: {
defaultTimeoutInterval: 60000,
grep: getRegularExpression(cmdOpts),
invertGrep: false,
isVerbose: false,
},
};
// getSpecFiles.js
const glob = require('glob');
const { readFileSync } = require('fs-extra');
function getFilteredSpecFiles(specs, opts = {}) {
const { glob, grep } = getRegularExpression(opts);
const fileNames = getGlobMatches(specs);
const includedSpecFiles = [];
for (const path of fileNames) {
const file = readFileSync(path, 'utf-8');
if (file.match(glob)) {
includedSpecFiles.push(path);
}
}
return includedSpecFiles;
}
function getGlobMatches(specs) {
return specs.reduce((accumulator, spec) => {
return [...accumulator, ...glob.sync(spec)];
}, []);
}
function getRegularExpression({ happyFlow, target }) {
if (!happyFlow && target === 'desktop') {
// ❌ #happy-flow and ❌ #mobile
return {
glob: /^.*(describe|it)\((?!.*(#happy-flow|#mobile)).*/gm,
grep: /^(?!.*(#happy-flow|#mobile)).*/,
};
} else if (happyFlow && target === 'desktop') {
// ✅ #happy-flow, ❌ #mobile
return {
glob: /^.*(describe|it)\((?!.*(#mobile)).*(#happy-flow).*/gm,
grep: /^(?!.*(#mobile)).*(#happy-flow).*/,
};
} else if (!happyFlow && target === 'mobile') {
// ❌ #happy-flow, ❌ #desktop, ✅ #mobile
return {
glob: /^.*(describe|it)\((?!.*(#happy-flow|#desktop)).*(#mobile).*/gm,
grep: /^(?!.*(#happy-flow|#desktop)).*(#mobile).*/
};
} else if (happyFlow && target === 'mobile') {
// ✅ #happy-flow, ❌ #desktop, ✅ #mobile
return {
glob: /^.*(describe|it)\((?!.*(#desktop)).*(#happy-flow).*/gm,
grep: /^(?!.*(#desktop)).*(#happy-flow).*/
};
} else {
throw new Error('unforeseen case');
}
}
exports.getFilteredSpecFiles = getFilteredSpecFiles;
exports.getRegularExpression = getRegularExpression;
This optimization allowed us to reduce the amount of created browser instances from over two hundred to a couple dozen, while at the same time keeping developer experience great (they can write all their tests in a single spec file).
If you have any comments, potential improvements or completely disagree with this approach, leave a note in the comments, I’d love to hear your thoughts!
Thanks for reading!
Bonus: favorite WDIO plugins ❤
- Selenium standalone service
This service sets up a selenium server for you to use without any hassle. It will also provide you with browser drivers. - Axe-core
Best way to test for accessibility issues. Technically not a plug-in, but easily set up using this example - wdio-image-comparison-service
Easy to use, yet powerful way to automatically take screenshots of pages or components and compare them to a baseline - Performance
For performance we use an in-house package called Gonzales.