Testing Electron Auto-Updater: Verifying Update Flows Before Shipping

Testing Electron Auto-Updater: Verifying Update Flows Before Shipping

Auto-update is one of the most critical features in an Electron app and one of the least tested. When auto-update breaks, users get stuck on old versions — sometimes permanently. When auto-update has a bug, you can push a broken update that prevents future updates from working.

Testing auto-update thoroughly before each release is non-negotiable. This guide covers how.

How electron-updater Works

electron-updater (part of electron-builder) is the standard auto-update library for Electron apps. The flow:

  1. App calls autoUpdater.checkForUpdates()
  2. Library requests latest.yml (or latest-mac.yml, latest-linux.yml) from your update server
  3. If a new version is available, update-available event fires
  4. autoUpdater.downloadUpdate() downloads the installer
  5. download-progress events fire with progress data
  6. update-downloaded fires when complete
  7. autoUpdater.quitAndInstall() restarts the app and installs the update

Each step can fail, and each step is testable.

Unit Testing the Update Service

Extract your update logic into a service class:

// main/services/update-service.js
const { autoUpdater } = require('electron-updater');
const log = require('electron-log');

class UpdateService {
  constructor(options = {}) {
    this.autoUpdater = options.autoUpdater || autoUpdater;
    this.logger = options.logger || log;
    this.mainWindow = null;
    
    this._setupListeners();
  }
  
  setMainWindow(window) {
    this.mainWindow = window;
  }
  
  _setupListeners() {
    this.autoUpdater.on('checking-for-update', () => {
      this.logger.info('Checking for update...');
      this._notifyRenderer('update-checking');
    });
    
    this.autoUpdater.on('update-available', (info) => {
      this.logger.info('Update available:', info.version);
      this._notifyRenderer('update-available', info);
    });
    
    this.autoUpdater.on('update-not-available', (info) => {
      this.logger.info('App is up to date:', info.version);
      this._notifyRenderer('update-not-available', info);
    });
    
    this.autoUpdater.on('download-progress', (progress) => {
      this._notifyRenderer('update-progress', progress);
    });
    
    this.autoUpdater.on('update-downloaded', (info) => {
      this.logger.info('Update downloaded:', info.version);
      this._notifyRenderer('update-downloaded', info);
    });
    
    this.autoUpdater.on('error', (error) => {
      this.logger.error('Auto-update error:', error.message);
      this._notifyRenderer('update-error', { message: error.message });
    });
  }
  
  _notifyRenderer(channel, data) {
    if (this.mainWindow && !this.mainWindow.isDestroyed()) {
      this.mainWindow.webContents.send(`updater:${channel}`, data);
    }
  }
  
  async checkForUpdates() {
    return this.autoUpdater.checkForUpdates();
  }
  
  async downloadUpdate() {
    return this.autoUpdater.downloadUpdate();
  }
  
  quitAndInstall() {
    this.autoUpdater.quitAndInstall(false, true);
  }
}

module.exports = UpdateService;
// tests/main/services/update-service.test.js
const EventEmitter = require('events');

class MockAutoUpdater extends EventEmitter {
  constructor() {
    super();
    this.checkForUpdates = jest.fn();
    this.downloadUpdate = jest.fn();
    this.quitAndInstall = jest.fn();
  }
}

describe('UpdateService', () => {
  let service;
  let mockUpdater;
  let mockWindow;
  
  beforeEach(() => {
    mockUpdater = new MockAutoUpdater();
    
    mockWindow = {
      isDestroyed: jest.fn().mockReturnValue(false),
      webContents: {
        send: jest.fn(),
      },
    };
    
    const UpdateService = require('../../../main/services/update-service');
    service = new UpdateService({ autoUpdater: mockUpdater });
    service.setMainWindow(mockWindow);
  });
  
  describe('update-available event', () => {
    it('notifies renderer when update is available', () => {
      const updateInfo = { version: '2.0.0', releaseDate: '2025-01-15' };
      
      mockUpdater.emit('update-available', updateInfo);
      
      expect(mockWindow.webContents.send).toHaveBeenCalledWith(
        'updater:update-available',
        updateInfo
      );
    });
  });
  
  describe('download-progress event', () => {
    it('forwards progress data to renderer', () => {
      const progress = {
        bytesPerSecond: 500000,
        percent: 45.5,
        transferred: 4550000,
        total: 10000000,
      };
      
      mockUpdater.emit('download-progress', progress);
      
      expect(mockWindow.webContents.send).toHaveBeenCalledWith(
        'updater:update-progress',
        progress
      );
    });
  });
  
  describe('error handling', () => {
    it('notifies renderer of update error', () => {
      const error = new Error('net::ERR_INTERNET_DISCONNECTED');
      
      mockUpdater.emit('error', error);
      
      expect(mockWindow.webContents.send).toHaveBeenCalledWith(
        'updater:update-error',
        { message: 'net::ERR_INTERNET_DISCONNECTED' }
      );
    });
    
    it('does not crash when main window is destroyed', () => {
      mockWindow.isDestroyed.mockReturnValue(true);
      
      // Should not throw when window is destroyed
      expect(() => {
        mockUpdater.emit('update-available', { version: '2.0.0' });
      }).not.toThrow();
      
      expect(mockWindow.webContents.send).not.toHaveBeenCalled();
    });
  });
  
  describe('quitAndInstall', () => {
    it('calls quitAndInstall with correct parameters', () => {
      service.quitAndInstall();
      
      // false = don't force quit immediately, true = install on restart
      expect(mockUpdater.quitAndInstall).toHaveBeenCalledWith(false, true);
    });
  });
});

Testing Against a Mock Update Server

For integration testing, run a local update server that serves controlled update data:

// tests/fixtures/update-server.js
const express = require('express');
const path = require('path');

function createMockUpdateServer(options = {}) {
  const app = express();
  const { latestVersion = '2.0.0', hasUpdate = true } = options;
  
  // Serve the latest.yml file that electron-updater requests
  app.get('/latest.yml', (req, res) => {
    if (!hasUpdate) {
      // Return current version — no update available
      res.status(404).send('Not found');
      return;
    }
    
    res.type('text/yaml').send(`
version: ${latestVersion}
files:
  - url: app-${latestVersion}-setup.exe
    sha512: abc123def456
    size: 75000000
path: app-${latestVersion}-setup.exe
sha512: abc123def456
releaseDate: '2025-01-15T00:00:00.000Z'
    `);
  });
  
  // Serve fake installer (just bytes for download testing)
  app.get(`/app-${latestVersion}-setup.exe`, (req, res) => {
    // Stream fake bytes for download progress testing
    res.setHeader('Content-Length', '1000000');
    let sent = 0;
    const interval = setInterval(() => {
      const chunk = Buffer.alloc(10000);
      res.write(chunk);
      sent += 10000;
      if (sent >= 1000000) {
        clearInterval(interval);
        res.end();
      }
    }, 10);
  });
  
  return app;
}

module.exports = { createMockUpdateServer };
// tests/integration/auto-update.test.js
const http = require('http');
const { createMockUpdateServer } = require('../fixtures/update-server');
const UpdateService = require('../../main/services/update-service');

describe('auto-update integration', () => {
  let server;
  let serverPort;
  
  beforeEach(async () => {
    // Start mock update server
    const app = createMockUpdateServer({ latestVersion: '2.0.0', hasUpdate: true });
    server = http.createServer(app);
    
    await new Promise(resolve => server.listen(0, resolve));
    serverPort = server.address().port;
    
    // Configure electron-updater to use our mock server
    process.env.UPDATE_SERVER_URL = `http://localhost:${serverPort}`;
  });
  
  afterEach(async () => {
    await new Promise(resolve => server.close(resolve));
  });
  
  it('detects available update from server', async () => {
    const { autoUpdater } = require('electron-updater');
    autoUpdater.setFeedURL({ url: `http://localhost:${serverPort}` });
    
    const updateInfo = await new Promise((resolve, reject) => {
      autoUpdater.once('update-available', resolve);
      autoUpdater.once('update-not-available', () => reject(new Error('No update found')));
      autoUpdater.once('error', reject);
      
      autoUpdater.checkForUpdates();
    });
    
    expect(updateInfo.version).toBe('2.0.0');
  });
  
  it('reports no update when server returns 404', async () => {
    const app = createMockUpdateServer({ hasUpdate: false });
    // Use a different port for this test
    
    const noUpdateInfo = await new Promise((resolve, reject) => {
      autoUpdater.once('update-not-available', resolve);
      autoUpdater.once('update-available', () => reject(new Error('Update found unexpectedly')));
      autoUpdater.once('error', reject);
      
      autoUpdater.checkForUpdates();
    });
    
    expect(noUpdateInfo).toBeDefined();
  });
  
  it('fires download-progress events during download', async () => {
    const progressEvents = [];
    
    autoUpdater.once('update-available', () => autoUpdater.downloadUpdate());
    autoUpdater.on('download-progress', p => progressEvents.push(p.percent));
    
    await new Promise((resolve, reject) => {
      autoUpdater.once('update-downloaded', resolve);
      autoUpdater.once('error', reject);
      autoUpdater.checkForUpdates();
    });
    
    expect(progressEvents.length).toBeGreaterThan(0);
    expect(progressEvents[progressEvents.length - 1]).toBe(100);
  });
});

Testing Update Server Configuration

A common production failure: the update server URL is wrong for a platform or architecture:

// tests/update-server-config.test.js
const { getUpdateServerUrl } = require('../../main/config/update-config');

describe('update server URL configuration', () => {
  const testCases = [
    { platform: 'win32', arch: 'x64', expected: /latest\.yml$/ },
    { platform: 'darwin', arch: 'x64', expected: /latest-mac\.yml$/ },
    { platform: 'darwin', arch: 'arm64', expected: /latest-mac\.yml$/ },
    { platform: 'linux', arch: 'x64', expected: /latest-linux\.yml$/ },
  ];
  
  for (const { platform, arch, expected } of testCases) {
    it(`${platform}/${arch} uses correct update manifest`, () => {
      // Mock the platform
      Object.defineProperty(process, 'platform', { value: platform });
      Object.defineProperty(process, 'arch', { value: arch });
      
      const url = getUpdateServerUrl();
      expect(url).toMatch(expected);
    });
  }
});

Testing the UI Update Flow

Test that the renderer correctly shows update status:

// tests/components/UpdateBanner.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import UpdateBanner from '../../src/components/UpdateBanner';

beforeEach(() => {
  window.electronAPI = {
    installUpdate: jest.fn(),
    dismissUpdate: jest.fn(),
    onUpdateStatus: jest.fn(),
  };
});

describe('UpdateBanner', () => {
  it('shows download progress', () => {
    window.electronAPI.onUpdateStatus.mockImplementation((callback) => {
      callback({ type: 'update-progress', percent: 65 });
    });
    
    render(<UpdateBanner />);
    
    expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '65');
    expect(screen.getByText('65%')).toBeInTheDocument();
  });
  
  it('shows install button when update is downloaded', () => {
    window.electronAPI.onUpdateStatus.mockImplementation((callback) => {
      callback({ type: 'update-downloaded', version: '2.0.0' });
    });
    
    render(<UpdateBanner />);
    
    expect(screen.getByRole('button', { name: /install/i })).toBeVisible();
    expect(screen.getByText('Version 2.0.0 is ready to install')).toBeInTheDocument();
  });
  
  it('calls installUpdate when install button clicked', () => {
    window.electronAPI.onUpdateStatus.mockImplementation((callback) => {
      callback({ type: 'update-downloaded', version: '2.0.0' });
    });
    
    render(<UpdateBanner />);
    
    fireEvent.click(screen.getByRole('button', { name: /install/i }));
    
    expect(window.electronAPI.installUpdate).toHaveBeenCalled();
  });
  
  it('shows error state when update fails', () => {
    window.electronAPI.onUpdateStatus.mockImplementation((callback) => {
      callback({ type: 'update-error', message: 'Network error' });
    });
    
    render(<UpdateBanner />);
    
    expect(screen.getByText(/failed to download/i)).toBeVisible();
    expect(screen.getByRole('button', { name: /retry/i })).toBeVisible();
  });
});

Summary

Auto-update testing has four layers:

  1. Unit tests: Test UpdateService event handling with a mock EventEmitter for autoUpdater
  2. Mock update server: Integration test the full check-and-download flow against a local HTTP server
  3. Configuration tests: Verify the update server URL is correct for each platform
  4. UI tests: Verify the renderer shows correct status for each update state

The most critical tests are: update check correctly identifies available updates, download progress fires correctly, and the window-destroyed guard prevents crashes during shutdown. These catch the majority of auto-update bugs before release.

Read more

Start now free