git:20260501.ed3e92e to git:20261011.86834d4

72 added, 561 removed. Audit A to A.

---
name: capacitor-testing
- description: Complete testing guide for Capacitor apps covering unit tests, integration tests, E2E tests, and native testing. Includes Jest, Vitest, Playwright, Appium, and native testing frameworks. Use this skill when users need to test their mobile apps.
+ description: Testing guide for Capacitor apps and plugins. Covers web-layer unit and component tests with Vitest and mocked Capacitor plugins (`vi.mock('@capacitor/core')`, `registerPlugin`), Playwright for the web build, Appium E2E with NATIVE_APP / WEBVIEW context switching, native plugin unit tests with Swift Testing vs XCTest (what can migrate, `CAPPluginCall` patterns, `xcodebuild test` on a simulator) and Android JUnit, plus an agent-driven verification loop on simulators and devices (build, install, launch, screenshot, UI hierarchy, tap, logs) with `xcrun simctl`, `xcrun devicectl`, `adb`, Xcode MCP device tools, and live reload via `npx cap run --url` (Capacitor 9) or `-l --host --port` (Capacitor 8). Use when adding tests, mocking plugins, "does it work on device", verifying a UI change on a simulator, migrating XCTest to Swift Testing, or setting up test CI. Do not use for crash log capture (ios-android-logs), WebView debugging sessions (debugging-capacitor), or build pipelines (capacitor-ci-cd).
---
- # Testing Capacitor Applications
-
- Comprehensive testing strategies for Capacitor mobile apps.
-
- ## When to Use This Skill
-
- - User wants to add tests
- - User asks about testing strategies
- - User needs E2E testing
- - User wants to mock native plugins
- - User needs CI testing setup
-
- ## Testing Pyramid
-
- ```
- /\
- / \ E2E Tests (Few)
- /----\ - Real devices
- / \ - Full user flows
- /--------\ Integration Tests (Some)
- / \ - Component interactions
- /------------\ - API integration
- / \ Unit Tests (Many)
- /----------------\ - Pure functions
- - Business logic
- ```
-
- ## Unit Testing
-
- ### Setup with Vitest
-
- ```bash
- npm install -D vitest @vitest/coverage-v8
- ```
-
- ```typescript
- // vitest.config.ts
- import { defineConfig } from 'vitest/config';
-
- export default defineConfig({
- test: {
- globals: true,
- environment: 'jsdom',
- coverage: {
- provider: 'v8',
- reporter: ['text', 'json', 'html'],
- },
- setupFiles: ['./src/test/setup.ts'],
- },
- });
- ```
-
- ### Mock Capacitor Plugins
-
- ```typescript
- // src/test/setup.ts
- import { vi } from 'vitest';
-
- // Mock @capacitor/core
- vi.mock('@capacitor/core', () => ({
- Capacitor: {
- isNativePlatform: vi.fn(() => true),
- getPlatform: vi.fn(() => 'ios'),
- isPluginAvailable: vi.fn(() => true),
- },
- registerPlugin: vi.fn(),
- }));
-
- // Mock @capacitor/preferences
- vi.mock('@capacitor/preferences', () => ({
- Preferences: {
- get: vi.fn(),
- set: vi.fn(),
- remove: vi.fn(),
- clear: vi.fn(),
- },
- }));
-
- // Mock @capgo/capacitor-native-biometric
- vi.mock('@capgo/capacitor-native-biometric', () => ({
- NativeBiometric: {
- isAvailable: vi.fn().mockResolvedValue({
- isAvailable: true,
- biometryType: 'touchId',
- }),
- verifyIdentity: vi.fn().mockResolvedValue({}),
- setCredentials: vi.fn().mockResolvedValue({}),
- getCredentials: vi.fn().mockResolvedValue({
- username: 'test@example.com',
- password: 'token',
- }),
- },
- }));
- ```
-
- ### Unit Test Examples
-
- ```typescript
- // src/services/auth.test.ts
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- import { AuthService } from './auth';
- import { NativeBiometric } from '@capgo/capacitor-native-biometric';
-
- describe('AuthService', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe('biometricLogin', () => {
- it('should authenticate with biometrics', async () => {
- const authService = new AuthService();
-
- const result = await authService.biometricLogin();
-
- expect(NativeBiometric.verifyIdentity).toHaveBeenCalledWith({
- reason: 'Authenticate to login',
- title: 'Biometric Login',
- });
- expect(result).toBe(true);
- });
-
- it('should return false when biometrics unavailable', async () => {
- vi.mocked(NativeBiometric.isAvailable).mockResolvedValueOnce({
- isAvailable: false,
- biometryType: 'none',
- });
-
- const authService = new AuthService();
- const result = await authService.biometricLogin();
-
- expect(result).toBe(false);
- });
-
- it('should handle user cancellation', async () => {
- vi.mocked(NativeBiometric.verifyIdentity).mockRejectedValueOnce(
- new Error('User cancelled')
- );
-
- const authService = new AuthService();
- const result = await authService.biometricLogin();
-
- expect(result).toBe(false);
- });
- });
- });
- ```
-
- ### Testing Utilities
-
- ```typescript
- // src/test/utils.ts
- import { Capacitor } from '@capacitor/core';
- import { vi } from 'vitest';
-
- export function mockPlatform(platform: 'ios' | 'android' | 'web') {
- vi.mocked(Capacitor.getPlatform).mockReturnValue(platform);
- vi.mocked(Capacitor.isNativePlatform).mockReturnValue(platform !== 'web');
- }
-
- export function mockPluginAvailable(available: boolean) {
- vi.mocked(Capacitor.isPluginAvailable).mockReturnValue(available);
- }
-
- // Usage in tests
- describe('Platform-specific behavior', () => {
- it('should use iOS-specific code on iOS', () => {
- mockPlatform('ios');
- // Test iOS behavior
- });
-
- it('should use Android-specific code on Android', () => {
- mockPlatform('android');
- // Test Android behavior
- });
- });
- ```
-
- ## Component Testing
-
- ### React Testing Library
-
- ```bash
- npm install -D @testing-library/react @testing-library/user-event
- ```
-
- ```typescript
- // src/components/LoginButton.test.tsx
- import { render, screen, waitFor } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { LoginButton } from './LoginButton';
- import { NativeBiometric } from '@capgo/capacitor-native-biometric';
-
- describe('LoginButton', () => {
- it('should show biometric option when available', async () => {
- render(<LoginButton />);
-
- await waitFor(() => {
- expect(screen.getByText('Login with Face ID')).toBeInTheDocument();
- });
- });
-
- it('should call biometric auth on click', async () => {
- const user = userEvent.setup();
- render(<LoginButton />);
-
- const button = await screen.findByRole('button', { name: /face id/i });
- await user.click(button);
-
- expect(NativeBiometric.verifyIdentity).toHaveBeenCalled();
- });
- });
- ```
-
- ### Vue Test Utils
-
- ```bash
- npm install -D @vue/test-utils
- ```
-
- ```typescript
- // src/components/LoginButton.spec.ts
- import { mount, flushPromises } from '@vue/test-utils';
- import LoginButton from './LoginButton.vue';
- import { NativeBiometric } from '@capgo/capacitor-native-biometric';
-
- describe('LoginButton', () => {
- it('should render biometric button', async () => {
- const wrapper = mount(LoginButton);
- await flushPromises();
-
- expect(wrapper.text()).toContain('Login with Biometrics');
- });
-
- it('should trigger authentication on click', async () => {
- const wrapper = mount(LoginButton);
- await flushPromises();
-
- await wrapper.find('button').trigger('click');
-
- expect(NativeBiometric.verifyIdentity).toHaveBeenCalled();
- });
- });
- ```
-
- ## E2E Testing
-
- ### Playwright for Web
-
- ```bash
- npm install -D @playwright/test
- npx playwright install
- ```
-
- ```typescript
- // playwright.config.ts
- import { defineConfig, devices } from '@playwright/test';
-
- export default defineConfig({
- testDir: './e2e',
- fullyParallel: true,
- use: {
- baseURL: 'http://localhost:5173',
- trace: 'on-first-retry',
- },
- projects: [
- {
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] },
- },
- {
- name: 'Mobile Safari',
- use: { ...devices['iPhone 14'] },
- },
- {
- name: 'Mobile Chrome',
- use: { ...devices['Pixel 7'] },
- },
- ],
- webServer: {
- command: 'npm run dev',
- url: 'http://localhost:5173',
- reuseExistingServer: !process.env.CI,
- },
- });
- ```
-
- ```typescript
- // e2e/login.spec.ts
- import { test, expect } from '@playwright/test';
-
- test.describe('Login Flow', () => {
- test('should login with email and password', async ({ page }) => {
- await page.goto('/login');
-
- await page.fill('[data-testid="email"]', 'test@example.com');
- await page.fill('[data-testid="password"]', 'password123');
- await page.click('[data-testid="login-button"]');
+ # Testing Capacitor Apps and Plugins
- await expect(page).toHaveURL('/dashboard');
- await expect(page.locator('h1')).toContainText('Welcome');
- });
+ 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.
- test('should show error for invalid credentials', async ({ page }) => {
- await page.goto('/login');
+ ## When to Use
- await page.fill('[data-testid="email"]', 'wrong@example.com');
- await page.fill('[data-testid="password"]', 'wrong');
- await page.click('[data-testid="login-button"]');
+ 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
- await expect(page.locator('[data-testid="error"]')).toBeVisible();
- });
- });
- ```
+ 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)
- ### Appium for Native
+ ## Choose the layer
- ```bash
- npm install -D webdriverio @wdio/appium-service @wdio/mocha-framework
- ```
+ | What changed | Test with | Reference |
+ |---|---|---|
+ | Business logic, state, services that call plugins | Vitest with mocked plugins | [references/web-unit-tests.md](references/web-unit-tests.md) |
+ | Components (React, Vue, Angular, Svelte) | Testing Library on Vitest | [references/web-unit-tests.md](references/web-unit-tests.md) |
+ | Web flows (routing, forms) | Playwright against the dev server or `dist` | [references/e2e.md](references/e2e.md) |
+ | Plugin native code (Swift / Kotlin) | Swift Testing or XCTest; JUnit | [references/native-plugin-tests.md](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/device-verification-loop.md), [references/e2e.md](references/e2e.md) |
+ | CI jobs | GitHub Actions matrix | [references/ci.md](references/ci.md) |
- ```typescript
- // wdio.conf.ts
- export const config: WebdriverIO.Config = {
- runner: 'local',
- specs: ['./e2e/native/**/*.spec.ts'],
- capabilities: [
- {
- platformName: 'iOS',
- 'appium:deviceName': 'iPhone 15',
- 'appium:platformVersion': '17.0',
- 'appium:app': './ios/App/build/App.app',
- 'appium:automationName': 'XCUITest',
- },
- {
- platformName: 'Android',
- 'appium:deviceName': 'Pixel 8',
- 'appium:platformVersion': '14',
- 'appium:app': './android/app/build/outputs/apk/debug/app-debug.apk',
- 'appium:automationName': 'UiAutomator2',
- },
- ],
- services: ['appium'],
- framework: 'mocha',
- };
- ```
+ Only load a reference when its layer is in play.
- ```typescript
- // e2e/native/login.spec.ts
- describe('Native Login', () => {
- it('should login with biometrics', async () => {
- // Wait for app to load
- await $('~login-screen').waitForExist();
+ ## Facts that save time
- // Tap biometric button
- await $('~biometric-login').click();
+ - **Plugins in jsdom run their web implementation**, or throw `unimplemented` when there is none. `Capacitor.isNativePlatform()` returns `false` in 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 set `ios.webContentsDebuggingEnabled` / `android.webContentsDebuggingEnabled` in `capacitor.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 test`** because they depend on UIKit and Capacitor. Run `xcodebuild 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 or `CAPPlugin(bridge:pluginId:pluginName:)` must move to `CAPPluginCall(callbackId:methodName:options:success:error:)` and a plain `init()`.
+ - **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's `ionic cap run ios -l --external` is a different tool.
- // Simulate biometric auth (device-specific)
- if (driver.isIOS) {
- await driver.touchId(true);
- } else {
- await driver.fingerPrint(1);
- }
+ ## Workflow
- // Verify logged in
- await expect($('~dashboard')).toBeExisting();
- });
- });
- ```
+ 1. **Inspect.** Read `package.json` (test runner, framework, `@capacitor/*` versions), `capacitor.config.*`, existing test config (`vitest.config.*`, `playwright.config.*`, `wdio.conf.*`, `.maestro/`), and for plugins `Package.swift`, the podspec, `ios/Tests/`, `android/src/test/`.
+ 2. **Ask** which layer the user wants if the request is general. Suggest the cheapest one that covers the risk.
+ 3. **Write tests** using the matching reference. Follow the project's existing runner and style; do not add a second runner.
+ 4. **Run them** and show the output. A test that was never run is not done.
+ 5. **For UI-affecting changes, run the device verification loop** ([references/device-verification-loop.md](references/device-verification-loop.md)) and report screenshots and findings.
- ### Detox for React Native Style Testing
+ ## Verify
```bash
- npm install -D detox
- ```
-
- ```javascript
- // .detoxrc.js
- module.exports = {
- testRunner: {
- $0: 'jest',
- args: {
- config: 'e2e/jest.config.js',
- },
- },
- apps: {
- 'ios.debug': {
- type: 'ios.app',
- binaryPath: 'ios/App/build/App.app',
- build: 'cd ios && xcodebuild ...',
- },
- 'android.debug': {
- type: 'android.apk',
- binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
- build: 'cd android && ./gradlew assembleDebug',
- },
- },
- devices: {
- simulator: {
- type: 'ios.simulator',
- device: { type: 'iPhone 15' },
- },
- emulator: {
- type: 'android.emulator',
- device: { avdName: 'Pixel_8_API_34' },
- },
- },
- };
- ```
-
- ## Native Testing
-
- ### iOS XCTest
-
- ```swift
- // ios/AppTests/PluginTests.swift
- import XCTest
- @testable import App
- import Capacitor
-
- class PluginTests: XCTestCase {
- var bridge: MockBridge!
-
- override func setUp() {
- super.setUp()
- bridge = MockBridge()
- }
-
- func testPluginMethodReturnsExpectedValue() {
- let plugin = MyPlugin(bridge: bridge, pluginId: "MyPlugin", pluginName: "MyPlugin")
-
- let call = CAPPluginCall(callbackId: "test", options: ["value": "test"], success: { result, call in
- XCTAssertEqual(result?.data?["value"] as? String, "test")
- }, error: { error in
- XCTFail("Should not error")
- })
-
- plugin.echo(call!)
- }
- }
- ```
-
- ### Android JUnit
-
- ```kotlin
- // android/app/src/test/java/com/example/PluginTest.kt
- import org.junit.Test
- import org.junit.Assert.*
- import org.mockito.Mockito.*
-
- class PluginTest {
- @Test
- fun `echo returns input value`() {
- val plugin = MyPlugin()
- val call = mock(PluginCall::class.java)
-
- `when`(call.getString("value")).thenReturn("test")
-
- plugin.echo(call)
-
- verify(call).resolve(argThat { data ->
- data.getString("value") == "test"
- })
- }
- }
- ```
-
- ### Android Instrumented Tests
-
- ```kotlin
- // android/app/src/androidTest/java/com/example/PluginInstrumentedTest.kt
- import androidx.test.ext.junit.runners.AndroidJUnit4
- import androidx.test.platform.app.InstrumentationRegistry
- import org.junit.Test
- import org.junit.runner.RunWith
- import org.junit.Assert.*
-
- @RunWith(AndroidJUnit4::class)
- class PluginInstrumentedTest {
- @Test
- fun useAppContext() {
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.example.app", appContext.packageName)
- }
- }
- ```
-
- ## Testing Best Practices
-
- ### Test Organization
-
- ```
- src/
- ├── components/
- │ ├── Button.tsx
- │ └── Button.test.tsx # Unit tests next to component
- ├── services/
- │ ├── auth.ts
- │ └── auth.test.ts
- ├── test/
- │ ├── setup.ts # Test setup
- │ ├── mocks/ # Shared mocks
- │ └── utils.ts # Test utilities
- e2e/
- ├── web/ # Playwright tests
- │ └── login.spec.ts
- └── native/ # Appium tests
- └── login.spec.ts
- ```
-
- ### Mock Best Practices
-
- ```typescript
- // Don't over-mock - test real behavior when possible
- // BAD
- vi.mock('./api', () => ({
- fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
- }));
+ # Web layer
+ npx vitest run
+ npx playwright test
- // GOOD - Use MSW for API mocking
- import { setupServer } from 'msw/node';
- import { http, HttpResponse } from 'msw';
+ # 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/ *$//')"
- const server = setupServer(
- http.get('/api/user', () => {
- return HttpResponse.json({ id: 1, name: 'Test' });
- })
- );
+ # Plugin, Android (from the plugin repo)
+ cd android && ./gradlew test
- beforeAll(() => server.listen());
- afterEach(() => server.resetHandlers());
- afterAll(() => server.close());
+ # 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
```
- ### CI Configuration
-
- ```yaml
- # .github/workflows/test.yml
- name: Tests
-
- on: [push, pull_request]
-
- jobs:
- unit:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- - run: npm install
- - run: npm test -- --coverage
-
- e2e:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- - run: npm install
- - run: npx playwright install --with-deps
- - run: npm run build
- - run: npx playwright test
+ Report: tests added, command run, pass/fail counts, and anything that could not run locally (no simulator, no device, missing Android SDK).
- ios:
- runs-on: macos-latest
- steps:
- - uses: actions/checkout@v4
- - run: cd ios/App && xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 15'
+ ## Error Handling
- android:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
- - run: cd android && ./gradlew test
- ```
+ | 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 |
- ## Resources
+ ## Related Skills
- - Vitest Documentation: https://vitest.dev
- - Playwright Documentation: https://playwright.dev
- - Testing Library: https://testing-library.com
- - Appium Documentation: https://appium.io
+ - `debugging-capacitor`: inspect the WebView and native debuggers
+ - `ios-android-logs`: stream logs during device runs
+ - `capacitor-ci-cd`: build and release pipelines
+ - `capacitor-ios-resizability`: what to check in resized windows during device runs
+ - `capacitor-plugin-upgrade-v8-to-v9`: plugin API removals that affect test helpers