axe-core for Mobile: Integrating Accessibility Testing into Appium Suites

axe-core for Mobile: Integrating Accessibility Testing into Appium Suites

axe-core is the most widely deployed accessibility rules engine in the world, and it works inside Appium test suites — not just on the web. By injecting axe-core into WebView contexts within hybrid and cross-platform apps, you can run the same accessibility rules in your mobile CI pipeline that your web team already uses, producing consistent WCAG-mapped violation reports.

Most accessibility testing in Appium suites is opportunistic — developers check that an element exists or has a content description, but there's no systematic audit of the entire screen. axe-core changes that. By injecting axe-core's JavaScript engine into WebView contexts, you can run 90+ accessibility rules against every screen in your app and get structured violation reports mapped to WCAG criteria.

This guide covers the full integration: setting up axe-webdriverjs with Appium, targeting WebView contexts in hybrid apps, parsing and reporting violations, writing custom mobile-specific rules, and wiring everything into CI.

When axe-core Works with Appium

axe-core is a JavaScript library that inspects the DOM. This means it works natively in:

  • Hybrid apps (React Native, Ionic, Cordova) where content renders in a WebView
  • Progressive Web Apps accessed via mobile browsers
  • Web content inside native shells (common in fintech, e-commerce, and content apps)

For fully native iOS/Android apps with no WebView content, axe-core cannot inspect the native accessibility tree directly — use platform-specific tools (XCTest performAccessibilityAudit or Espresso AccessibilityChecks) for those. Many real-world apps are hybrid, making axe-core highly applicable.

Setup: axe-webdriverjs with Appium

Install the required packages:

# JavaScript
npm install --save-dev @axe-core/webdriverjs webdriverio @wdio/appium-service

<span class="hljs-comment"># Python
pip install axe-selenium-python Appium-Python-Client

Initialize an Appium WebDriver session and attach axe:

// JavaScript — WebdriverIO + axe-core
const { remote } = require('webdriverio');
const AxeBuilder = require('@axe-core/webdriverjs').default;

async function createDriver() {
  return remote({
    hostname: 'localhost',
    port: 4723,
    capabilities: {
      platformName: 'iOS',
      'appium:deviceName': 'iPhone 15',
      'appium:platformVersion': '17.4',
      'appium:app': '/path/to/your/app.app',
      'appium:automationName': 'XCUITest',
    },
  });
}

describe('Accessibility audit', () => {
  let driver;

  before(async () => {
    driver = await createDriver();
  });

  after(async () => {
    await driver.deleteSession();
  });

  it('login screen passes axe audit', async () => {
    // Navigate to the WebView context
    const contexts = await driver.getContexts();
    const webviewContext = contexts.find(c => c.startsWith('WEBVIEW'));
    await driver.switchContext(webviewContext);

    // Run axe against the current page
    const results = await new AxeBuilder(driver)
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();

    expect(results.violations).toHaveLength(0);
  });
});

Targeting WebView Contexts in Hybrid Apps

The key challenge with Appium + hybrid apps is context switching. Appium distinguishes between NATIVE_APP context (native UI elements) and WEBVIEW_* contexts (web content). axe-core only runs in WebView contexts.

async function switchToWebView(driver) {
  // Wait for WebView to load
  await driver.waitUntil(async () => {
    const contexts = await driver.getContexts();
    return contexts.some(c => c.startsWith('WEBVIEW'));
  }, { timeout: 15000, timeoutMsg: 'WebView context did not appear within 15s' });

  const contexts = await driver.getContexts();
  console.log('Available contexts:', contexts);

  // Find the right WebView — apps with multiple WebViews need careful selection
  const webviewContexts = contexts.filter(c => c.startsWith('WEBVIEW'));

  // For React Native apps on iOS, there's typically one WebView per bridge
  const targetContext = webviewContexts[0];
  await driver.switchContext(targetContext);

  return targetContext;
}

async function runAxeAudit(driver, options = {}) {
  const context = await switchToWebView(driver);

  const builder = new AxeBuilder(driver)
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']);

  // Exclude known third-party iframes that you don't control
  if (options.exclude) {
    builder.exclude(options.exclude);
  }

  const results = await builder.analyze();

  // Switch back to native context after audit
  await driver.switchContext('NATIVE_APP');

  return { results, context };
}

For Android, the WebView context name includes the package name:

// Android WebView context example:
// "WEBVIEW_com.example.myapp"
const webviewContext = contexts.find(c =>
  c.startsWith('WEBVIEW_com.example.myapp')
);

Python Implementation with Appium-Python-Client

from appium import webdriver
from appium.options import XCUITestOptions
from axe_selenium_python import Axe
import json

def create_driver():
    options = XCUITestOptions()
    options.platform_name = 'iOS'
    options.device_name = 'iPhone 15'
    options.platform_version = '17.4'
    options.app = '/path/to/your/app.app'

    return webdriver.Remote('http://localhost:4723', options=options)

def switch_to_webview(driver):
    contexts = driver.contexts
    webview = next((c for c in contexts if c.startswith('WEBVIEW')), None)
    if not webview:
        raise RuntimeError(f"No WebView context found. Available: {contexts}")
    driver.switch_to.context(webview)
    return webview

def run_axe_audit(driver, screen_name):
    switch_to_webview(driver)

    axe = Axe(driver)
    axe.inject()  # Injects axe-core JS into the WebView

    results = axe.run(options={
        'runOnly': {
            'type': 'tag',
            'values': ['wcag2a', 'wcag2aa', 'wcag21aa']
        }
    })

    # Switch back to native
    driver.switch_to.context('NATIVE_APP')

    return results

def test_checkout_screen_accessibility():
    driver = create_driver()
    try:
        # Navigate to checkout
        driver.find_element('accessibility id', 'Cart').click()
        driver.find_element('accessibility id', 'Proceed to Checkout').click()

        results = run_axe_audit(driver, 'checkout')

        assert len(results['violations']) == 0, \
            format_violations(results['violations'])
    finally:
        driver.quit()

def format_violations(violations):
    lines = ['Accessibility violations found:']
    for v in violations:
        lines.append(f"\n[{v['impact'].upper()}] {v['id']}: {v['description']}")
        lines.append(f"  WCAG: {', '.join(t['id'] for t in v['tags'] if 'wcag' in t['id'])}")
        for node in v['nodes'][:3]:  # Show first 3 failing nodes
            lines.append(f"  Element: {node['target']}")
            lines.append(f"  Fix: {node['failureSummary']}")
    return '\n'.join(lines)

Parsing axe Violations and Mapping to WCAG

axe violations have a structured format that maps directly to WCAG criteria:

function parseViolations(results) {
  const wcagMap = {};

  for (const violation of results.violations) {
    // Extract WCAG criterion references from tags
    const wcagTags = violation.tags.filter(tag =>
      tag.startsWith('wcag') && /wcag\d+/.test(tag)
    );

    const level = violation.tags.includes('wcag2aa') ? 'AA' :
                  violation.tags.includes('wcag2a') ? 'A' : 'AAA';

    const record = {
      ruleId: violation.id,
      description: violation.description,
      impact: violation.impact, // 'critical', 'serious', 'moderate', 'minor'
      wcagCriteria: wcagTags,
      conformanceLevel: level,
      affectedElements: violation.nodes.map(node => ({
        selector: node.target.join(', '),
        html: node.html,
        fix: node.failureSummary,
      })),
      helpUrl: violation.helpUrl,
    };

    for (const tag of wcagTags) {
      if (!wcagMap[tag]) wcagMap[tag] = [];
      wcagMap[tag].push(record);
    }
  }

  return {
    summary: {
      total: results.violations.length,
      critical: results.violations.filter(v => v.impact === 'critical').length,
      serious: results.violations.filter(v => v.impact === 'serious').length,
      byLevel: {
        A: Object.entries(wcagMap)
          .filter(([k]) => k.includes('wcag2a') && !k.includes('aa'))
          .reduce((sum, [, v]) => sum + v.length, 0),
        AA: Object.entries(wcagMap)
          .filter(([k]) => k.includes('aa'))
          .reduce((sum, [, v]) => sum + v.length, 0),
      },
    },
    byWcagCriterion: wcagMap,
    violations: results.violations,
    passes: results.passes.length,
    incomplete: results.incomplete.length,
  };
}

Custom Rules for Mobile-Specific Checks

axe-core's rule engine is extensible. Add mobile-specific rules that the default set doesn't cover:

const { Axe } = require('axe-core');

// Custom rule: ensure mobile tap targets meet 44px minimum
const mobileTapTargetRule = {
  id: 'mobile-tap-target',
  selector: 'button, a, [role="button"], input, select, textarea',
  tags: ['mobile', 'wcag255'],
  metadata: {
    description: 'Interactive elements must have a minimum tap target of 44x44 CSS pixels',
    help: 'Increase padding or element size to meet minimum tap target requirements',
    helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/target-size.html',
  },
  all: [],
  any: [{
    id: 'mobile-tap-target-size',
    evaluate: function(node) {
      const rect = node.getBoundingClientRect();
      const minSize = 44;

      if (rect.width < minSize || rect.height < minSize) {
        this.data({
          width: Math.round(rect.width),
          height: Math.round(rect.height),
          minSize,
        });
        return false;
      }
      return true;
    },
    metadata: {
      impact: 'serious',
      messages: {
        pass: 'Element has sufficient tap target size',
        fail: ({
          data: { width, height, minSize }
        }) => `Tap target is ${width}x${height}px, minimum is ${minSize}x${minSize}px`,
      },
    },
  }],
  none: [],
};

// Register the custom rule
const axeCore = require('axe-core');
axeCore.configure({ rules: [mobileTapTargetRule] });

// Use in Appium test
const results = await new AxeBuilder(driver)
  .withTags(['wcag2a', 'wcag2aa', 'mobile'])
  .analyze();

Accessibility Result Reporting in CI

Structure your CI reports to be actionable. Write results to JUnit XML for test runner integration and JSON for archiving:

const { writeFileSync } = require('fs');
const { create } = require('xmlbuilder2');

function writeJUnitReport(screenResults, outputPath) {
  const root = create({ version: '1.0' })
    .ele('testsuites', { name: 'Accessibility Audit' });

  for (const { screen, results } of screenResults) {
    const suite = root.ele('testsuite', {
      name: `Accessibility: ${screen}`,
      tests: results.violations.length + results.passes.length,
      failures: results.violations.length,
    });

    // One test case per violation
    for (const violation of results.violations) {
      suite.ele('testcase', {
        classname: `a11y.${screen}`,
        name: `${violation.id}: ${violation.description}`,
      }).ele('failure', {
        message: `${violation.impact} - ${violation.nodes.length} element(s) affected`,
        type: violation.id,
      }).txt(
        violation.nodes.map(n =>
          `${n.target.join(', ')}: ${n.failureSummary}`
        ).join('\n')
      );
    }

    // One passing test case summarizing passes
    if (results.passes.length > 0) {
      suite.ele('testcase', {
        classname: `a11y.${screen}`,
        name: `${results.passes.length} rules passed`,
      });
    }
  }

  writeFileSync(outputPath, root.end({ prettyPrint: true }));
}

// In CI, run and report
async function runAccessibilitySuite() {
  const screens = ['login', 'home', 'product', 'checkout', 'confirmation'];
  const screenResults = [];

  for (const screen of screens) {
    await navigateToScreen(driver, screen);
    const { results } = await runAxeAudit(driver, screen);
    screenResults.push({ screen, results });

    // Log immediately for CI output
    console.log(`${screen}: ${results.violations.length} violations, ${results.passes.length} passes`);
  }

  writeJUnitReport(screenResults, 'reports/accessibility-junit.xml');
  writeFileSync(
    'reports/accessibility-full.json',
    JSON.stringify(screenResults, null, 2)
  );

  const totalViolations = screenResults.reduce((sum, { results }) =>
    sum + results.violations.length, 0
  );

  if (totalViolations > 0) {
    process.exit(1); // Fail CI on violations
  }
}

Combining Appium Native Assertions with axe Scans

The most thorough approach combines native accessibility assertions (checking accessibility id, content description) with axe scans of WebView content:

it('product screen is fully accessible', async () => {
  // Navigate
  await driver.switchContext('NATIVE_APP');
  await driver.$('~Products').click();
  await driver.$('~Blue Running Shoes').click();

  // 1. Native assertion — check the native layer is correct
  const addToCartButton = await driver.$('~Add to Cart');
  expect(await addToCartButton.isDisplayed()).toBe(true);
  expect(await addToCartButton.getAttribute('name')).toBe('Add to Cart');

  // 2. Switch to WebView and run axe
  const { results } = await runAxeAudit(driver, 'product-detail');

  // 3. Assert no violations
  const criticalViolations = results.violations.filter(
    v => v.impact === 'critical' || v.impact === 'serious'
  );

  expect(criticalViolations).toHaveLength(0);

  // 4. Log non-critical issues as warnings (don't fail the build)
  const warnings = results.violations.filter(
    v => v.impact === 'moderate' || v.impact === 'minor'
  );
  if (warnings.length > 0) {
    console.warn(`${warnings.length} moderate/minor accessibility issues on product screen`);
  }
});

This tiered approach — fail on critical and serious violations, warn on moderate and minor — gives teams a practical path to improving accessibility without blocking every deployment on non-critical issues.

Teams using HelpMeTest can configure accessibility scan thresholds per screen and receive alerts when violation counts change, making it easy to track accessibility improvement over time without adding manual review steps to every PR.

Integrating axe-core into your Appium suite is one of the highest-leverage accessibility investments you can make for hybrid mobile apps. You get the same rules engine used by enterprise accessibility teams worldwide, running automatically in CI, producing reports that map directly to the WCAG criteria your legal and compliance teams care about.

Read more