WebdriverIO эволюционировал из простой обертки над WebDriver в комплексный фреймворк для end-to-end тестирования. Его плагиновая архитектура, возможности multiremote и мощные функции расширяемости делают его привлекательным выбором для современной автоматизации тестирования. Это руководство исследует продвинутые возможности WebdriverIO и предоставляет полный путь миграции с Selenium (как обсуждается в Katalon Studio: Complete All-in-One Test Automation Platform) WebDriver.
Введение
WebdriverIO (WDIO) выделяется на переполненном рынке автоматизации благодаря модульной архитектуре, обширной экосистеме и дружественному к разработчикам API. В то время как многие команды начинают с Selenium (как обсуждается в TestComplete Commercial Tool: ROI Analysis and Enterprise Test Automation) WebDriver, WebdriverIO предлагает значительные преимущества: встроенные интеграции сервисов, автоматические механизмы повторных попыток, интеллектуальные стратегии ожидания элементов и возможность запускать тесты в нескольких браузерах одновременно.
Эта статья рассматривает три критических аспекта:
- Расширяемость - Создание пользовательских команд, сервисов и репортеров
- Multiremote - Запуск синхронизированных тестов в нескольких сеансах
- Миграция - Переход с Selenium (как обсуждается в Cypress Deep Dive: Architecture, Debugging, and Network Stubbing Mastery) WebDriver на WebdriverIO
Независимо от того, разрабатываете ли вы масштабируемый фреймворк для тестирования или оцениваете WebdriverIO для своей команды, это руководство предоставляет практические знания, подкрепленные реальными реализациями.
Обзор Архитектуры WebdriverIO
Основные Компоненты
Архитектура WebdriverIO состоит из нескольких взаимосвязанных слоев:
Слой Протокола
webdriver
- Реализация протокола W3C WebDriverdevtools
- Поддержка протокола Chrome DevToolsappium
- Протокол мобильной автоматизации
Основной Слой
@wdio/cli
- Интерфейс командной строки и test runner@wdio/config
- Парсер конфигурации@wdio/utils
- Общие утилиты
Слой Интеграции
- Services - Интеграции с внешними инструментами (Selenium, Appium, Sauce Labs)
- Reporters - Форматировщики результатов тестов (Allure, Spec, JUnit)
- Frameworks - Адаптеры фреймворков тестирования (Mocha, Jasmine, Cucumber)
Этот модульный дизайн позволяет выборочное принятие функций и простую расширяемость.
Расширяемость: Создание Пользовательских Решений
Пользовательские Команды
WebdriverIO позволяет расширять как объект browser, так и отдельные элементы пользовательскими командами. Эта возможность критически важна для создания предметно-ориентированных DSL тестирования и уменьшения дублирования кода.
Пользовательские Команды на Уровне Browser
// wdio.conf.js
export const config = {
before: function() {
// Добавить пользовательскую команду к объекту browser
browser.addCommand('loginAs', async function(username, password) {
await browser.url('/login');
await $('#username').setValue(username);
await $('#password').setValue(password);
await $('button[type="submit"]').click();
await browser.waitUntil(
async () => (await browser.getUrl()).includes('/dashboard'),
{
timeout: 5000,
timeoutMsg: 'Логин не перенаправил на dashboard'
}
);
});
// Асинхронная команда с логикой повторных попыток
browser.addCommand('waitForApiReady', async function(endpoint, maxRetries = 5) {
let attempts = 0;
while (attempts < maxRetries) {
try {
const response = await browser.executeAsync((endpoint, done) => {
fetch(endpoint)
.then(res => done({ status: res.status, ok: res.ok }))
.catch(err => done({ error: err.message }));
}, endpoint);
if (response.ok) {
return true;
}
} catch (error) {
console.log(`Попытка ${attempts + 1} проверки API провалилась`);
}
attempts++;
await browser.pause(1000);
}
throw new Error(`API ${endpoint} не готов после ${maxRetries} попыток`);
});
}
};
Пользовательские Команды на Уровне Элемента
browser.addCommand('clickIfDisplayed', async function() {
// 'this' ссылается на элемент
if (await this.isDisplayed()) {
await this.click();
return true;
}
return false;
}, true); // true указывает на команду уровня элемента
browser.addCommand('setValueAndVerify', async function(value) {
await this.setValue(value);
const actualValue = await this.getValue();
if (actualValue !== value) {
throw new Error(`Несоответствие значения: ожидалось "${value}", получено "${actualValue}"`);
}
}, true);
// Использование в тестах
await $('#dismissModal').clickIfDisplayed();
await $('#email').setValueAndVerify('test@example.com');
Переопределение Команд
Вы можете переопределять существующие команды для изменения поведения по умолчанию:
// Добавить автоматический скриншот при ошибках клика
const originalClick = browser.click;
browser.addCommand('click', async function(...args) {
try {
return await originalClick.apply(this, args);
} catch (error) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await browser.saveScreenshot(`./screenshots/click-failure-${timestamp}.png`);
throw error;
}
}, true);
Пользовательские Сервисы
Сервисы расширяют возможности WebdriverIO, подключаясь к жизненному циклу тестов. Они идеальны для настройки тестовой инфраструктуры, управления внешними зависимостями или реализации пользовательских отчетов.
Структура Сервиса
// services/DatabaseService.js
import { MongoClient } from 'mongodb';
export default class DatabaseService {
constructor(options) {
this.options = options;
this.client = null;
this.db = null;
}
// Вызывается один раз перед всеми тестами
async onPrepare(config, capabilities) {
console.log('Подключение к базе данных...');
this.client = await MongoClient.connect(this.options.connectionString);
this.db = this.client.db(this.options.dbName);
}
// Вызывается перед каждым набором тестов
async before(capabilities, specs) {
// Сделать базу данных доступной для тестов
global.testDb = this.db;
// Заполнить тестовыми данными
if (this.options.seedData) {
await this.seedDatabase();
}
}
// Вызывается после каждого теста
async afterTest(test, context, { passed }) {
if (!passed && this.options.captureStateOnFailure) {
const state = await this.db.collection('users').find({}).toArray();
test.dbState = state;
}
}
// Вызывается после каждого набора тестов
async after(result, capabilities, specs) {
// Очистить тестовые данные
if (this.options.cleanupAfterSuite) {
await this.cleanupDatabase();
}
}
// Вызывается один раз после всех тестов
async onComplete(exitCode, config, capabilities) {
console.log('Закрытие соединения с базой данных...');
await this.client.close();
}
async seedDatabase() {
await this.db.collection('users').insertMany([
{ username: 'testuser1', email: 'test1@example.com', role: 'user' },
{ username: 'admin1', email: 'admin@example.com', role: 'admin' }
]);
}
async cleanupDatabase() {
await this.db.collection('users').deleteMany({ email: /@example\.com$/ });
}
}
Конфигурация Сервиса
// wdio.conf.js
import DatabaseService from './services/DatabaseService.js';
export const config = {
services: [
['chromedriver'],
[DatabaseService, {
connectionString: 'mongodb://localhost:27017',
dbName: 'test_db',
seedData: true,
cleanupAfterSuite: true,
captureStateOnFailure: true
}]
]
};
Реальный Пример Сервиса: Mock API Сервер
// services/MockApiService.js
import express from 'express';
export default class MockApiService {
constructor(options = {}) {
this.port = options.port || 3001;
this.app = express();
this.server = null;
this.mocks = new Map();
}
async onPrepare() {
this.app.use(express.json());
// Динамический mock endpoint
this.app.all('*', (req, res) => {
const key = `${req.method}:${req.path}`;
const mock = this.mocks.get(key);
if (mock) {
res.status(mock.status || 200).json(mock.response);
} else {
res.status(404).json({ error: 'Mock не найден' });
}
});
return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => {
console.log(`Mock API сервер запущен на порту ${this.port}`);
resolve();
});
});
}
before() {
// Добавить helper к объекту browser
browser.addCommand('mockApi', (method, path, response, status = 200) => {
const key = `${method.toUpperCase()}:${path}`;
this.mocks.set(key, { response, status });
});
browser.addCommand('clearMocks', () => {
this.mocks.clear();
});
}
async onComplete() {
if (this.server) {
await new Promise((resolve) => this.server.close(resolve));
console.log('Mock API сервер остановлен');
}
}
}
Пользовательские Репортеры
Репортеры форматируют и выводят результаты тестов. Пользовательские репортеры обеспечивают интеграцию с собственными дашбордами, системами уведомлений или специализированными CI/CD пайплайнами.
// reporters/SlackReporter.js
import WDIOReporter from '@wdio/reporter';
import axios from 'axios';
export default class SlackReporter extends WDIOReporter {
constructor(options) {
super(options);
this.webhookUrl = options.webhookUrl;
this.failures = [];
}
onTestFail(test) {
this.failures.push({
title: test.title,
parent: test.parent,
error: test.error.message,
stack: test.error.stack
});
}
async onRunnerEnd(runner) {
if (this.failures.length === 0) return;
const message = {
text: `⚠️ Обнаружены Упавшие Тесты (${this.failures.length})`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${this.failures.length} тест(ов) провалились*`
}
},
{
type: 'divider'
},
...this.failures.slice(0, 5).map(failure => ({
type: 'section',
text: {
type: 'mrkdwn',
text: `*${failure.parent} > ${failure.title}*\n\`\`\`${failure.error}\`\`\``
}
}))
]
};
try {
await axios.post(this.webhookUrl, message);
} catch (error) {
console.error('Не удалось отправить уведомление в Slack:', error.message);
}
}
}
Multiremote: Синхронизированное Тестирование в Нескольких Браузерах
Multiremote — это уникальная возможность WebdriverIO одновременно управлять несколькими сеансами браузера. Это бесценно для тестирования:
- Функций совместной работы в реальном времени (чат, видеозвонки, совместное редактирование)
- Межбраузерной коммуникации
- Многопользовательских рабочих процессов
- Адаптивного дизайна на разных устройствах
Базовая Настройка Multiremote
// wdio.conf.js
export const config = {
capabilities: {
browser1: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--window-size=1920,1080']
}
}
},
browser2: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--window-size=1920,1080']
}
}
}
}
};
Примеры Multiremote Тестов
Тестирование Чата в Реальном Времени
describe('Чат в реальном времени', () => {
it('должен синхронизировать сообщения между пользователями', async () => {
// Пользователь 1 входит в систему
await browser1.loginAs('user1@test.com', 'password123');
await browser1.url('/chat/general');
// Пользователь 2 входит в систему
await browser2.loginAs('user2@test.com', 'password123');
await browser2.url('/chat/general');
// Пользователь 1 отправляет сообщение
const messageText = `Тестовое сообщение ${Date.now()}`;
await browser1.$('#messageInput').setValue(messageText);
await browser1.$('#sendButton').click();
// Проверить, что Пользователь 2 получает сообщение
await browser2.waitUntil(
async () => {
const messages = await browser2.$$('.chat-message');
const texts = await Promise.all(
messages.map(msg => msg.getText())
);
return texts.some(text => text.includes(messageText));
},
{
timeout: 5000,
timeoutMsg: 'Пользователь 2 не получил сообщение'
}
);
// Пользователь 2 видит правильного отправителя
const lastMessage = await browser2.$('.chat-message:last-child');
const sender = await lastMessage.$('.sender-name').getText();
expect(sender).toBe('user1');
});
it('должен показывать индикаторы ввода', async () => {
// Пользователь 1 начинает печатать
await browser1.$('#messageInput').click();
await browser1.$('#messageInput').keys('П');
// Пользователь 2 должен видеть индикатор ввода
await browser2.waitForDisplayed('.typing-indicator', { timeout: 2000 });
const indicatorText = await browser2.$('.typing-indicator').getText();
expect(indicatorText).toContain('user1 печатает');
// Пользователь 1 прекращает печатать
await browser1.$('#messageInput').clearValue();
await browser1.pause(3000); // Ждать таймаут ввода
// Индикатор должен исчезнуть
await browser2.waitForDisplayed('.typing-indicator', {
timeout: 2000,
reverse: true
});
});
});
Совместное Редактирование Документов
describe('Совместное редактирование документов', () => {
const documentId = 'test-doc-123';
before(async () => {
// Настройка: Оба пользователя переходят к одному документу
await Promise.all([
browser1.loginAs('editor1@test.com', 'pass123'),
browser2.loginAs('editor2@test.com', 'pass123')
]);
await browser1.url(`/documents/${documentId}`);
await browser2.url(`/documents/${documentId}`);
// Ждать загрузки документа
await Promise.all([
browser1.waitForDisplayed('#editor', { timeout: 5000 }),
browser2.waitForDisplayed('#editor', { timeout: 5000 })
]);
});
it('должен показывать одновременные правки в реальном времени', async () => {
// Редактор 1 печатает в параграфе 1
await browser1.$('#editor p:nth-child(1)').click();
await browser1.keys(['End']);
const text1 = ' Добавлено редактором 1.';
await browser1.keys(text1.split(''));
// Проверить, что Редактор 2 видит изменение
await browser2.waitUntil(
async () => {
const content = await browser2.$('#editor').getText();
return content.includes(text1);
},
{ timeout: 3000, timeoutMsg: 'Редактор 2 не увидел изменения Редактора 1' }
);
// Редактор 2 печатает в параграфе 2 одновременно
await browser2.$('#editor p:nth-child(2)').click();
await browser2.keys(['End']);
const text2 = ' Добавлено редактором 2.';
await browser2.keys(text2.split(''));
// Проверить, что Редактор 1 видит изменение Редактора 2
await browser1.waitUntil(
async () => {
const content = await browser1.$('#editor').getText();
return content.includes(text2);
},
{ timeout: 3000, timeoutMsg: 'Редактор 1 не увидел изменения Редактора 2' }
);
// Проверить, что оба редактора видят полный документ
const finalContent1 = await browser1.$('#editor').getText();
const finalContent2 = await browser2.$('#editor').getText();
expect(finalContent1).toBe(finalContent2);
expect(finalContent1).toContain(text1);
expect(finalContent1).toContain(text2);
});
it('должен обрабатывать разрешение конфликтов', async () => {
// Симулировать прерывание сети для browser1
await browser1.throttle('offline');
// Browser1 делает изменения в оффлайне
await browser1.$('#editor p:nth-child(1)').click();
await browser1.keys(['Command', 'a']); // Выделить все
await browser1.keys('Офлайн изменения редактором 1');
// Browser2 делает другие изменения, пока browser1 в оффлайне
await browser2.$('#editor p:nth-child(1)').click();
await browser2.keys(['Command', 'a']);
await browser2.keys('Онлайн изменения редактором 2');
// Восстановить соединение browser1
await browser1.throttle('online');
// Ждать разрешения конфликта
await browser.pause(2000);
// Проверить, что конфликт был обработан (зависит от реализации)
const conflictDialog1 = await browser1.$('.conflict-dialog');
const conflictDialog2 = await browser2.$('.conflict-dialog');
expect(await conflictDialog1.isDisplayed() || await conflictDialog2.isDisplayed()).toBe(true);
});
});
Продвинутые Паттерны Multiremote
Кросс-Девайсное Адаптивное Тестирование
export const config = {
capabilities: {
desktop: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--window-size=1920,1080']
}
}
},
tablet: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
mobileEmulation: {
deviceName: 'iPad'
}
}
}
},
mobile: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
mobileEmulation: {
deviceName: 'iPhone 12 Pro'
}
}
}
}
}
};
describe('Адаптивный макет', () => {
it('должен отображать соответствующую навигацию для каждого устройства', async () => {
await Promise.all([
desktop.url('/'),
tablet.url('/'),
mobile.url('/')
]);
// Desktop должен показывать полную навигацию
const desktopNav = await desktop.$('nav.desktop-nav');
expect(await desktopNav.isDisplayed()).toBe(true);
// Tablet может показывать сжатую навигацию
const tabletNav = await tablet.$('nav.tablet-nav');
expect(await tabletNav.isDisplayed()).toBe(true);
// Mobile должен показывать меню-гамбургер
const mobileHamburger = await mobile.$('.hamburger-menu');
expect(await mobileHamburger.isDisplayed()).toBe(true);
const mobileFullNav = await mobile.$('nav.desktop-nav');
expect(await mobileFullNav.isDisplayed()).toBe(false);
});
});
Руководство по Миграции: С Selenium WebDriver на WebdriverIO
Миграция с Selenium WebDriver на WebdriverIO требует понимания как концептуальных различий, так и изменений API.
Ключевые Концептуальные Различия
Аспект | Selenium WebDriver | WebdriverIO |
---|---|---|
Обработка Промисов | Требуются явные промисы/async-await | Автоматическая синхронизация (в sync режиме) |
Поиск Элементов | Многословный (driver.findElement(By.css(...)) ) | Краткий ($('selector') ) |
Ожидания | Необходимы ручные явные ожидания | Автоматическое умное ожидание |
Конфигурация | Требуется программная настройка | Основан на конфигурационном файле |
Test Runner | Требуется отдельный фреймворк (Jest, Mocha) | Встроенный test runner |
Логика Повторов | Ручная реализация | Встроенные повторы элементов |
Сопоставление Миграции API
Выбор Элементов
// Selenium WebDriver
const { By } = require('selenium-webdriver');
const element = await driver.findElement(By.css('#login-button'));
const elements = await driver.findElements(By.css('.list-item'));
// WebdriverIO
const element = await $('#login-button');
const elements = await $$('.list-item');
Взаимодействие с Элементами
// Selenium
const input = await driver.findElement(By.id('email'));
await input.clear();
await input.sendKeys('test@example.com');
await driver.findElement(By.id('submit')).click();
// WebdriverIO
await $('#email').clearValue();
await $('#email').setValue('test@example.com');
await $('#submit').click();
Навигация
// Selenium
await driver.get('https://example.com/login');
await driver.navigate().back();
await driver.navigate().forward();
await driver.navigate().refresh();
// WebdriverIO
await browser.url('/login'); // Относительно baseUrl
await browser.back();
await browser.forward();
await browser.refresh();
Ожидания и Ожидаемые Условия
// Selenium
const { until } = require('selenium-webdriver');
await driver.wait(until.elementLocated(By.id('result')), 5000);
await driver.wait(until.elementIsVisible(element), 5000);
// WebdriverIO (автоматическое ожидание)
await $('#result').waitForDisplayed({ timeout: 5000 });
await $('#result').waitForEnabled({ timeout: 5000 });
await browser.waitUntil(
async () => (await $('#counter').getText()) === '10',
{ timeout: 5000, timeoutMsg: 'Счетчик не достиг 10' }
);
Page Objects
// Page Object Selenium
class LoginPage {
constructor(driver) {
this.driver = driver;
}
async open() {
await this.driver.get('https://example.com/login');
}
async login(username, password) {
await this.driver.findElement(By.id('username')).sendKeys(username);
await this.driver.findElement(By.id('password')).sendKeys(password);
await this.driver.findElement(By.css('button[type="submit"]')).click();
}
async getErrorMessage() {
const element = await this.driver.findElement(By.css('.error-message'));
return await element.getText();
}
}
// Page Object WebdriverIO
class LoginPage {
get usernameInput() { return $('#username'); }
get passwordInput() { return $('#password'); }
get submitButton() { return $('button[type="submit"]'); }
get errorMessage() { return $('.error-message'); }
async open() {
await browser.url('/login');
}
async login(username, password) {
await this.usernameInput.setValue(username);
await this.passwordInput.setValue(password);
await this.submitButton.click();
}
async getErrorMessage() {
return await this.errorMessage.getText();
}
}
Пошаговый Процесс Миграции
Шаг 1: Установить WebdriverIO
npm install --save-dev @wdio/cli
npx wdio config
Шаг 2: Создать Конфигурационный Файл
// wdio.conf.js
export const config = {
specs: ['./test/specs/**/*.js'],
maxInstances: 5,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--disable-gpu', '--no-sandbox']
}
}],
logLevel: 'info',
baseUrl: 'http://localhost:3000',
waitforTimeout: 10000,
framework: 'mocha',
mochaOpts: {
timeout: 60000
},
reporters: ['spec']
};
Шаг 3: Мигрировать Структуру Тестов
// До: Selenium с Mocha
const { Builder } = require('selenium-webdriver');
describe('Тесты Логина', function() {
let driver;
before(async function() {
driver = await new Builder().forBrowser('chrome').build();
});
after(async function() {
await driver.quit();
});
it('должен успешно войти', async function() {
await driver.get('https://example.com/login');
// ... логика теста
});
});
// После: WebdriverIO
describe('Тесты Логина', () => {
it('должен успешно войти', async () => {
await browser.url('/login');
// ... логика теста - объект browser автоматически доступен
});
});
Шаг 4: Обработать Асинхронные Паттерны
// Selenium: Явное разрешение промисов
const elements = await driver.findElements(By.css('.item'));
const texts = await Promise.all(elements.map(el => el.getText()));
// WebdriverIO: Упрощенная асинхронная обработка
const texts = await $$('.item').map(el => el.getText());
Шаг 5: Обновить Утверждения
// Selenium с assert
const assert = require('assert');
const title = await driver.getTitle();
assert.strictEqual(title, 'Ожидаемый Заголовок');
// WebdriverIO с expect (встроенный)
await expect(browser).toHaveTitle('Ожидаемый Заголовок');
await expect($('#result')).toHaveText('Успех');
Чеклист Миграции
- Установить WebdriverIO и настроить
wdio.conf.js
- Конвертировать инициализацию драйвера в конфигурационный файл
- Обновить селекторы элементов на синтаксис WebdriverIO (
$
,$$
) - Заменить явные ожидания автоматическим ожиданием WebdriverIO
- Конвертировать page objects для использования геттеров
- Обновить утверждения на expect матчеры WebdriverIO
- Настроить репортеры (Allure, Spec и т.д.)
- Настроить интеграцию CI/CD с WebdriverIO
- Мигрировать пользовательские утилиты и хелперы
- Обновить документацию и материалы онбординга
Заключение
Расширяемость, возможности multiremote и полный набор функций WebdriverIO делают его мощным выбором для современной автоматизации тестирования. Его система пользовательских команд позволяет создавать предметно-ориентированные языки тестирования, сервисы обеспечивают бесшовную интеграцию с инфраструктурой, а multiremote открывает сценарии тестирования, невозможные с традиционными фреймворками.
Для команд, мигрирующих с Selenium WebDriver, переход требует первоначальных инвестиций в изучение паттернов WebdriverIO, но приносит значительную отдачу в поддерживаемости тестов, скорости выполнения и опыте разработчика. Активное сообщество фреймворка, обширная экосистема плагинов и отличная документация дополнительно снижают трение миграции.
Независимо от того, строите ли вы новый набор тестов или модернизируете существующий, WebdriverIO предоставляет инструменты и гибкость, необходимые для надежной, масштабируемой автоматизации тестирования.