WebdriverIO with Mocha: Writing Your First Test Suite
WebdriverIO ships with Mocha as its default test framework, and the combination is a natural fit. Mocha's describe/it structure keeps tests readable, its hooks give you fine-grained control over setup and teardown, and its async support pairs cleanly with WebdriverIO's promise-based API. This guide covers how to write well-structured test suites using this combination.
Why Mocha?
WebdriverIO supports Mocha, Jasmine, and Cucumber. Mocha is the default for good reasons:
- Familiar syntax —
describe,it,before,afterare universal JavaScript conventions - Flexible — no opinions about assertions (pair with Chai, expect, or WebdriverIO's built-in matchers)
- Async-first — async/await works naturally
- Widely supported — strong ecosystem of plugins and reporters
If your team already uses Mocha for unit tests, using it for E2E tests creates a consistent experience across the test pyramid.
Initial Setup
If you haven't set up WebdriverIO yet, run the wizard and select Mocha when prompted:
npx wdio setup
# Select: Mocha as frameworkOr if adding Mocha to an existing WebdriverIO project:
npm install @wdio/mocha-framework --save-devUpdate wdio.conf.js:
export const config = {
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 60000,
require: ['./test/helpers/setup.js'], // optional global helpers
},
// ... other config
};Test Structure
A well-organized test file follows this pattern:
// test/specs/checkout.e2e.js
describe('Checkout flow', () => {
before(async () => {
// Runs once before this describe block
// Good for: logging in, seeding test data
await browser.url('/');
await loginAsTestUser();
});
after(async () => {
// Runs once after this describe block
// Good for: cleanup, logging out
await cleanupTestData();
});
beforeEach(async () => {
// Runs before each `it` block
// Good for: navigating to a fresh page state
await browser.url('/shop');
});
afterEach(async function () {
// `this` refers to the Mocha test context
// Good for: capturing screenshots on failure
if (this.currentTest.state === 'failed') {
const testName = this.currentTest.title.replace(/\s+/g, '-');
await browser.saveScreenshot(`./screenshots/${testName}.png`);
}
});
it('should add a product to cart', async () => {
await $('[data-testid="product-card"]').click();
await $('[data-testid="add-to-cart"]').click();
const cartCount = await $('[data-testid="cart-count"]').getText();
expect(parseInt(cartCount)).toBe(1);
});
it('should proceed to checkout', async () => {
// Add item first
await $('[data-testid="product-card"]').click();
await $('[data-testid="add-to-cart"]').click();
// Navigate to cart
await $('[data-testid="cart-icon"]').click();
await $('[data-testid="checkout-button"]').click();
// Verify checkout page
expect(await browser.getUrl()).toContain('/checkout');
await expect($('h1')).toHaveTextContaining('Checkout');
});
describe('Payment step', () => {
// Nested describe for grouping related tests
beforeEach(async () => {
// Extra setup specific to payment tests
await addItemToCart();
await navigateToCheckout();
});
it('should display payment form', async () => {
const cardInput = await $('[data-testid="card-number"]');
await expect(cardInput).toBeDisplayed();
});
it('should reject invalid card numbers', async () => {
await $('[data-testid="card-number"]').setValue('1234 5678 9012 3456');
await $('[data-testid="pay-button"]').click();
const error = await $('[data-testid="card-error"]');
await expect(error).toBeDisplayed();
await expect(error).toHaveTextContaining('Invalid card number');
});
});
});Hooks in Depth
Mocha provides four hooks, and WebdriverIO adds its own layer. Understanding when each runs is essential:
Mocha Hooks (Test-Level)
describe('Suite', () => {
before(async () => {
// Once per describe block — good for expensive setup like logging in
console.log('Starting suite');
});
after(async () => {
// Once per describe block — clean up what before() created
console.log('Suite finished');
});
beforeEach(async () => {
// Before every it() — good for resetting state
await browser.url('/start');
});
afterEach(async () => {
// After every it() — good for screenshots, cleanup
});
});WebdriverIO Hooks (Runner-Level)
In wdio.conf.js, WebdriverIO provides higher-level hooks:
export const config = {
// Before/after all specs
onPrepare: function (config, capabilities) {
// Runs once before any browser opens
// Good for: starting test servers, generating test data
},
onComplete: function (exitCode, config, capabilities) {
// Runs after all tests complete
// Good for: sending notifications, archiving results
},
// Before/after each spec file
before: async function (capabilities, specs) {
// Browser is open, no tests run yet
// Global setup: inject helpers, set cookies
},
after: async function (result, capabilities, specs) {
// All tests in spec file done
},
// Before/after each test
beforeTest: async function (test, context) {
// Runs before each Mocha `it()`
console.log(`Starting: ${test.title}`);
},
afterTest: async function (test, context, { error, result, duration, passed }) {
if (!passed) {
await browser.saveScreenshot(`./screenshots/failure-${Date.now()}.png`);
}
},
};Assertions
WebdriverIO includes expect-webdriverio, a powerful assertion library built specifically for browser automation. You don't need Chai or other assertion libraries unless you prefer them.
Element Assertions
const button = await $('button.submit');
// Visibility
await expect(button).toBeDisplayed();
await expect(button).not.toBeDisplayed();
await expect(button).toBeVisible();
// State
await expect(button).toBeEnabled();
await expect(button).toBeDisabled();
await expect($('input[type="checkbox"]')).toBeChecked();
await expect($('input[type="checkbox"]')).not.toBeChecked();
// Text
await expect(button).toHaveText('Submit');
await expect($('h1')).toHaveTextContaining('Welcome');
// Attributes
await expect($('a')).toHaveAttribute('href', '/home');
await expect($('img')).toHaveAttribute('src');
// CSS
await expect($('.error')).toHaveElementClass('visible');
// Input value
await expect($('#email')).toHaveValue('test@example.com');Browser Assertions
// URL
await expect(browser).toHaveUrl('https://example.com/dashboard');
await expect(browser).toHaveUrlContaining('/dashboard');
// Title
await expect(browser).toHaveTitle('My Application - Dashboard');
await expect(browser).toHaveTitleContaining('Dashboard');Async Matchers
WebdriverIO matchers are async-aware — you don't need to await the element value yourself:
// These work without manually awaiting element properties
await expect($('.count')).toHaveText('42');
// Instead of this (also works but verbose):
const text = await $('.count').getText();
expect(text).toBe('42');Helper Functions
As your test suite grows, extract reusable actions into helpers:
// test/helpers/auth.js
export async function loginAs(username, password = 'testpass123') {
await browser.url('/login');
await $('#username').setValue(username);
await $('#password').setValue(password);
await $('button[type="submit"]').click();
// Wait for redirect
await browser.waitUntil(
async () => (await browser.getUrl()).includes('/dashboard'),
{ timeout: 5000, timeoutMsg: 'Login did not redirect to dashboard' }
);
}
export async function logout() {
await $('[data-testid="user-menu"]').click();
await $('[data-testid="logout-button"]').click();
await browser.waitUntil(
async () => (await browser.getUrl()).includes('/login'),
{ timeout: 3000 }
);
}// test/helpers/cart.js
export async function addItemToCart(productIndex = 0) {
const products = await $$('[data-testid="product-card"]');
await products[productIndex].click();
await $('[data-testid="add-to-cart"]').click();
// Wait for cart to update
await browser.waitUntil(async () => {
const count = await $('[data-testid="cart-count"]').getText();
return parseInt(count) > 0;
}, { timeout: 3000 });
}Use helpers in tests:
import { loginAs } from '../helpers/auth.js';
import { addItemToCart } from '../helpers/cart.js';
describe('Cart management', () => {
before(async () => {
await loginAs('testuser@example.com');
});
it('should persist cart after page reload', async () => {
await addItemToCart();
await browser.refresh();
const cartCount = await $('[data-testid="cart-count"]').getText();
expect(parseInt(cartCount)).toBe(1);
});
});Organizing Test Files
Structure tests by feature area, not by page:
test/
├── specs/
│ ├── auth/
│ │ ├── login.e2e.js
│ │ ├── register.e2e.js
│ │ └── password-reset.e2e.js
│ ├── checkout/
│ │ ├── cart.e2e.js
│ │ ├── payment.e2e.js
│ │ └── order-confirmation.e2e.js
│ ├── search/
│ │ ├── filters.e2e.js
│ │ └── results.e2e.js
│ └── smoke/
│ └── critical-path.e2e.js
├── helpers/
│ ├── auth.js
│ ├── cart.js
│ └── navigation.js
└── pageobjects/
├── LoginPage.js
└── CheckoutPage.jsReference specific spec files on the command line:
# Run only auth tests
npx wdio run wdio.conf.js --spec <span class="hljs-built_in">test/specs/auth/*.e2e.js
<span class="hljs-comment"># Run smoke tests
npx wdio run wdio.conf.js --spec <span class="hljs-built_in">test/specs/smoke/
<span class="hljs-comment"># Run a single file
npx wdio run wdio.conf.js --spec <span class="hljs-built_in">test/specs/checkout/payment.e2e.jsHandling Async Correctly
A common mistake in WebdriverIO with Mocha is forgetting to await:
// WRONG — test passes immediately without waiting
it('should show error', async () => {
$('button').click(); // not awaited — continues immediately
expect($('.error').isDisplayed()).toBe(true); // also not awaited
});
// CORRECT
it('should show error', async () => {
await $('button').click();
await expect($('.error')).toBeDisplayed();
});WebdriverIO's expect-webdriverio matchers handle their own awaiting internally, but native element API calls need await.
Running Tests in Parallel
By default, tests run serially. Enable parallel execution in wdio.conf.js:
export const config = {
maxInstances: 4, // Run 4 browser instances simultaneously
// WebdriverIO distributes spec files across instances
specs: ['./test/specs/**/*.e2e.js'],
};For meaningful parallelism, each spec file should be independent — no shared state between files.
Reporting
The default spec reporter outputs results to the terminal. Add HTML reports for CI:
npm install @wdio/allure-reporter --save-dev// wdio.conf.js
reporters: [
'spec',
['allure', {
outputDir: 'allure-results',
disableWebdriverStepsReporting: true,
}],
],Generate the report after tests:
npx allure generate allure-results --clean
npx allure openSummary
WebdriverIO with Mocha provides a solid foundation for browser automation test suites. The combination gives you familiar JavaScript patterns, flexible hooks, powerful built-in assertions, and a clear organization model that scales from small projects to enterprise test suites.
The key patterns to internalize:
- Use
before/afterfor expensive setup shared across a suite - Use
beforeEach/afterEachfor per-test state reset and cleanup - Extract helper functions early — they pay dividends as suites grow
- Keep spec files independent for reliable parallel execution
- Always
awaitelement interactions and assertions
For teams looking to go further, the Page Object Model pattern provides the structure needed for large-scale test suites, and integrating with CI/CD pipelines ensures tests run automatically on every code change.