Capacitor Testing Guide: Native Plugin Mocking, Jest Setup, and Cypress for the Web Layer
Capacitor bridges the gap between web and native mobile, but that bridge creates a testing challenge. Your business logic calls Camera.getPhoto() or Geolocation.getCurrentPosition(), but in a Jest environment there is no native runtime — those calls throw immediately. Meanwhile, your Cypress tests run in a browser where the Capacitor plugin bridge does not exist at all. Getting a reliable test suite means solving both problems deliberately.
This guide covers the full spectrum: Jest configuration for unit testing, mocking every major Capacitor plugin realistically, testing platform-specific code branches, and using Cypress to validate the web layer end to end.
Jest Setup for Capacitor Projects
Start with a clean Jest configuration that understands your project structure and handles Capacitor's ES module packages.
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
// Map Capacitor core so we can mock it
'@capacitor/core': '<rootDir>/src/__mocks__/@capacitor/core.ts',
// Handle CSS imports that Jest cannot process
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
allowSyntheticDefaultImports: true,
esModuleInterop: true,
},
}],
},
transformIgnorePatterns: [
// Capacitor packages ship as ESM — tell Jest to transform them
'node_modules/(?!(@capacitor|@ionic)/)',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
],
};
export default config;The transformIgnorePatterns entry is the most common source of confusion. Capacitor packages use ES module syntax, and Jest's default configuration skips node_modules entirely. Without this override, you'll see SyntaxError: Cannot use import statement in a module the moment any plugin is imported.
Mocking Capacitor Core
Before mocking individual plugins, mock @capacitor/core itself. This gives you control over Capacitor.isNativePlatform(), Capacitor.getPlatform(), and the plugin bridge.
// src/__mocks__/@capacitor/core.ts
export const Capacitor = {
isNativePlatform: jest.fn().mockReturnValue(false),
getPlatform: jest.fn().mockReturnValue('web'),
isPluginAvailable: jest.fn().mockReturnValue(true),
convertFileSrc: jest.fn((src: string) => src),
};
export const registerPlugin = jest.fn((name: string, implementations: object) => {
return implementations;
});Now in any test you can flip the platform:
import { Capacitor } from '@capacitor/core';
describe('platform-specific behavior', () => {
it('uses native camera on mobile platform', () => {
(Capacitor.isNativePlatform as jest.Mock).mockReturnValue(true);
(Capacitor.getPlatform as jest.Mock).mockReturnValue('ios');
// test native path
});
it('falls back to web input on web platform', () => {
(Capacitor.isNativePlatform as jest.Mock).mockReturnValue(false);
(Capacitor.getPlatform as jest.Mock).mockReturnValue('web');
// test web fallback path
});
});Mocking the Camera Plugin
The Camera plugin is one of the most commonly tested plugins because photo capture is central to many apps. Mock it at the module level with jest.mock.
// src/__mocks__/@capacitor/camera.ts
export const Camera = {
getPhoto: jest.fn(),
pickImages: jest.fn(),
checkPermissions: jest.fn(),
requestPermissions: jest.fn(),
};
export const CameraResultType = {
Uri: 'uri',
Base64: 'base64',
DataUrl: 'dataUrl',
};
export const CameraSource = {
Camera: 'CAMERA',
Photos: 'PHOTOS',
Prompt: 'PROMPT',
};Now write your service and test it:
// src/services/photo.service.ts
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Capacitor } from '@capacitor/core';
export interface PhotoResult {
webPath: string;
format: string;
}
export async function capturePhoto(): Promise<PhotoResult> {
const permissions = await Camera.checkPermissions();
if (permissions.camera === 'denied') {
throw new Error('Camera permission denied');
}
const photo = await Camera.getPhoto({
resultType: CameraResultType.Uri,
source: CameraSource.Camera,
quality: 90,
});
return {
webPath: photo.webPath ?? '',
format: photo.format,
};
}// src/services/photo.service.test.ts
import { Camera } from '@capacitor/camera';
import { capturePhoto } from './photo.service';
jest.mock('@capacitor/camera');
const mockCamera = Camera as jest.Mocked<typeof Camera>;
describe('capturePhoto', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns photo result when permissions are granted', async () => {
mockCamera.checkPermissions.mockResolvedValue({
camera: 'granted',
photos: 'granted',
});
mockCamera.getPhoto.mockResolvedValue({
webPath: 'capacitor://localhost/tmp/photo.jpg',
format: 'jpeg',
saved: false,
});
const result = await capturePhoto();
expect(result.webPath).toBe('capacitor://localhost/tmp/photo.jpg');
expect(result.format).toBe('jpeg');
expect(mockCamera.getPhoto).toHaveBeenCalledWith(
expect.objectContaining({ quality: 90 })
);
});
it('throws when camera permission is denied', async () => {
mockCamera.checkPermissions.mockResolvedValue({
camera: 'denied',
photos: 'denied',
});
await expect(capturePhoto()).rejects.toThrow('Camera permission denied');
expect(mockCamera.getPhoto).not.toHaveBeenCalled();
});
it('handles getPhoto rejection gracefully', async () => {
mockCamera.checkPermissions.mockResolvedValue({
camera: 'granted',
photos: 'granted',
});
mockCamera.getPhoto.mockRejectedValue(new Error('User cancelled'));
await expect(capturePhoto()).rejects.toThrow('User cancelled');
});
});Mocking the Filesystem Plugin
// src/__mocks__/@capacitor/filesystem.ts
export const Filesystem = {
readFile: jest.fn(),
writeFile: jest.fn(),
deleteFile: jest.fn(),
mkdir: jest.fn(),
rmdir: jest.fn(),
readdir: jest.fn(),
getUri: jest.fn(),
stat: jest.fn(),
copy: jest.fn(),
};
export const Directory = {
Documents: 'DOCUMENTS',
Data: 'DATA',
Cache: 'CACHE',
External: 'EXTERNAL',
ExternalStorage: 'EXTERNAL_STORAGE',
};
export const Encoding = {
UTF8: 'utf8',
ASCII: 'ascii',
UTF16: 'utf16',
};// src/services/storage.service.test.ts
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import { saveUserData, loadUserData } from './storage.service';
jest.mock('@capacitor/filesystem');
const mockFs = Filesystem as jest.Mocked<typeof Filesystem>;
describe('storage service', () => {
it('saves JSON data to the filesystem', async () => {
mockFs.writeFile.mockResolvedValue({ uri: 'file:///data/user.json' });
await saveUserData({ name: 'Alice', score: 42 });
expect(mockFs.writeFile).toHaveBeenCalledWith({
path: 'user.json',
data: JSON.stringify({ name: 'Alice', score: 42 }),
directory: Directory.Data,
encoding: Encoding.UTF8,
});
});
it('returns parsed data when file exists', async () => {
mockFs.readFile.mockResolvedValue({
data: JSON.stringify({ name: 'Alice', score: 42 }),
});
const data = await loadUserData();
expect(data).toEqual({ name: 'Alice', score: 42 });
});
it('returns null when file does not exist', async () => {
mockFs.readFile.mockRejectedValue(new Error('File does not exist'));
const data = await loadUserData();
expect(data).toBeNull();
});
});Mocking Geolocation and Push Notifications
// src/__mocks__/@capacitor/geolocation.ts
export const Geolocation = {
getCurrentPosition: jest.fn(),
watchPosition: jest.fn(),
clearWatch: jest.fn(),
checkPermissions: jest.fn(),
requestPermissions: jest.fn(),
};// src/services/location.service.test.ts
import { Geolocation } from '@capacitor/geolocation';
import { getUserLocation } from './location.service';
jest.mock('@capacitor/geolocation');
const mockGeo = Geolocation as jest.Mocked<typeof Geolocation>;
describe('getUserLocation', () => {
it('returns coordinates when permission is granted', async () => {
mockGeo.checkPermissions.mockResolvedValue({
location: 'granted',
coarseLocation: 'granted',
});
mockGeo.getCurrentPosition.mockResolvedValue({
coords: {
latitude: 48.8566,
longitude: 2.3522,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
},
timestamp: Date.now(),
});
const location = await getUserLocation();
expect(location.latitude).toBeCloseTo(48.8566);
expect(location.longitude).toBeCloseTo(2.3522);
});
});Testing Platform-Specific Code Branches
Many Capacitor apps need different behavior on iOS, Android, and web. Structure your code to make these branches testable:
// src/services/share.service.ts
import { Capacitor } from '@capacitor/core';
import { Share } from '@capacitor/share';
export async function shareContent(title: string, url: string): Promise<void> {
if (Capacitor.isNativePlatform()) {
await Share.share({ title, url, dialogTitle: title });
} else {
if (navigator.share) {
await navigator.share({ title, url });
} else {
await navigator.clipboard.writeText(url);
}
}
}// src/services/share.service.test.ts
import { Capacitor } from '@capacitor/core';
import { Share } from '@capacitor/share';
import { shareContent } from './share.service';
jest.mock('@capacitor/core');
jest.mock('@capacitor/share');
describe('shareContent', () => {
const mockCapacitor = Capacitor as jest.Mocked<typeof Capacitor>;
it('uses native Share on mobile', async () => {
mockCapacitor.isNativePlatform.mockReturnValue(true);
(Share.share as jest.Mock).mockResolvedValue({});
await shareContent('My App', 'https://example.com');
expect(Share.share).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://example.com' })
);
});
it('uses Web Share API on web when available', async () => {
mockCapacitor.isNativePlatform.mockReturnValue(false);
Object.defineProperty(navigator, 'share', {
value: jest.fn().mockResolvedValue(undefined),
writable: true,
});
await shareContent('My App', 'https://example.com');
expect(navigator.share).toHaveBeenCalled();
expect(Share.share).not.toHaveBeenCalled();
});
});Cypress for the Web Layer
Cypress tests the web layer of your Capacitor app as it runs in a browser. The plugin bridge is absent, but you can intercept the JavaScript bridge calls to simulate plugin responses.
// cypress/support/capacitor-mocks.js
Cypress.Commands.add('mockCapacitorPlugin', (pluginName, methodName, response) => {
cy.window().then((win) => {
if (!win.Capacitor) {
win.Capacitor = { Plugins: {} };
}
if (!win.Capacitor.Plugins[pluginName]) {
win.Capacitor.Plugins[pluginName] = {};
}
win.Capacitor.Plugins[pluginName][methodName] = cy.stub().resolves(response);
});
});// cypress/e2e/photo-upload.cy.js
describe('Photo upload flow', () => {
beforeEach(() => {
cy.visit('/');
cy.mockCapacitorPlugin('Camera', 'getPhoto', {
webPath: '/assets/test-photo.jpg',
format: 'jpeg',
});
cy.mockCapacitorPlugin('Camera', 'checkPermissions', {
camera: 'granted',
photos: 'granted',
});
});
it('shows preview after selecting a photo', () => {
cy.get('[data-cy="take-photo-btn"]').click();
cy.get('[data-cy="photo-preview"]').should('be.visible');
cy.get('[data-cy="photo-preview"] img').should('have.attr', 'src').and('include', 'test-photo');
});
it('shows error message when camera is unavailable', () => {
cy.window().then((win) => {
win.Capacitor.Plugins.Camera.checkPermissions = cy.stub().resolves({
camera: 'denied',
photos: 'denied',
});
});
cy.get('[data-cy="take-photo-btn"]').click();
cy.get('[data-cy="error-message"]').should('contain', 'Camera permission denied');
});
});Integration Test Strategy
Unit tests and Cypress tests cover different concerns. Use this mental model:
- Unit tests (Jest): Business logic, service methods, plugin call arguments, error handling, platform branching
- Cypress (web layer): User flows end to end, UI state after async plugin responses, form validation, navigation
- Device tests (Detox/Appium): Real native plugin behavior — covered in a separate guide
Keep mocks as close to the real plugin API shape as possible. When Capacitor releases a breaking change to a plugin's return type, your mocks will catch it as a TypeScript error before it reaches production.
Running Tests in CI
Add both test suites to your CI pipeline:
# .github/workflows/test.yml (excerpt)
- name: Run unit tests
run: npx jest --coverage --ci
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
start: npm run start
wait-on: 'http://localhost:3000'
browser: chromeA well-structured Capacitor test suite — even without a device — catches the majority of regressions before they reach QA or production.
For continuous monitoring and automated test execution across your Capacitor app's releases, HelpMeTest lets you run your full test suite on every deploy and get notified the moment something breaks — no device farm required for the web layer.