WebdriverIO with Mocha: Writing Your First Test Suite

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 syntaxdescribe, it, before, after are 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 framework

Or if adding Mocha to an existing WebdriverIO project:

npm install @wdio/mocha-framework --save-dev

Update 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.js

Reference 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.js

Handling 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 open

Summary

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:

  1. Use before/after for expensive setup shared across a suite
  2. Use beforeEach/afterEach for per-test state reset and cleanup
  3. Extract helper functions early — they pay dividends as suites grow
  4. Keep spec files independent for reliable parallel execution
  5. Always await element 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.

Read more