capacitor-testing by cap-go/capgo-skills
npx skills add https://github.com/cap-go/capgo-skills --skill capacitor-testing适用于 Capacitor 移动应用的全面测试策略。
/\
/ \ 端到端测试(少量)
/----\ - 真实设备
/ \ - 完整的用户流程
/--------\ 集成测试(适量)
/ \ - 组件交互
/------------\ - API 集成
/ \ 单元测试(大量)
/----------------\ - 纯函数
- 业务逻辑
bun add -D vitest @vitest/coverage-v8
// 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'],
},
});
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// src/test/setup.ts
import { vi } from 'vitest';
// 模拟 @capacitor/core
vi.mock('@capacitor/core', () => ({
Capacitor: {
isNativePlatform: vi.fn(() => true),
getPlatform: vi.fn(() => 'ios'),
isPluginAvailable: vi.fn(() => true),
},
registerPlugin: vi.fn(),
}));
// 模拟 @capacitor/preferences
vi.mock('@capacitor/preferences', () => ({
Preferences: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
},
}));
// 模拟 @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',
}),
},
}));
// 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('应该使用生物识别技术进行身份验证', 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('当生物识别不可用时应该返回 false', async () => {
vi.mocked(NativeBiometric.isAvailable).mockResolvedValueOnce({
isAvailable: false,
biometryType: 'none',
});
const authService = new AuthService();
const result = await authService.biometricLogin();
expect(result).toBe(false);
});
it('应该处理用户取消操作', async () => {
vi.mocked(NativeBiometric.verifyIdentity).mockRejectedValueOnce(
new Error('User cancelled')
);
const authService = new AuthService();
const result = await authService.biometricLogin();
expect(result).toBe(false);
});
});
});
// 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);
}
// 在测试中的用法
describe('平台特定行为', () => {
it('在 iOS 上应该使用 iOS 特定代码', () => {
mockPlatform('ios');
// 测试 iOS 行为
});
it('在 Android 上应该使用 Android 特定代码', () => {
mockPlatform('android');
// 测试 Android 行为
});
});
bun add -D @testing-library/react @testing-library/user-event
// 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('当生物识别可用时应该显示生物识别选项', async () => {
render(<LoginButton />);
await waitFor(() => {
expect(screen.getByText('Login with Face ID')).toBeInTheDocument();
});
});
it('点击时应调用生物识别身份验证', 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();
});
});
bun add -D @vue/test-utils
// 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('应该渲染生物识别按钮', async () => {
const wrapper = mount(LoginButton);
await flushPromises();
expect(wrapper.text()).toContain('Login with Biometrics');
});
it('点击时应触发身份验证', async () => {
const wrapper = mount(LoginButton);
await flushPromises();
await wrapper.find('button').trigger('click');
expect(NativeBiometric.verifyIdentity).toHaveBeenCalled();
});
});
bun add -D @playwright/test
bunx playwright install
// 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: 'bun run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('应该使用邮箱和密码登录', 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"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('对于无效凭据应显示错误', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrong');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error"]')).toBeVisible();
});
});
bun add -D webdriverio @wdio/appium-service @wdio/mocha-framework
// 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',
};
// e2e/native/login.spec.ts
describe('原生登录', () => {
it('应该使用生物识别技术登录', async () => {
// 等待应用加载
await $('~login-screen').waitForExist();
// 点击生物识别按钮
await $('~biometric-login').click();
// 模拟生物识别身份验证(设备特定)
if (driver.isIOS) {
await driver.touchId(true);
} else {
await driver.fingerPrint(1);
}
// 验证已登录
await expect($('~dashboard')).toBeExisting();
});
});
bun add -D detox
// .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' },
},
},
};
// 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/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/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)
}
}
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx # 单元测试放在组件旁边
├── services/
│ ├── auth.ts
│ └── auth.test.ts
├── test/
│ ├── setup.ts # 测试设置
│ ├── mocks/ # 共享模拟
│ └── utils.ts # 测试工具函数
e2e/
├── web/ # Playwright 测试
│ └── login.spec.ts
└── native/ # Appium 测试
└── login.spec.ts
// 不要过度模拟 - 尽可能测试真实行为
// 不好的做法
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
}));
// 好的做法 - 使用 MSW 进行 API 模拟
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'Test' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun test --coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bunx playwright install --with-deps
- run: bun run build
- run: bunx playwright test
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'
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
每周安装次数
64
仓库
GitHub 星标数
18
首次出现时间
2026年2月6日
安全审计
安装于
gemini-cli60
opencode58
codex57
github-copilot56
amp54
kimi-cli54
Comprehensive testing strategies for Capacitor mobile apps.
/\
/ \ E2E Tests (Few)
/----\ - Real devices
/ \ - Full user flows
/--------\ Integration Tests (Some)
/ \ - Component interactions
/------------\ - API integration
/ \ Unit Tests (Many)
/----------------\ - Pure functions
- Business logic
bun add -D vitest @vitest/coverage-v8
// 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'],
},
});
// 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',
}),
},
}));
// 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);
});
});
});
// 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
});
});
bun add -D @testing-library/react @testing-library/user-event
// 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();
});
});
bun add -D @vue/test-utils
// 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();
});
});
bun add -D @playwright/test
bunx playwright install
// 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: 'bun run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
// 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"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrong');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error"]')).toBeVisible();
});
});
bun add -D webdriverio @wdio/appium-service @wdio/mocha-framework
// 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',
};
// e2e/native/login.spec.ts
describe('Native Login', () => {
it('should login with biometrics', async () => {
// Wait for app to load
await $('~login-screen').waitForExist();
// Tap biometric button
await $('~biometric-login').click();
// Simulate biometric auth (device-specific)
if (driver.isIOS) {
await driver.touchId(true);
} else {
await driver.fingerPrint(1);
}
// Verify logged in
await expect($('~dashboard')).toBeExisting();
});
});
bun add -D detox
// .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' },
},
},
};
// 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/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/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)
}
}
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
// Don't over-mock - test real behavior when possible
// BAD
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
}));
// GOOD - Use MSW for API mocking
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'Test' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun test --coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bunx playwright install --with-deps
- run: bun run build
- run: bunx playwright test
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'
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
Weekly Installs
64
Repository
GitHub Stars
18
First Seen
Feb 6, 2026
Security Audits
Gen Agent Trust HubFailSocketPassSnykPass
Installed on
gemini-cli60
opencode58
codex57
github-copilot56
amp54
kimi-cli54
Vue 3 调试指南:解决响应式、计算属性与监听器常见错误
11,900 周安装