Capacitor End-to-End Testing with Detox and Appium: Hybrid App E2E Strategies

Capacitor End-to-End Testing with Detox and Appium: Hybrid App E2E Strategies

End-to-end testing a Capacitor application is fundamentally different from testing a pure native app. Your app is a native shell containing a WebView. User interactions flow from native gesture recognizers into the WebView's JavaScript runtime and back out through the Capacitor plugin bridge. Both Detox and Appium can handle this architecture, but each has different strengths and requires different configuration to work with Capacitor's hybrid model.

This guide covers Detox for React-based Capacitor apps and Appium for Angular and Vue-based Capacitor apps, including the critical WebView context switching technique that makes automated interaction with hybrid app content possible.

Why Capacitor E2E Testing Is Different

A pure native app test driver interacts with native accessibility trees. Your button is a UIButton on iOS or a android.widget.Button on Android — the test framework sees it directly.

In a Capacitor app, the UI is rendered inside a WKWebView (iOS) or WebView (Android). The native accessibility tree shows one node: the WebView container. Your React or Angular components live inside that container, invisible to naive native testing approaches.

Getting E2E tests to work requires:

  1. Locating the WebView context within the app
  2. Switching the test driver's focus into that context
  3. Using web-based selectors (CSS, XPath, accessibility IDs) inside the WebView
  4. Switching back to native context when interacting with native UI (status bars, system permissions dialogs, native keyboards)

Detox for Capacitor React Apps

Detox is Gray Box testing — it integrates with the iOS and Android build systems to synchronize test execution with app state. This eliminates flakiness caused by arbitrary sleep() calls.

Building the Capacitor App for Detox

Detox needs a debug build of your native app with the Detox instrumentation layer included.

# Build the web layer first
npm run build

<span class="hljs-comment"># Sync to native platforms
npx <span class="hljs-built_in">cap <span class="hljs-built_in">sync ios
npx <span class="hljs-built_in">cap <span class="hljs-built_in">sync android

<span class="hljs-comment"># Build iOS debug binary for Detox (requires Xcode)
<span class="hljs-built_in">cd ios && xcodebuild -workspace App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -sdk iphonesimulator \
  -derivedDataPath ./build \
  <span class="hljs-pipe">| xcbeautify

<span class="hljs-comment"># Android: build debug APK
<span class="hljs-built_in">cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug

.detoxrc.js Configuration

// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
  testRunner: {
    args: {
      '$0': 'jest',
      config: 'e2e/jest.config.js',
    },
    jest: {
      setupTimeout: 120000,
    },
  },
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/App.app',
      build: 'cd ios && xcodebuild -workspace App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator -derivedDataPath ./build',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: {
        type: 'iPhone 15',
      },
    },
    emulator: {
      type: 'android.emulator',
      device: {
        avdName: 'Pixel_6_API_33',
      },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
  },
};

Writing Detox Tests for Capacitor Content

The challenge with Detox and Capacitor is that Detox's default element(by.id()) matcher looks at native accessibility IDs, not web element IDs. For web content inside the WebView, you need to use Detox's web testing API:

// e2e/login.test.js
describe('Login flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
  });

  beforeEach(async () => {
    await device.reloadReactNative(); // or device.launchApp({ delete: false })
  });

  it('logs in with valid credentials', async () => {
    // For Capacitor, content lives in the WebView
    // Use web() API to interact with it
    const webView = web(by.type('WKWebView'));

    await webView.element(by.web.id('email-input')).typeText('user@example.com');
    await webView.element(by.web.id('password-input')).typeText('password123');
    await webView.element(by.web.id('login-button')).tap();

    // After login, check that the dashboard loads
    await webView.element(by.web.id('dashboard-header')).waitToBeVisible(5000);
    await expect(webView.element(by.web.id('dashboard-header'))).toBeVisible();
  });

  it('shows error on invalid credentials', async () => {
    const webView = web(by.type('WKWebView'));

    await webView.element(by.web.id('email-input')).typeText('bad@example.com');
    await webView.element(by.web.id('password-input')).typeText('wrongpassword');
    await webView.element(by.web.id('login-button')).tap();

    await webView.element(by.web.id('error-message')).waitToBeVisible(3000);
    const errorText = await webView.element(by.web.id('error-message')).getText();
    expect(errorText).toContain('Invalid credentials');
  });

  it('handles native permission dialog for camera', async () => {
    const webView = web(by.type('WKWebView'));

    // Tap the photo upload button (inside WebView)
    await webView.element(by.web.id('upload-photo-btn')).tap();

    // Switch to native context to handle the iOS permission dialog
    await device.disableSynchronization();
    const permissionAlert = element(by.type('_UIAlertControllerView'));
    if (await permissionAlert.isVisible()) {
      await element(by.label('Allow')).tap();
    }
    await device.enableSynchronization();

    // Back to WebView to assert the photo picker appeared
    await webView.element(by.web.id('photo-picker')).waitToBeVisible(3000);
  });
});

Appium for Capacitor Angular and Vue Apps

Appium uses the WebDriver protocol and can drive any app — native, hybrid, or web. For Capacitor apps built with Angular or Vue, Appium with the appium-webdriverio setup is the most flexible option because it supports hybrid context switching explicitly.

Appium Setup

npm install --save-dev webdriverio @wdio/cli @wdio/mocha-framework @wdio/local-runner
npm install --save-dev appium appium-driver-cache
// wdio.conf.js
exports.config = {
  runner: 'local',
  framework: 'mocha',
  reporters: ['spec'],
  mochaOpts: {
    timeout: 120000,
  },

  capabilities: [{
    platformName: 'Android',
    'appium:deviceName': 'Pixel_6_API_33',
    'appium:app': './android/app/build/outputs/apk/debug/app-debug.apk',
    'appium:automationName': 'UiAutomator2',
    'appium:appPackage': 'com.example.myapp',
    'appium:appActivity': 'com.example.myapp.MainActivity',
    'appium:noReset': false,
    // Required for WebView access
    'appium:chromedriverExecutableDir': './chromedriver/',
  }],

  services: ['appium'],
  port: 4723,
};

Context Switching — The Core Hybrid Testing Pattern

This is the most important concept for Capacitor Appium testing. All web content lives in a WEBVIEW_* context; native UI (status bars, system dialogs, bottom navigation) lives in NATIVE_APP.

// e2e/helpers/context.js

/**
 * Switch to the Capacitor WebView context.
 * The WebView context name includes the app package on Android.
 */
async function switchToWebView(driver) {
  // Wait for the WebView to be available
  await driver.pause(2000);

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

  // Find the WebView context — usually 'WEBVIEW_com.example.myapp'
  const webViewContext = contexts.find(ctx =>
    ctx.toString().includes('WEBVIEW') && ctx.toString() !== 'NATIVE_APP'
  );

  if (!webViewContext) {
    throw new Error(`No WebView context found. Available: ${contexts.join(', ')}`);
  }

  await driver.switchContext(webViewContext);
  console.log('Switched to:', webViewContext);
}

async function switchToNative(driver) {
  await driver.switchContext('NATIVE_APP');
}

module.exports = { switchToWebView, switchToNative };

Writing Appium Tests with Context Switching

// e2e/tests/product-flow.test.js
const { switchToWebView, switchToNative } = require('../helpers/context');

describe('Product search and checkout', () => {
  it('searches for a product and adds it to cart', async () => {
    // Start in native context — app just launched
    await switchToWebView(driver);

    // Now we're in the WebView — use standard web selectors
    const searchInput = await driver.$('[data-cy="search-input"]');
    await searchInput.waitForDisplayed({ timeout: 10000 });
    await searchInput.setValue('running shoes');

    const searchButton = await driver.$('[data-cy="search-submit"]');
    await searchButton.click();

    // Wait for results to load
    const firstResult = await driver.$('[data-cy="product-card"]:first-child');
    await firstResult.waitForDisplayed({ timeout: 8000 });
    await firstResult.click();

    // Product detail page
    const addToCartBtn = await driver.$('[data-cy="add-to-cart"]');
    await addToCartBtn.waitForDisplayed({ timeout: 5000 });
    await addToCartBtn.click();

    // Verify cart badge updated
    const cartBadge = await driver.$('[data-cy="cart-count"]');
    const cartCount = await cartBadge.getText();
    expect(parseInt(cartCount)).toBeGreaterThan(0);
  });

  it('handles native share dialog when sharing a product', async () => {
    await switchToWebView(driver);

    const shareBtn = await driver.$('[data-cy="share-product"]');
    await shareBtn.waitForDisplayed({ timeout: 5000 });
    await shareBtn.click();

    // On Android, the native share sheet is a native UI element
    await switchToNative(driver);

    // Wait for native share sheet
    const shareSheet = await driver.$('//android.widget.ListView[@resource-id="android:id/resolver_list"]');
    await shareSheet.waitForDisplayed({ timeout: 5000 });
    expect(await shareSheet.isDisplayed()).toBe(true);

    // Dismiss the share sheet
    await driver.back();

    // Return to web context
    await switchToWebView(driver);
  });
});

iOS WebView Testing with Appium

iOS requires additional configuration in the Xcode project to allow Appium to inspect the WebView:

// wdio.conf.ios.js — additional iOS capabilities
capabilities: [{
  platformName: 'iOS',
  'appium:deviceName': 'iPhone 15',
  'appium:platformVersion': '17.0',
  'appium:app': './ios/build/Build/Products/Debug-iphonesimulator/App.app',
  'appium:automationName': 'XCUITest',
  // Required for WebView access on iOS
  'appium:safariAllowPopups': true,
  'appium:includeSafariInWebviews': true,
  'appium:webviewConnectTimeout': 20000,
}]
// iOS-specific context switching
async function switchToWebViewiOS(driver) {
  await driver.pause(3000); // WKWebView takes longer to register on iOS

  const contexts = await driver.getContexts();
  // iOS contexts look like 'WEBVIEW_1234.1' (process ID based)
  const webViewContext = contexts.find(ctx =>
    ctx.toString().startsWith('WEBVIEW_') && ctx.toString() !== 'NATIVE_APP'
  );

  if (!webViewContext) {
    throw new Error(`No iOS WebView context. Contexts: ${JSON.stringify(contexts)}`);
  }

  await driver.switchContext(webViewContext);
}

Platform-Specific Test Branches

Some behavior genuinely differs between iOS and Android — handle it in tests:

// e2e/helpers/platform.js
function isAndroid(driver) {
  return driver.isAndroid;
}

function isIOS(driver) {
  return driver.isIOS;
}

// In your test:
it('handles back navigation correctly', async () => {
  await switchToWebView(driver);

  const settingsLink = await driver.$('[data-cy="open-settings"]');
  await settingsLink.click();

  if (isAndroid(driver)) {
    // Android has hardware back button
    await switchToNative(driver);
    await driver.back();
    await switchToWebView(driver);
  } else {
    // iOS needs a back button in the web UI
    const backBtn = await driver.$('[data-cy="back-button"]');
    await backBtn.click();
  }

  const homeScreen = await driver.$('[data-cy="home-header"]');
  await homeScreen.waitForDisplayed({ timeout: 5000 });
  expect(await homeScreen.isDisplayed()).toBe(true);
});

CI/CD Setup for Detox and Appium

GitHub Actions — Detox iOS

# .github/workflows/e2e-ios.yml
name: E2E iOS Tests
on: [push]

jobs:
  e2e-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Build web layer
        run: npm run build

      - name: Sync Capacitor
        run: npx cap sync ios

      - name: Build iOS app for Detox
        run: detox build --configuration ios.sim.debug

      - name: Run Detox tests
        run: detox test --configuration ios.sim.debug --cleanup --headless

GitHub Actions — Appium Android

# .github/workflows/e2e-android.yml
name: E2E Android Tests
on: [push]

jobs:
  e2e-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Start Android emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          target: google_apis
          arch: x86_64
          script: |
            npm ci
            npm run build
            npx cap sync android
            cd android && ./gradlew assembleDebug
            cd ..
            npx wdio wdio.conf.js

Both Detox and Appium have real-world trade-offs. Detox is faster and more reliable due to its gray-box synchronization, but it requires React and is iOS-first. Appium works with any framework and supports both platforms equally, but requires more careful handling of timing and context switching. For most Capacitor teams, Appium is the more practical choice due to its framework agnosticism.


HelpMeTest can run your Capacitor E2E tests on real Android devices automatically, giving you pass/fail results on every release without maintaining your own device farm.

Read more