WebDriverIO's architecture is designed to be highly extensible, allowing you to create custom commands and services that can significantly enhance your test automation framework. This module explores how to extend WebDriverIO's functionality through custom commands, services, and reporters to create a more powerful and tailored testing solution.
By the end of this module, you will be able to:
WebDriverIO provides several ways to extend its functionality:
The extension architecture follows these principles:
Browser-level commands are methods that you can call on the browser
object:
// In a custom command file or wdio.conf.js
browser.addCommand('waitAndClick', async function(selector, timeout = 5000) {
const element = await $(selector);
await element.waitForClickable({ timeout });
await element.click();
return element;
});
// Usage in tests
await browser.waitAndClick('#submit-button');
Element-level commands are methods that you can call on element objects:
// In a custom command file or wdio.conf.js
browser.addCommand('waitAndClick', async function(timeout = 5000) {
await this.waitForClickable({ timeout });
await this.click();
return this;
}, true); // true flag indicates this is an element command
// Usage in tests
const button = await $('#submit-button');
await button.waitAndClick();
// Or chained
await $('#submit-button').waitAndClick();
For better organization, create a dedicated file for custom commands:
// customCommands.js
module.exports = {
init: function() {
// Browser commands
browser.addCommand('login', async function(username, password) {
await this.url('/login');
await $('#username').setValue(username);
await $('#password').setValue(password);
await $('#login-button').click();
await $('.welcome-message').waitForDisplayed({ timeout: 5000 });
});
// Element commands
browser.addCommand('fillInput', async function(value) {
await this.clearValue();
await this.setValue(value);
return this;
}, true);
browser.addCommand('hasClass', async function(className) {
const classes = await this.getAttribute('class');
return classes.split(' ').includes(className);
}, true);
}
};
// In wdio.conf.js
const customCommands = require('./customCommands');
exports.config = {
// ...
before: function() {
customCommands.init();
}
};
// Form interaction commands
browser.addCommand('fillForm', async function(formData) {
for (const [field, value] of Object.entries(formData)) {
const element = await $(`#${field}`);
await element.clearValue();
await element.setValue(value);
}
});
browser.addCommand('submitForm', async function(formSelector, formData) {
await this.fillForm(formData);
await $(formSelector).scrollIntoView();
await $(`${formSelector} button[type="submit"]`).click();
});
// Usage
await browser.fillForm({
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com'
});
await browser.submitForm('#registration-form', {
username: 'johndoe',
password: 'securePassword123'
});
// Navigation commands
browser.addCommand('navigateTo', async function(section) {
const navigationMap = {
home: '/',
products: '/products',
about: '/about',
contact: '/contact'
};
if (!navigationMap[section]) {
throw new Error(`Unknown section: ${section}`);
}
await this.url(navigationMap[section]);
await this.waitUntil(async () => {
return (await this.getUrl()).includes(navigationMap[section]);
}, { timeout: 5000 });
});
// State verification commands
browser.addCommand('isLoggedIn', async function() {
return await $('.user-profile').isExisting();
});
browser.addCommand('waitForPageLoad', async function() {
await this.waitUntil(async () => {
return await this.execute(() => document.readyState === 'complete');
}, { timeout: 10000, timeoutMsg: 'Page did not finish loading' });
});
// Usage
await browser.navigateTo('products');
await browser.waitForPageLoad();
const loggedIn = await browser.isLoggedIn();
// Element interaction commands
browser.addCommand('getText', async function() {
return await this.getText();
}, true);
browser.addCommand('setValue', async function(value) {
await this.clearValue();
await this.setValue(value);
return this;
}, true);
browser.addCommand('selectOption', async function(optionText) {
await this.click();
await $(`//option[text()="${optionText}"]`).click();
return this;
}, true);
// Usage
const text = await $('.heading').getText();
await $('#username').setValue('johndoe');
await $('#country').selectOption('United States');
Services are plugins that provide additional functionality to WebDriverIO. They can:
// myService.js
class MyService {
// Service constructor receives the custom options, capabilities, and config
constructor(options, capabilities, config) {
this.options = options;
this.capabilities = capabilities;
this.config = config;
}
// Runs before the test execution begins
onPrepare(config, capabilities) {
console.log('Preparing test execution');
// Setup code: start servers, prepare test data, etc.
}
// Runs before a test session starts
beforeSession(config, capabilities, specs) {
console.log('Starting test session');
// Session setup code
}
// Runs before the first test starts
before(capabilities, specs) {
console.log('Before first test');
// Add custom commands
browser.addCommand('myCustomCommand', function() {
return 'result';
});
}
// Runs before each test
beforeTest(test, context) {
console.log(`Running test: ${test.title}`);
// Test setup code
}
// Runs after each test
afterTest(test, context, { error, result, duration, passed, retries }) {
console.log(`Test ${passed ? 'passed' : 'failed'}: ${test.title}`);
// Test cleanup code
}
// Runs after all tests are complete
after(result, capabilities, specs) {
console.log('After all tests');
// Cleanup code
}
// Runs after a test session is complete
afterSession(config, capabilities, specs) {
console.log('After session');
// Session cleanup code
}
// Runs after all workers have finished
onComplete(exitCode, config, capabilities, results) {
console.log('Test execution complete');
// Final cleanup code
}
}
module.exports = MyService;
// wdio.conf.js
const MyService = require('./myService');
exports.config = {
// ...
services: [
['myService', {
// Custom service options
option1: 'value1',
option2: 'value2'
}]
],
// ...
};
// screenshotService.js
class ScreenshotService {
constructor(options) {
this.options = {
screenshotPath: './screenshots',
screenshotOnFail: true,
...options
};
}
beforeSession() {
const fs = require('fs');
if (!fs.existsSync(this.options.screenshotPath)) {
fs.mkdirSync(this.options.screenshotPath, { recursive: true });
}
}
afterTest(test, context, { error, passed }) {
if (this.options.screenshotOnFail && !passed) {
const timestamp = new Date().toISOString().replace(/:/g, '-');
const filename = `${test.parent}-${test.title}-${timestamp}.png`;
const path = `${this.options.screenshotPath}/${filename}`;
browser.saveScreenshot(path);
console.log(`Screenshot saved to: ${path}`);
}
}
before() {
browser.addCommand('takeScreenshot', async function(name) {
const timestamp = new Date().toISOString().replace(/:/g, '-');
const filename = `${name || 'screenshot'}-${timestamp}.png`;
const path = `${this.options.screenshotPath}/${filename}`;
await browser.saveScreenshot(path);
console.log(`Screenshot saved to: ${path}`);
return path;
}.bind(this));
}
}
module.exports = ScreenshotService;
// apiService.js
const axios = require('axios');
class ApiService {
constructor(options) {
this.options = {
baseUrl: 'https://api.example.com',
headers: {
'Content-Type': 'application/json'
},
...options
};
this.token = null;
}
before() {
// Add API commands to browser object
browser.addCommand('apiGet', this.apiGet.bind(this));
browser.addCommand('apiPost', this.apiPost.bind(this));
browser.addCommand('apiPut', this.apiPut.bind(this));
browser.addCommand('apiDelete', this.apiDelete.bind(this));
browser.addCommand('apiLogin', this.apiLogin.bind(this));
}
async apiGet(endpoint, params = {}) {
try {
const response = await axios.get(`${this.options.baseUrl}${endpoint}`, {
params,
headers: this.getHeaders()
});
return response.data;
} catch (error) {
console.error(`API GET Error: ${error.message}`);
throw error;
}
}
async apiPost(endpoint, data = {}) {
try {
const response = await axios.post(`${this.options.baseUrl}${endpoint}`, data, {
headers: this.getHeaders()
});
return response.data;
} catch (error) {
console.error(`API POST Error: ${error.message}`);
throw error;
}
}
async apiPut(endpoint, data = {}) {
try {
const response = await axios.put(`${this.options.baseUrl}${endpoint}`, data, {
headers: this.getHeaders()
});
return response.data;
} catch (error) {
console.error(`API PUT Error: ${error.message}`);
throw error;
}
}
async apiDelete(endpoint) {
try {
const response = await axios.delete(`${this.options.baseUrl}${endpoint}`, {
headers: this.getHeaders()
});
return response.data;
} catch (error) {
console.error(`API DELETE Error: ${error.message}`);
throw error;
}
}
async apiLogin(username, password) {
try {
const response = await axios.post(`${this.options.baseUrl}/login`, {
username,
password
}, {
headers: this.options.headers
});
this.token = response.data.token;
return response.data;
} catch (error) {
console.error(`API Login Error: ${error.message}`);
throw error;
}
}
getHeaders() {
const headers = { ...this.options.headers };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
}
module.exports = ApiService;
// dbService.js
const mysql = require('mysql2/promise');
class DatabaseService {
constructor(options) {
this.options = {
host: 'localhost',
user: 'root',
password: '',
database: 'test',
...options
};
this.connection = null;
}
async onPrepare() {
try {
this.connection = await mysql.createConnection(this.options);
console.log('Database connection established');
} catch (error) {
console.error(`Database connection error: ${error.message}`);
throw error;
}
}
before() {
browser.addCommand('dbQuery', this.dbQuery.bind(this));
browser.addCommand('dbExecute', this.dbExecute.bind(this));
browser.addCommand('dbInsert', this.dbInsert.bind(this));
browser.addCommand('dbUpdate', this.dbUpdate.bind(this));
browser.addCommand('dbDelete', this.dbDelete.bind(this));
}
async dbQuery(sql, params = []) {
try {
const [rows] = await this.connection.execute(sql, params);
return rows;
} catch (error) {
console.error(`Database query error: ${error.message}`);
throw error;
}
}
async dbExecute(sql, params = []) {
try {
const [result] = await this.connection.execute(sql, params);
return result;
} catch (error) {
console.error(`Database execute error: ${error.message}`);
throw error;
}
}
async dbInsert(table, data) {
const columns = Object.keys(data).join(', ');
const placeholders = Object.keys(data).map(() => '?').join(', ');
const values = Object.values(data);
const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`;
try {
const [result] = await this.connection.execute(sql, values);
return result;
} catch (error) {
console.error(`Database insert error: ${error.message}`);
throw error;
}
}
async dbUpdate(table, data, where) {
const setClause = Object.keys(data).map(key => `${key} = ?`).join(', ');
const whereClause = Object.keys(where).map(key => `${key} = ?`).join(' AND ');
const values = [...Object.values(data), ...Object.values(where)];
const sql = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`;
try {
const [result] = await this.connection.execute(sql, values);
return result;
} catch (error) {
console.error(`Database update error: ${error.message}`);
throw error;
}
}
async dbDelete(table, where) {
const whereClause = Object.keys(where).map(key => `${key} = ?`).join(' AND ');
const values = Object.values(where);
const sql = `DELETE FROM ${table} WHERE ${whereClause}`;
try {
const [result] = await this.connection.execute(sql, values);
return result;
} catch (error) {
console.error(`Database delete error: ${error.message}`);
throw error;
}
}
onComplete() {
if (this.connection) {
this.connection.end();
console.log('Database connection closed');
}
}
}
module.exports = DatabaseService;
Reporters in WebDriverIO are responsible for processing and formatting test results. They can:
// myReporter.js
class MyReporter {
constructor(options) {
this.options = options;
this.results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
}
// Called when the test runner starts
onRunnerStart(runner) {
console.log('Test run started');
console.log(`Running ${runner.specs.length} spec files`);
}
// Called when a test suite starts
onSuiteStart(suite) {
console.log(`Suite started: ${suite.title}`);
}
// Called when a test starts
onTestStart(test) {
console.log(`Test started: ${test.title}`);
}
// Called when a test ends
onTestEnd(test) {
console.log(`Test ended: ${test.title}`);
this.results.tests.push({
title: test.title,
state: test.state,
duration: test.duration,
error: test.error ? test.error.message : null
});
if (test.state === 'passed') {
this.results.passed++;
} else if (test.state === 'failed') {
this.results.failed++;
} else if (test.state === 'skipped') {
this.results.skipped++;
}
}
// Called when a test suite ends
onSuiteEnd(suite) {
console.log(`Suite ended: ${suite.title}`);
}
// Called when the test runner ends
onRunnerEnd(runner) {
console.log('Test run ended');
console.log(`Results: ${this.results.passed} passed, ${this.results.failed} failed, ${this.results.skipped} skipped`);
// Save results to file if needed
if (this.options.outputFile) {
const fs = require('fs');
fs.writeFileSync(
this.options.outputFile,
JSON.stringify(this.results, null, 2)
);
console.log(`Report saved to ${this.options.outputFile}`);
}
}
}
module.exports = MyReporter;
// wdio.conf.js
const MyReporter = require('./myReporter');
exports.config = {
// ...
reporters: [
'spec',
['myReporter', {
outputFile: './reports/custom-report.json'
}]
],
// ...
};
// htmlReporter.js
const fs = require('fs');
const path = require('path');
class HtmlReporter {
constructor(options) {
this.options = {
outputDir: './reports',
filename: 'report.html',
...options
};
this.results = {
start: new Date(),
end: null,
passed: 0,
failed: 0,
skipped: 0,
suites: []
};
this.currentSuite = null;
}
onRunnerStart(runner) {
this.results.start = new Date();
// Create output directory if it doesn't exist
if (!fs.existsSync(this.options.outputDir)) {
fs.mkdirSync(this.options.outputDir, { recursive: true });
}
}
onSuiteStart(suite) {
this.currentSuite = {
title: suite.title,
tests: []
};
}
onTestStart(test) {
// Nothing to do here
}
onTestEnd(test) {
if (this.currentSuite) {
this.currentSuite.tests.push({
title: test.title,
state: test.state,
duration: test.duration,
error: test.error ? test.error.message : null
});
if (test.state === 'passed') {
this.results.passed++;
} else if (test.state === 'failed') {
this.results.failed++;
} else if (test.state === 'skipped') {
this.results.skipped++;
}
}
}
onSuiteEnd(suite) {
if (this.currentSuite) {
this.results.suites.push(this.currentSuite);
this.currentSuite = null;
}
}
onRunnerEnd(runner) {
this.results.end = new Date();
this.results.duration = this.results.end - this.results.start;
// Generate HTML report
const html = this.generateHtml();
// Write report to file
const outputPath = path.join(this.options.outputDir, this.options.filename);
fs.writeFileSync(outputPath, html);
console.log(`HTML report generated at ${outputPath}`);
}
generateHtml() {
const totalTests = this.results.passed + this.results.failed + this.results.skipped;
const passRate = totalTests > 0 ? (this.results.passed / totalTests * 100).toFixed(2) : 0;
let html = `
<!DOCTYPE html>
<html>
<head>
<title>WebDriverIO Test Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
.header { background-color: #f8f9fa; padding: 20px; margin-bottom: 20px; }
.summary { display: flex; margin-bottom: 20px; }
.summary-item { flex: 1; padding: 10px; text-align: center; }
.passed { background-color: #d4edda; }
.failed { background-color: #f8d7da; }
.skipped { background-color: #fff3cd; }
.suite { margin-bottom: 20px; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
.suite-header { background-color: #f8f9fa; padding: 10px; font-weight: bold; }
.test { padding: 10px; border-top: 1px solid #ddd; }
.test-passed { border-left: 5px solid #28a745; }
.test-failed { border-left: 5px solid #dc3545; }
.test-skipped { border-left: 5px solid #ffc107; }
.error { color: #dc3545; margin-top: 5px; font-family: monospace; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="header">
<h1>WebDriverIO Test Report</h1>
<p>Started: ${this.results.start.toLocaleString()}</p>
<p>Duration: ${(this.results.duration / 1000).toFixed(2)} seconds</p>
</div>
<div class="summary">
<div class="summary-item passed">
<h2>${this.results.passed}</h2>
<p>Passed</p>
</div>
<div class="summary-item failed">
<h2>${this.results.failed}</h2>
<p>Failed</p>
</div>
<div class="summary-item skipped">
<h2>${this.results.skipped}</h2>
<p>Skipped</p>
</div>
<div class="summary-item">
<h2>${passRate}%</h2>
<p>Pass Rate</p>
</div>
</div>
`;
// Add test suites
for (const suite of this.results.suites) {
html += `
<div class="suite">
<div class="suite-header">${suite.title}</div>
`;
for (const test of suite.tests) {
const testClass = `test test-${test.state}`;
html += `
<div class="${testClass}">
<div>${test.title}</div>
<div>State: ${test.state}, Duration: ${(test.duration / 1000).toFixed(2)}s</div>
`;
if (test.error) {
html += `<div class="error">${test.error}</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += `
</body>
</html>
`;
return html;
}
}
module.exports = HtmlReporter;
// slackReporter.js
const axios = require('axios');
class SlackReporter {
constructor(options) {
this.options = {
webhookUrl: '',
notifyOnlyOnFailure: false,
...options
};
this.results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
}
onRunnerStart(runner) {
this.startTime = new Date();
this.results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
}
onTestEnd(test) {
if (test.state === 'passed') {
this.results.passed++;
} else if (test.state === 'failed') {
this.results.failed++;
this.results.tests.push({
title: test.title,
error: test.error ? test.error.message : 'Unknown error'
});
} else if (test.state === 'skipped') {
this.results.skipped++;
}
}
async onRunnerEnd(runner) {
const duration = (new Date() - this.startTime) / 1000;
const totalTests = this.results.passed + this.results.failed + this.results.skipped;
// Skip notification if notifyOnlyOnFailure is true and no tests failed
if (this.options.notifyOnlyOnFailure && this.results.failed === 0) {
return;
}
// Create Slack message
const message = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `Test Run ${this.results.failed > 0 ? 'Failed ❌' : 'Passed ✅'}`
}
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Total Tests:*\n${totalTests}`
},
{
type: 'mrkdwn',
text: `*Duration:*\n${duration.toFixed(2)}s`
},
{
type: 'mrkdwn',
text: `*Passed:*\n${this.results.passed}`
},
{
type: 'mrkdwn',
text: `*Failed:*\n${this.results.failed}`
}
]
}
]
};
// Add failed tests if any
if (this.results.failed > 0) {
message.blocks.push({
type: 'divider'
});
message.blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Failed Tests:*'
}
});
for (const test of this.results.tests) {
message.blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `• *${test.title}*\n\`\`\`${test.error}\`\`\``
}
});
}
}
// Send to Slack
if (this.options.webhookUrl) {
try {
await axios.post(this.options.webhookUrl, message);
console.log('Test results sent to Slack');
} catch (error) {
console.error(`Failed to send results to Slack: ${error.message}`);
}
} else {
console.warn('Slack webhook URL not provided');
}
}
}
module.exports = SlackReporter;
Modular Structure: Organize extensions by type and functionality
project/
├── wdio.conf.js
├── test/
│ └── specs/
├── commands/
│ ├── browserCommands.js
│ ├── elementCommands.js
│ └── index.js
├── services/
│ ├── apiService.js
│ ├── dbService.js
│ └── index.js
└── reporters/
├── htmlReporter.js
└── slackReporter.js
Centralized Initialization: Create index files to initialize all extensions
// commands/index.js
const browserCommands = require('./browserCommands');
const elementCommands = require('./elementCommands');
module.exports = {
init: function() {
browserCommands.init();
elementCommands.init();
}
};
// In wdio.conf.js
const commands = require('./commands');
exports.config = {
// ...
before: function() {
commands.init();
}
};
Follow Naming Conventions: Use clear, descriptive names that indicate functionality
// Good
browser.addCommand('waitAndClick', ...);
browser.addCommand('fillFormAndSubmit', ...);
// Avoid
browser.addCommand('wc', ...); // Too short, unclear
browser.addCommand('doStuff', ...); // Too vague
Return Values for Chaining: Return appropriate values to enable method chaining
// Element commands should return the element for chaining
browser.addCommand('fillInput', async function(value) {
await this.clearValue();
await this.setValue(value);
return this; // Return element for chaining
}, true);
// Usage
await $('#username')
.fillInput('johndoe')
.pressEnter();
Handle Errors Gracefully: Include proper error handling in commands
browser.addCommand('safeClick', async function(selector) {
try {
const element = await $(selector);
await element.waitForClickable({ timeout: 5000 });
await element.click();
return true;
} catch (error) {
console.error(`Failed to click ${selector}: ${error.message}`);
return false;
}
});
Document Commands: Add clear documentation for each command
/**
* Waits for an element to be clickable and then clicks it
* @param {string} selector - CSS selector for the element
* @param {number} timeout - Timeout in milliseconds (default: 5000)
* @returns {Promise<WebdriverIO.Element>} The clicked element
* @example
* await browser.waitAndClick('#submit-button');
*/
browser.addCommand('waitAndClick', async function(selector, timeout = 5000) {
const element = await $(selector);
await element.waitForClickable({ timeout });
await element.click();
return element;
});
Configuration Options: Make services configurable with sensible defaults
class MyService {
constructor(options) {
this.options = {
// Default options
enabled: true,
logLevel: 'info',
// Merge with user options
...options
};
}
// Service methods
}
Resource Management: Properly initialize and clean up resources
class DatabaseService {
// ...
async onPrepare() {
// Initialize resources
this.connection = await createConnection();
}
onComplete() {
// Clean up resources
if (this.connection) {
this.connection.close();
}
}
}
Error Handling: Handle and report errors without crashing tests
class ApiService {
// ...
async makeRequest(url, options) {
try {
return await axios(url, options);
} catch (error) {
console.error(`API request failed: ${error.message}`);
// Log additional details but don't crash the test
return { error: error.message };
}
}
}
Create a set of custom commands for form interactions:
fillForm
: Fill multiple form fields with provided datavalidateForm
: Check form validation messagessubmitFormAndWait
: Submit a form and wait for a responseselectFromDropdown
: Select an option from a dropdown by textDevelop a service for API testing that:
Create a custom HTML reporter that:
This module explored how to extend WebDriverIO's functionality through custom commands, services, and reporters. By leveraging WebDriverIO's extension architecture, you can create more powerful, efficient, and tailored test automation solutions.
Key takeaways:
In the next module, we'll explore how to integrate WebDriverIO with Cucumber for behavior-driven development (BDD) testing.