Testing Capacitor Apps and Plugins
A Capacitor app has three layers to test: the web app (most logic), the native bridge and plugins (Swift/Kotlin), and the running app on a device. Pick the cheapest layer that can catch the bug.
When to Use
TRIGGER when:
- Adding unit, component or E2E tests to a Capacitor app
- Mocking Capacitor or plugin APIs in Vitest / Jest
- Writing or modernizing native unit tests for a plugin (Swift Testing, XCTest, JUnit)
- Verifying a change on a simulator, emulator or device ("test this", "does it work", "check on device")
- E2E tests that cannot find web elements in the app (WebView context)
- Setting up test jobs in CI
Do not use:
- Reading device or crash logs:
ios-android-logs - Safari Web Inspector / Chrome DevTools debugging:
debugging-capacitor - Build, signing and release pipelines:
capacitor-ci-cd - App Intents tests:
capacitor-app-intents(AppIntentsTesting)
Choose the layer
| What changed | Test with | Reference |
|---|---|---|
| Business logic, state, services that call plugins | Vitest with mocked plugins | references/web-unit-tests.md |
| Components (React, Vue, Angular, Svelte) | Testing Library on Vitest | references/web-unit-tests.md |
| Web flows (routing, forms) | Playwright against the dev server or dist | references/e2e.md |
| Plugin native code (Swift / Kotlin) | Swift Testing or XCTest; JUnit | references/native-plugin-tests.md |
| Full app on device, native UI, permissions, deep links | Agent verification loop; Appium or Maestro for repeatable suites | references/device-verification-loop.md, references/e2e.md |
| CI jobs | GitHub Actions matrix | references/ci.md |
Only load a reference when its layer is in play.
Facts that save time
- Plugins in jsdom run their web implementation, or throw
unimplementedwhen there is none.Capacitor.isNativePlatform()returnsfalsein tests unless you mock@capacitor/core. Mock at the plugin package boundary, not deep inside the app. - E2E tools see the WebView as one native element. Appium must switch to the
WEBVIEW_*context to find DOM elements. Capacitor makes the WebView inspectable in debug builds; for release builds setios.webContentsDebuggingEnabled/android.webContentsDebuggingEnabledincapacitor.config. Never ship release builds with it on by accident. - Detox is not an option. It targets React Native and does not drive Capacitor WebView content.
- Swift Testing cannot replace all XCTest. UI tests (XCUIApplication) and
measure {}performance tests stay XCTest. Unit tests can migrate one class at a time; a file can hold both. - iOS-only plugin packages cannot use
swift testbecause they depend on UIKit and Capacitor. Runxcodebuild test -scheme <Package> -destination 'platform=iOS Simulator,...'. - Capacitor 9 removed the deprecated Swift APIs. Tests that build calls with the old
CAPPluginCall(callbackId:options:success:error:)initializer orCAPPlugin(bridge:pluginId:pluginName:)must move toCAPPluginCall(callbackId:methodName:options:success:error:)and a plaininit(). - Live reload flags changed in Capacitor 9. Capacitor 9:
npx cap run ios --url http://<lan-ip>:5173. Capacitor 8:npx cap run ios -l --host <lan-ip> --port 5173. The Ionic CLI'sionic cap run ios -l --externalis a different tool.
Workflow
- Inspect. Read
package.json(test runner, framework,@capacitor/*versions),capacitor.config.*, existing test config (vitest.config.*,playwright.config.*,wdio.conf.*,.maestro/), and for pluginsPackage.swift, the podspec,ios/Tests/,android/src/test/. - Ask which layer the user wants if the request is general. Suggest the cheapest one that covers the risk.
- Write tests using the matching reference. Follow the project's existing runner and style; do not add a second runner.
- Run them and show the output. A test that was never run is not done.
- For UI-affecting changes, run the device verification loop (references/device-verification-loop.md) and report screenshots and findings.
Verify
# Web layer
npx vitest run
npx playwright test
# Plugin, iOS (from the plugin repo)
xcodebuild test -scheme <PluginPackageName> \
-destination "platform=iOS Simulator,name=$(xcrun simctl list devices available | grep -m1 -o 'iPhone [^(]*' | sed 's/ *$//')"
# Plugin, Android (from the plugin repo)
cd android && ./gradlew test
# App native tests, if the app has a test target
xcodebuild test -project ios/App/App.xcodeproj -scheme App -destination 'platform=iOS Simulator,name=<device>'
# CocoaPods projects: -workspace ios/App/App.xcworkspace
Report: tests added, command run, pass/fail counts, and anything that could not run locally (no simulator, no device, missing Android SDK).
Error Handling
| Symptom | Cause | Fix |
|---|---|---|
| "<Plugin>" plugin is not implemented on web in Vitest | Real plugin running in jsdom | vi.mock('<plugin package>') in the setup file |
| Mock ignored, real plugin still called | Mock path differs from the import path, or the mock is registered after import | Mock the exact specifier the app imports. Put vi.mock in setupFiles or at the top of the test file |
| Appium cannot find data-testid elements | Still in the NATIVE_APP context | await driver.switchContext(webviewContext) after waiting for getContexts() to list a WEBVIEW_* entry |
| No WEBVIEW_* context | WebView not inspectable (release build) | Use a debug build, or enable webContentsDebuggingEnabled for the test build only |
| Android WebView context fails with a chromedriver version error | Chromedriver does not match the device WebView | Enable Appium's chromedriver autodownload (an insecure feature flag) or pin a matching chromedriver; check the Appium UiAutomator2 docs |
| No such module 'Testing' | Old Xcode | Swift Testing needs the Xcode 16+ toolchain. Capacitor 9 needs Xcode 27 anyway |
| error: no such module 'UIKit' from swift test | macOS host build of an iOS package | Use xcodebuild test with an iOS Simulator destination |
| 'init(callbackId:options:success:error:)' is deprecated / unavailable | Old test helper | Add methodName: |
| Live reload: error: unknown option '-l' | Capacitor 9 CLI | Use --url http://<lan-ip>:<port> |
| Live reload app shows a blank page | Dev server bound to localhost, or wrong IP | Bind to 0.0.0.0 (Vite: --host), use the LAN IP, same Wi-Fi |
Related Skills
debugging-capacitor: inspect the WebView and native debuggersios-android-logs: stream logs during device runscapacitor-ci-cd: build and release pipelinescapacitor-ios-resizability: what to check in resized windows during device runscapacitor-plugin-upgrade-v8-to-v9: plugin API removals that affect test helpers
Scan to join WeChat group