Previous: Debugging and Troubleshooting Next: TestNG Integration

Module 9: Custom Commands and Utilities

Overview

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.

Learning Objectives

By the end of this module, you will be able to:

  1. Create and implement custom WebDriverIO commands
  2. Develop reusable services for common testing needs
  3. Build custom reporters for specialized test reporting
  4. Understand WebDriverIO's plugin architecture
  5. Apply best practices for extending WebDriverIO

5.1 Understanding WebDriverIO's Extension Architecture

WebDriverIO's Plugin System

WebDriverIO provides several ways to extend its functionality:

  1. Custom Commands: Add new methods to the browser, element, or mock objects
  2. Services: Add functionality that runs before, during, or after test execution
  3. Reporters: Customize how test results are reported and formatted
  4. Hooks: Execute code at specific points in the test lifecycle
  5. Frameworks: Integrate with test frameworks beyond the built-in options

The extension architecture follows these principles:

5.2 Creating Custom Commands

Adding Browser-Level Commands

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');

Adding Element-Level Commands

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();

Creating a Custom Commands File

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();
    }
};

Practical Custom Command Examples

Form Interaction Commands

// 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

// 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');

5.3 Creating WebDriverIO Services

What Are WebDriverIO Services?

Services are plugins that provide additional functionality to WebDriverIO. They can:

Creating a Basic Service

// 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;

Configuring a Service in wdio.conf.js

// wdio.conf.js
const MyService = require('./myService');

exports.config = {
    // ...
    services: [
        ['myService', {
            // Custom service options
            option1: 'value1',
            option2: 'value2'
        }]
    ],
    // ...
};

Practical Service Examples

Screenshot Service

// 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;

API Testing Service

// 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;

Database Service

// 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;

5.4 Creating Custom Reporters

Understanding WebDriverIO Reporters

Reporters in WebDriverIO are responsible for processing and formatting test results. They can:

Creating a Basic Reporter

// 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;

Configuring a Reporter in wdio.conf.js

// wdio.conf.js
const MyReporter = require('./myReporter');

exports.config = {
    // ...
    reporters: [
        'spec',
        ['myReporter', {
            outputFile: './reports/custom-report.json'
        }]
    ],
    // ...
};

Practical Reporter Examples

HTML Reporter

// 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;

Slack Reporter

// 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;

5.5 Best Practices for Extending WebDriverIO

Organizing Extensions

  1. 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
    
  2. 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();
        }
    };
    

Designing Effective Commands

  1. 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
    
  2. 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();
    
  3. 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;
        }
    });
    
  4. 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;
    });
    

Creating Maintainable Services

  1. 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
    }
    
  2. 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();
            }
        }
    }
    
  3. 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 };
            }
        }
    }
    

5.6 Practical Exercises

Exercise 1: Form Interaction Commands

Create a set of custom commands for form interactions:

Exercise 2: API Testing Service

Develop a service for API testing that:

Exercise 3: Custom HTML Reporter

Create a custom HTML reporter that:

Summary

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:

  1. Custom commands allow you to extend browser and element functionality with reusable methods
  2. Services provide a way to add functionality that runs at different points in the test lifecycle
  3. Custom reporters enable specialized reporting and integration with external tools
  4. Following best practices ensures maintainable and effective extensions
  5. WebDriverIO's modular architecture makes it highly extensible for various testing needs

In the next module, we'll explore how to integrate WebDriverIO with Cucumber for behavior-driven development (BDD) testing.

Previous: Debugging and Troubleshooting Next: TestNG Integration