Testing Electron Native APIs: System Tray, Notifications, and File System
Electron apps often use native OS APIs to feel like real desktop apps rather than web apps in a window. System tray icons, desktop notifications, file system dialogs, shell operations — these give your app its desktop feel. They're also notoriously hard to test.
The challenge: native APIs produce visible effects (a notification popup, a tray icon) that aren't easily assertable in automated tests. The solution is to abstract and mock the native layer, test the logic, and separately verify the integration points.
Testing Strategies for Native APIs
Don't test that the OS works: You don't need to verify that a Notification actually appears on screen. That's the OS's job. Test that your code calls the right native API with the right parameters.
Abstract native calls: Put native API calls behind thin wrappers that you can mock. This also makes your code more testable overall.
Use dependency injection: Instead of calling new Notification() directly, inject the notification factory into your service class.
Verify parameters, not effects: Assert that nativeImage.createFromPath was called with the correct icon path, not that an icon visually appears in the tray.
Testing System Tray Logic
// main/services/tray-service.js
const { Tray, Menu, nativeImage } = require('electron');
const path = require('path');
class TrayService {
constructor(options = {}) {
this._Tray = options.Tray || Tray;
this._Menu = options.Menu || Menu;
this._nativeImage = options.nativeImage || nativeImage;
this.tray = null;
}
initialize(iconPath) {
const icon = this._nativeImage.createFromPath(iconPath);
this.tray = new this._Tray(icon);
this.tray.setToolTip('My Application');
this._buildContextMenu();
return this.tray;
}
_buildContextMenu() {
const menu = this._Menu.buildFromTemplate([
{ label: 'Show', click: () => this.emit('show-window') },
{ type: 'separator' },
{ label: 'Quit', role: 'quit' },
]);
this.tray.setContextMenu(menu);
}
updateIcon(status) {
const iconName = status === 'active' ? 'tray-active.png' : 'tray-inactive.png';
const iconPath = path.join(__dirname, '../../assets', iconName);
const icon = this._nativeImage.createFromPath(iconPath);
this.tray?.setImage(icon);
}
setTooltip(text) {
this.tray?.setToolTip(text);
}
destroy() {
this.tray?.destroy();
this.tray = null;
}
}
module.exports = TrayService;// tests/main/services/tray-service.test.js
const TrayService = require('../../../main/services/tray-service');
function createMockTray() {
return {
setToolTip: jest.fn(),
setContextMenu: jest.fn(),
setImage: jest.fn(),
destroy: jest.fn(),
on: jest.fn(),
};
}
function createMockDeps() {
const mockTrayInstance = createMockTray();
return {
Tray: jest.fn(() => mockTrayInstance),
Menu: {
buildFromTemplate: jest.fn(() => ({ items: [] })),
},
nativeImage: {
createFromPath: jest.fn(() => ({ isEmpty: jest.fn(() => false) })),
},
mockTrayInstance,
};
}
describe('TrayService', () => {
let service;
let deps;
beforeEach(() => {
deps = createMockDeps();
service = new TrayService(deps);
});
describe('initialize', () => {
it('creates tray with specified icon', () => {
service.initialize('/path/to/icon.png');
expect(deps.nativeImage.createFromPath).toHaveBeenCalledWith('/path/to/icon.png');
expect(deps.Tray).toHaveBeenCalledWith(expect.any(Object));
});
it('sets tooltip on initialization', () => {
service.initialize('/icon.png');
expect(deps.mockTrayInstance.setToolTip).toHaveBeenCalledWith('My Application');
});
it('builds context menu with required items', () => {
service.initialize('/icon.png');
const menuTemplate = deps.Menu.buildFromTemplate.mock.calls[0][0];
const labels = menuTemplate.map(item => item.label).filter(Boolean);
expect(labels).toContain('Show');
expect(labels).toContain('Quit');
});
});
describe('updateIcon', () => {
beforeEach(() => service.initialize('/icon.png'));
it('uses active icon when status is active', () => {
service.updateIcon('active');
const calledPath = deps.nativeImage.createFromPath.mock.calls[1][0];
expect(calledPath).toContain('tray-active.png');
});
it('uses inactive icon when status is inactive', () => {
service.updateIcon('inactive');
const calledPath = deps.nativeImage.createFromPath.mock.calls[1][0];
expect(calledPath).toContain('tray-inactive.png');
});
it('calls setImage on the tray', () => {
service.updateIcon('active');
expect(deps.mockTrayInstance.setImage).toHaveBeenCalled();
});
});
describe('destroy', () => {
it('destroys tray and clears reference', () => {
service.initialize('/icon.png');
service.destroy();
expect(deps.mockTrayInstance.destroy).toHaveBeenCalled();
expect(service.tray).toBeNull();
});
});
});Testing Desktop Notifications
// main/services/notification-service.js
class NotificationService {
constructor(options = {}) {
this._Notification = options.Notification || require('electron').Notification;
this._isSupported = options.isSupported !== undefined
? options.isSupported
: this._Notification.isSupported();
}
notify({ title, body, icon, onClick }) {
if (!this._isSupported) {
console.warn('Notifications not supported on this system');
return null;
}
const notification = new this._Notification({ title, body, icon });
if (onClick) {
notification.on('click', onClick);
}
notification.show();
return notification;
}
notifyError(message) {
return this.notify({
title: 'Error',
body: message,
icon: 'error-icon.png',
});
}
}
module.exports = NotificationService;// tests/main/services/notification-service.test.js
describe('NotificationService', () => {
let MockNotification;
let mockNotificationInstance;
beforeEach(() => {
mockNotificationInstance = {
show: jest.fn(),
on: jest.fn(),
};
MockNotification = jest.fn(() => mockNotificationInstance);
MockNotification.isSupported = jest.fn(() => true);
});
it('creates notification with correct parameters', () => {
const service = new NotificationService({ Notification: MockNotification });
service.notify({ title: 'Test', body: 'Message', icon: 'icon.png' });
expect(MockNotification).toHaveBeenCalledWith({
title: 'Test',
body: 'Message',
icon: 'icon.png',
});
expect(mockNotificationInstance.show).toHaveBeenCalled();
});
it('registers click handler when provided', () => {
const service = new NotificationService({ Notification: MockNotification });
const clickHandler = jest.fn();
service.notify({ title: 'Test', body: 'Test', onClick: clickHandler });
expect(mockNotificationInstance.on).toHaveBeenCalledWith('click', clickHandler);
});
it('returns null when notifications not supported', () => {
const service = new NotificationService({
Notification: MockNotification,
isSupported: false,
});
const result = service.notify({ title: 'Test', body: 'Test' });
expect(result).toBeNull();
expect(MockNotification).not.toHaveBeenCalled();
});
it('notifyError uses error icon', () => {
const service = new NotificationService({ Notification: MockNotification });
service.notifyError('Something went wrong');
const notificationParams = MockNotification.mock.calls[0][0];
expect(notificationParams.title).toBe('Error');
expect(notificationParams.body).toBe('Something went wrong');
expect(notificationParams.icon).toBe('error-icon.png');
});
});Testing Shell Operations
Electron's shell module opens URLs, reveals files in Finder/Explorer, etc.:
// main/services/shell-service.js
const { shell } = require('electron');
const path = require('path');
class ShellService {
constructor(options = {}) {
this._shell = options.shell || shell;
}
async openExternal(url) {
// Validate URL to prevent opening arbitrary protocols
const allowed = ['https:', 'http:', 'mailto:'];
const parsed = new URL(url);
if (!allowed.includes(parsed.protocol)) {
throw new Error(`Blocked unsafe protocol: ${parsed.protocol}`);
}
return this._shell.openExternal(url);
}
async revealInFileManager(filePath) {
const resolvedPath = path.resolve(filePath);
return this._shell.showItemInFolder(resolvedPath);
}
async openPath(filePath) {
const result = await this._shell.openPath(filePath);
if (result) throw new Error(`Failed to open: ${result}`);
}
}
module.exports = ShellService;describe('ShellService', () => {
let mockShell;
let service;
beforeEach(() => {
mockShell = {
openExternal: jest.fn().mockResolvedValue(undefined),
showItemInFolder: jest.fn().mockResolvedValue(undefined),
openPath: jest.fn().mockResolvedValue(''), // empty string = success
};
service = new ShellService({ shell: mockShell });
});
describe('openExternal', () => {
it('opens HTTPS URLs', async () => {
await service.openExternal('https://example.com');
expect(mockShell.openExternal).toHaveBeenCalledWith('https://example.com');
});
it('blocks non-HTTP protocols', async () => {
await expect(service.openExternal('file:///etc/passwd'))
.rejects.toThrow('Blocked unsafe protocol: file:');
expect(mockShell.openExternal).not.toHaveBeenCalled();
});
it('blocks javascript: protocol', async () => {
await expect(service.openExternal('javascript:alert(1)'))
.rejects.toThrow('Blocked unsafe protocol');
});
it('allows mailto: links', async () => {
await service.openExternal('mailto:user@example.com');
expect(mockShell.openExternal).toHaveBeenCalledWith('mailto:user@example.com');
});
});
describe('openPath', () => {
it('throws when openPath returns an error string', async () => {
mockShell.openPath.mockResolvedValue('No application to open file');
await expect(service.openPath('/some/file.xyz'))
.rejects.toThrow('Failed to open: No application to open file');
});
it('succeeds when openPath returns empty string', async () => {
mockShell.openPath.mockResolvedValue(''); // success
await expect(service.openPath('/some/file.pdf')).resolves.not.toThrow();
});
});
});Testing Clipboard Operations
// tests/main/services/clipboard-service.test.js
describe('ClipboardService', () => {
let mockClipboard;
let service;
beforeEach(() => {
mockClipboard = {
writeText: jest.fn(),
readText: jest.fn().mockReturnValue('clipboard content'),
writeImage: jest.fn(),
};
service = new ClipboardService({ clipboard: mockClipboard });
});
it('copies text to clipboard', () => {
service.copyText('Hello, World!');
expect(mockClipboard.writeText).toHaveBeenCalledWith('Hello, World!');
});
it('reads text from clipboard', () => {
const text = service.readText();
expect(text).toBe('clipboard content');
});
it('does not write empty string to clipboard', () => {
service.copyText('');
expect(mockClipboard.writeText).not.toHaveBeenCalled();
});
});Integration Testing with a Real Electron Window
For testing that native features work correctly in a real window:
// tests/integration/native-features.spec.js
const { _electron: electron } = require('@playwright/test');
const { test, expect } = require('@playwright/test');
test('system tray icon is created', async () => {
const app = await electron.launch({
args: ['main.js'],
env: { NODE_ENV: 'test', ENABLE_TRAY: 'true', ...process.env },
});
// Verify via IPC that the tray was created
const result = await app.evaluate(({ BrowserWindow }) => {
// Access the tray service via the main process
const { trayService } = require('./main/services/tray-service-instance');
return trayService.tray !== null;
});
expect(result).toBe(true);
await app.close();
});Summary
Testing native Electron APIs requires:
- Dependency injection: Pass
Tray,Notification,shell, etc. as constructor options so tests can mock them - Mock native instances: Create mock objects with Jest functions that mirror the native API surface
- Test parameters not effects: Assert that native APIs were called with correct arguments, not that OS effects occurred
- Security test shell: Verify that
shell.openExternalblocks dangerous protocols — this is a common security vulnerability - Integration tests for smoke: A quick integration test in a real Electron window to verify the service mounts without errors
The abstraction pattern (thin service class with injectable dependencies) pays off here — it makes native API testing practical and fast.