Testing Capacitor Native Plugins: Camera, Filesystem, Push Notifications with @capacitor/mock

Testing Capacitor Native Plugins: Camera, Filesystem, Push Notifications with @capacitor/mock

Capacitor native plugins are the points in your application where web code makes requests to native capabilities — camera hardware, the device filesystem, push notification infrastructure. In unit and integration tests, none of those native capabilities exist. The test runner is Node.js; there is no camera, no iOS sandbox, no Firebase Cloud Messaging client.

Without mocks, any test that exercises code touching a plugin fails immediately with Capacitor is not available in this environment or simply hangs. With well-crafted mocks, you can test every success path, every error condition, and every permission edge case — all within milliseconds and entirely offline.

The Mocking Strategy

Two approaches work in practice:

1. Manual Jest mocks — create mock files in src/__mocks__/ that mirror the plugin's public API. Jest automatically intercepts imports when mock files exist in the right location.

2. Module-level jest.mock() calls — inline mock factories that give you per-test control without committing to a shared mock file.

The @capacitor/mock package was an early community experiment but is not the official Capacitor testing path. Official guidance is to use jest.mock() with manually crafted return values that match the TypeScript types the real plugins export.

For large projects, centralize mocks in src/__mocks__/ so every test file gets consistent behavior without repeating mock definitions. For small projects or one-off tests, inline jest.mock() is faster to set up.

Shared Mock Utilities

Create a central file that all test files import from:

// src/testing/capacitor-mocks.ts

// Camera mock factory
export function createCameraMock() {
  return {
    getPhoto: jest.fn().mockResolvedValue({
      webPath: 'capacitor://localhost/_capacitor_file_/tmp/photo_test.jpg',
      format: 'jpeg',
      saved: false,
    }),
    pickImages: jest.fn().mockResolvedValue({
      photos: [
        { webPath: 'capacitor://localhost/_capacitor_file_/tmp/img1.jpg', format: 'jpeg' },
      ],
    }),
    checkPermissions: jest.fn().mockResolvedValue({
      camera: 'granted',
      photos: 'granted',
    }),
    requestPermissions: jest.fn().mockResolvedValue({
      camera: 'granted',
      photos: 'granted',
    }),
  };
}

// Filesystem mock factory
export function createFilesystemMock() {
  const inMemoryFiles: Map<string, string> = new Map();

  return {
    writeFile: jest.fn().mockImplementation(async ({ path, data }: { path: string; data: string }) => {
      inMemoryFiles.set(path, data);
      return { uri: `file:///data/${path}` };
    }),
    readFile: jest.fn().mockImplementation(async ({ path }: { path: string }) => {
      if (!inMemoryFiles.has(path)) {
        throw new Error(`File does not exist: ${path}`);
      }
      return { data: inMemoryFiles.get(path) };
    }),
    deleteFile: jest.fn().mockImplementation(async ({ path }: { path: string }) => {
      inMemoryFiles.delete(path);
      return {};
    }),
    mkdir: jest.fn().mockResolvedValue({}),
    readdir: jest.fn().mockResolvedValue({ files: [] }),
    stat: jest.fn().mockResolvedValue({
      type: 'file',
      size: 1024,
      ctime: Date.now(),
      mtime: Date.now(),
      uri: 'file:///data/test.txt',
    }),
    getUri: jest.fn().mockResolvedValue({ uri: 'file:///data/test.txt' }),
    copy: jest.fn().mockResolvedValue({ uri: 'file:///data/copy.txt' }),
    _files: inMemoryFiles, // expose for test assertions
  };
}

// Push Notifications mock factory
export function createPushNotificationsMock() {
  return {
    checkPermissions: jest.fn().mockResolvedValue({ receive: 'granted' }),
    requestPermissions: jest.fn().mockResolvedValue({ receive: 'granted' }),
    register: jest.fn().mockResolvedValue({}),
    getDeliveredNotifications: jest.fn().mockResolvedValue({ notifications: [] }),
    removeDeliveredNotifications: jest.fn().mockResolvedValue({}),
    removeAllDeliveredNotifications: jest.fn().mockResolvedValue({}),
    createChannel: jest.fn().mockResolvedValue({}),
    deleteChannel: jest.fn().mockResolvedValue({}),
    listChannels: jest.fn().mockResolvedValue({ channels: [] }),
    addListener: jest.fn().mockImplementation((_event: string, _callback: Function) => ({
      remove: jest.fn(),
    })),
    removeAllListeners: jest.fn().mockResolvedValue({}),
  };
}

// Network mock factory
export function createNetworkMock(initialStatus = { connected: true, connectionType: 'wifi' }) {
  let currentStatus = { ...initialStatus };

  return {
    getStatus: jest.fn().mockImplementation(async () => currentStatus),
    addListener: jest.fn().mockImplementation((_event: string, callback: Function) => {
      // Store the callback so tests can trigger status changes
      if (_event === 'networkStatusChange') {
        (networkMock as any)._statusChangeCallback = callback;
      }
      return { remove: jest.fn() };
    }),
    removeAllListeners: jest.fn().mockResolvedValue({}),
    // Test helper: simulate network status change
    _simulateStatusChange(status: object) {
      currentStatus = { ...currentStatus, ...status };
      if ((this as any)._statusChangeCallback) {
        (this as any)._statusChangeCallback(currentStatus);
      }
    },
  };

  var networkMock = arguments[0]; // reference for closure — handled below
}

Testing the Camera Plugin

// src/services/photo.service.test.ts
import { Camera } from '@capacitor/camera';
import { createCameraMock } from '../testing/capacitor-mocks';
import { PhotoService } from './photo.service';

jest.mock('@capacitor/camera', () => ({
  Camera: {} as any, // will be overridden per test
  CameraResultType: { Uri: 'uri', Base64: 'base64', DataUrl: 'dataUrl' },
  CameraSource: { Camera: 'CAMERA', Photos: 'PHOTOS', Prompt: 'PROMPT' },
}));

describe('PhotoService', () => {
  let service: PhotoService;
  let cameraMock: ReturnType<typeof createCameraMock>;

  beforeEach(() => {
    cameraMock = createCameraMock();
    Object.assign(Camera, cameraMock);
    service = new PhotoService();
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('takePhoto', () => {
    it('calls getPhoto with correct options', async () => {
      await service.takePhoto();

      expect(cameraMock.getPhoto).toHaveBeenCalledWith({
        resultType: 'uri',
        source: 'CAMERA',
        quality: 90,
        allowEditing: false,
      });
    });

    it('returns web path from camera result', async () => {
      const result = await service.takePhoto();
      expect(result.webPath).toMatch(/tmp\/photo_test\.jpg/);
    });

    it('throws when camera permission is denied', async () => {
      cameraMock.checkPermissions.mockResolvedValue({
        camera: 'denied',
        photos: 'denied',
      });

      await expect(service.takePhoto()).rejects.toThrow('Camera access denied');
    });

    it('requests permissions when status is prompt', async () => {
      cameraMock.checkPermissions.mockResolvedValue({
        camera: 'prompt',
        photos: 'prompt',
      });
      cameraMock.requestPermissions.mockResolvedValue({
        camera: 'granted',
        photos: 'granted',
      });

      await service.takePhoto();

      expect(cameraMock.requestPermissions).toHaveBeenCalled();
      expect(cameraMock.getPhoto).toHaveBeenCalled();
    });

    it('throws when user cancels camera', async () => {
      cameraMock.checkPermissions.mockResolvedValue({
        camera: 'granted',
        photos: 'granted',
      });
      cameraMock.getPhoto.mockRejectedValue(new Error('User cancelled photos app'));

      await expect(service.takePhoto()).rejects.toThrow('User cancelled');
    });
  });

  describe('pickFromGallery', () => {
    it('returns multiple photos from gallery picker', async () => {
      const photos = await service.pickFromGallery(3);
      expect(photos).toHaveLength(1); // mock returns 1 by default
      expect(cameraMock.pickImages).toHaveBeenCalledWith({ limit: 3 });
    });
  });
});

Testing the Filesystem Plugin

The in-memory filesystem mock is particularly useful because it validates that write/read pairs are consistent:

// src/services/cache.service.test.ts
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import { createFilesystemMock } from '../testing/capacitor-mocks';
import { CacheService } from './cache.service';

jest.mock('@capacitor/filesystem', () => ({
  Filesystem: {} as any,
  Directory: {
    Documents: 'DOCUMENTS',
    Data: 'DATA',
    Cache: 'CACHE',
    External: 'EXTERNAL',
  },
  Encoding: { UTF8: 'utf8' },
}));

describe('CacheService', () => {
  let service: CacheService;
  let fsMock: ReturnType<typeof createFilesystemMock>;

  beforeEach(() => {
    fsMock = createFilesystemMock();
    Object.assign(Filesystem, fsMock);
    service = new CacheService();
  });

  describe('set and get', () => {
    it('writes serialized data to filesystem', async () => {
      await service.set('user-profile', { id: 1, name: 'Alice' });

      expect(fsMock.writeFile).toHaveBeenCalledWith({
        path: 'cache/user-profile.json',
        data: JSON.stringify({ id: 1, name: 'Alice' }),
        directory: 'DATA',
        encoding: 'utf8',
        recursive: true,
      });
    });

    it('retrieves and deserializes data', async () => {
      await service.set('user-profile', { id: 1, name: 'Alice' });
      const result = await service.get('user-profile');

      expect(result).toEqual({ id: 1, name: 'Alice' });
    });

    it('returns null for missing cache key', async () => {
      const result = await service.get('nonexistent-key');
      expect(result).toBeNull();
    });
  });

  describe('delete', () => {
    it('removes the file from filesystem', async () => {
      await service.set('temp-data', { x: 1 });
      await service.delete('temp-data');

      expect(fsMock.deleteFile).toHaveBeenCalledWith(
        expect.objectContaining({ path: 'cache/temp-data.json' })
      );

      const result = await service.get('temp-data');
      expect(result).toBeNull();
    });
  });

  describe('error handling', () => {
    it('handles filesystem write errors gracefully', async () => {
      fsMock.writeFile.mockRejectedValue(new Error('Storage quota exceeded'));

      await expect(service.set('key', { data: 'value' }))
        .rejects.toThrow('Storage quota exceeded');
    });

    it('handles corrupt JSON in cache', async () => {
      fsMock.readFile.mockResolvedValue({ data: 'not valid json{{' });

      const result = await service.get('corrupted-key');
      expect(result).toBeNull(); // service should handle parse errors gracefully
    });
  });
});

Testing Push Notifications

Push Notifications testing covers three areas: permission flows, token registration, and notification event handling.

// src/services/push.service.test.ts
import { PushNotifications } from '@capacitor/push-notifications';
import { createPushNotificationsMock } from '../testing/capacitor-mocks';
import { PushService } from './push.service';

jest.mock('@capacitor/push-notifications', () => ({
  PushNotifications: {} as any,
}));

describe('PushService', () => {
  let service: PushService;
  let pushMock: ReturnType<typeof createPushNotificationsMock>;
  let listenerCallbacks: Map<string, Function>;

  beforeEach(() => {
    listenerCallbacks = new Map();
    pushMock = createPushNotificationsMock();

    // Capture listener callbacks for manual triggering in tests
    pushMock.addListener.mockImplementation((event: string, callback: Function) => {
      listenerCallbacks.set(event, callback);
      return { remove: jest.fn() };
    });

    Object.assign(PushNotifications, pushMock);
    service = new PushService();
  });

  describe('initialize', () => {
    it('checks permissions on initialization', async () => {
      await service.initialize();
      expect(pushMock.checkPermissions).toHaveBeenCalled();
    });

    it('registers for push when permission is granted', async () => {
      pushMock.checkPermissions.mockResolvedValue({ receive: 'granted' });

      await service.initialize();

      expect(pushMock.register).toHaveBeenCalled();
    });

    it('requests permissions when not yet determined', async () => {
      pushMock.checkPermissions.mockResolvedValue({ receive: 'prompt' });
      pushMock.requestPermissions.mockResolvedValue({ receive: 'granted' });

      await service.initialize();

      expect(pushMock.requestPermissions).toHaveBeenCalled();
      expect(pushMock.register).toHaveBeenCalled();
    });

    it('does not register when permission is denied', async () => {
      pushMock.checkPermissions.mockResolvedValue({ receive: 'denied' });

      await service.initialize();

      expect(pushMock.register).not.toHaveBeenCalled();
    });
  });

  describe('token registration', () => {
    it('stores registration token when received', async () => {
      await service.initialize();

      // Simulate the registration event firing
      const registrationCallback = listenerCallbacks.get('registration');
      expect(registrationCallback).toBeDefined();

      const storeSpy = jest.spyOn(service, 'storeToken');
      registrationCallback?.({ value: 'fcm-token-abc123xyz' });

      expect(storeSpy).toHaveBeenCalledWith('fcm-token-abc123xyz');
    });

    it('handles registration errors', async () => {
      await service.initialize();

      const errorCallback = listenerCallbacks.get('registrationError');
      const logSpy = jest.spyOn(console, 'error').mockImplementation();

      errorCallback?.({ error: new Error('Registration failed') });

      expect(logSpy).toHaveBeenCalledWith(
        expect.stringContaining('Push registration error'),
        expect.any(Error)
      );
    });
  });

  describe('notification handling', () => {
    it('routes foreground notification to notification center', async () => {
      await service.initialize();

      const notificationHandler = listenerCallbacks.get('pushNotificationReceived');
      const routeSpy = jest.spyOn(service, 'handleForegroundNotification');

      notificationHandler?.({
        id: '1',
        title: 'New message',
        body: 'You have a new message',
        data: { chatId: '42' },
      });

      expect(routeSpy).toHaveBeenCalledWith(
        expect.objectContaining({ title: 'New message' })
      );
    });

    it('navigates to correct route when notification is tapped', async () => {
      await service.initialize();

      const actionCallback = listenerCallbacks.get('pushNotificationActionPerformed');
      const navSpy = jest.spyOn(service, 'navigateFromNotification');

      actionCallback?.({
        actionId: 'tap',
        notification: {
          id: '1',
          title: 'New message',
          body: 'Body',
          data: { route: '/messages/42' },
        },
      });

      expect(navSpy).toHaveBeenCalledWith('/messages/42');
    });
  });
});

Testing the Network Plugin

// src/services/network.service.test.ts
import { Network } from '@capacitor/network';

jest.mock('@capacitor/network');

const mockNetwork = Network as jest.Mocked<typeof Network>;

describe('NetworkService', () => {
  let statusChangeCallback: Function | null = null;

  beforeEach(() => {
    statusChangeCallback = null;

    mockNetwork.getStatus.mockResolvedValue({
      connected: true,
      connectionType: 'wifi',
    });

    (mockNetwork.addListener as jest.Mock).mockImplementation(
      (event: string, callback: Function) => {
        if (event === 'networkStatusChange') {
          statusChangeCallback = callback;
        }
        return Promise.resolve({ remove: jest.fn() });
      }
    );
  });

  it('reports online status when connected', async () => {
    const isOnline = await service.isOnline();
    expect(isOnline).toBe(true);
  });

  it('reports offline status when disconnected', async () => {
    mockNetwork.getStatus.mockResolvedValue({
      connected: false,
      connectionType: 'none',
    });

    const isOnline = await service.isOnline();
    expect(isOnline).toBe(false);
  });

  it('notifies listeners when network drops', async () => {
    await service.initialize();

    const offlineSpy = jest.fn();
    service.onOffline(offlineSpy);

    // Simulate network loss event
    statusChangeCallback?.({ connected: false, connectionType: 'none' });

    expect(offlineSpy).toHaveBeenCalled();
  });

  it('retries failed requests when connection is restored', async () => {
    await service.initialize();

    const retrySpy = jest.spyOn(service, 'retryPendingRequests');

    statusChangeCallback?.({ connected: true, connectionType: 'wifi' });

    expect(retrySpy).toHaveBeenCalled();
  });
});

Testing Preferences (Storage) Plugin

// src/services/preferences.service.test.ts
import { Preferences } from '@capacitor/preferences';

jest.mock('@capacitor/preferences', () => ({
  Preferences: {
    get: jest.fn(),
    set: jest.fn().mockResolvedValue(undefined),
    remove: jest.fn().mockResolvedValue(undefined),
    clear: jest.fn().mockResolvedValue(undefined),
    keys: jest.fn().mockResolvedValue({ keys: [] }),
  },
}));

const mockPrefs = Preferences as jest.Mocked<typeof Preferences>;

describe('UserSettingsService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('persists theme preference', async () => {
    await service.setTheme('dark');

    expect(mockPrefs.set).toHaveBeenCalledWith({
      key: 'theme',
      value: 'dark',
    });
  });

  it('loads saved theme on startup', async () => {
    mockPrefs.get.mockResolvedValue({ value: 'dark' });

    const theme = await service.getTheme();
    expect(theme).toBe('dark');
  });

  it('returns default theme when no preference is saved', async () => {
    mockPrefs.get.mockResolvedValue({ value: null });

    const theme = await service.getTheme();
    expect(theme).toBe('system'); // default
  });

  it('clears all preferences on logout', async () => {
    await service.clearOnLogout();

    expect(mockPrefs.clear).toHaveBeenCalled();
  });
});

Spy-Based Verification Pattern

For cross-cutting concerns — analytics, crash reporting, audit logging — spy on plugin calls rather than asserting return values:

describe('analytics event tracking', () => {
  it('tracks camera photo taken event', async () => {
    const cameraSpy = jest.spyOn(Camera, 'getPhoto');

    await photoService.takePhoto();

    expect(cameraSpy).toHaveBeenCalledTimes(1);
    // Verify analytics was also called
    expect(analyticsMock.track).toHaveBeenCalledWith('photo_taken', {
      source: 'camera',
    });
  });
});

Plugin Unavailability

On web, some plugins are not available. Test these paths explicitly:

it('shows web fallback when plugin is unavailable', async () => {
  (Capacitor.isPluginAvailable as jest.Mock).mockReturnValue(false);

  const result = await service.getLocation();

  expect(result.source).toBe('browser-geolocation');
  expect(Geolocation.getCurrentPosition).not.toHaveBeenCalled();
});

Well-structured plugin mocks make the difference between a test suite that runs in CI and one that requires a physical device for every test run. Model your mocks faithfully against the TypeScript types the plugins export, and the type system will catch drift when plugins release new versions.


HelpMeTest lets you schedule and monitor your full Capacitor plugin test suite continuously, so plugin mock drift and regression are caught before reaching production.

Read more