Electron Documentation

Docs / Guides / Automated Testing with a Custom Driver electron@master (1b3fdb)

Automated Testing with a Custom Driver

To write automated tests for your Electron app, you will need a way to "drive" your application. Spectron is a commonly-used solution which lets you emulate user actions via WebDriver. However, it's also possible to write your own custom driver using node's builtin IPC-over-STDIO. The benefit of a custom driver is that it tends to require less overhead than Spectron, and lets you expose custom methods to your test suite.

To create a custom driver, we'll use nodejs' child_process API. The test suite will spawn the Electron process, then establish a simple messaging protocol:

var childProcess = require('child_process')
  var electronPath = require('electron')
  
  // spawn the process
  var env = { /* ... */ }
  var stdio = ['inherit', 'inherit', 'inherit', 'ipc']
  var appProcess = childProcess.spawn(electronPath, ['./app'], { stdio, env })
  
  // listen for IPC messages from the app
  appProcess.on('message', (msg) => {
    // ...
  })
  
  // send an IPC message to the app
  appProcess.send({ my: 'message' })

From within the Electron app, you can listen for messages and send replies using the nodejs process API:

// listen for IPC messages from the test suite
  process.on('message', (msg) => {
    // ...
  })
  
  // send an IPC message to the test suite
  process.send({ my: 'message' })

We can now communicate from the test suite to the Electron app using the appProcess object.

For convenience, you may want to wrap appProcess in a driver object that provides more high-level functions. Here is an example of how you can do this:

class TestDriver {
    constructor ({ path, args, env }) {
      this.rpcCalls = []
  
      // start child process
      env.APP_TEST_DRIVER = 1 // let the app know it should listen for messages
      this.process = childProcess.spawn(path, args, { stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
  
      // handle rpc responses
      this.process.on('message', (message) => {
        // pop the handler
        var rpcCall = this.rpcCalls[message.msgId]
        if (!rpcCall) return
        this.rpcCalls[message.msgId] = null
        // reject/resolve
        if (message.reject) rpcCall.reject(message.reject)
        else rpcCall.resolve(message.resolve)
      })
  
      // wait for ready
      this.isReady = this.rpc('isReady').catch((err) => {
        console.error('Application failed to start', err)
        this.stop()
        process.exit(1)
      })
    }
  
    // simple RPC call
    // to use: driver.rpc('method', 1, 2, 3).then(...)
    async rpc (cmd, ...args) {
      // send rpc request
      var msgId = this.rpcCalls.length
      this.process.send({ msgId, cmd, args })
      return new Promise((resolve, reject) => this.rpcCalls.push({ resolve, reject }))
    }
  
    stop () {
      this.process.kill()
    }
  }

In the app, you'd need to write a simple handler for the RPC calls:

if (process.env.APP_TEST_DRIVER) {
    process.on('message', onMessage)
  }
  
  async function onMessage ({ msgId, cmd, args }) {
    var method = METHODS[cmd]
    if (!method) method = () => new Error('Invalid method: ' + cmd)
    try {
      var resolve = await method(...args)
      process.send({ msgId, resolve })
    } catch (err) {
      var reject = {
        message: err.message,
        stack: err.stack,
        name: err.name
      }
      process.send({ msgId, reject })
    }
  }
  
  const METHODS = {
    isReady () {
      // do any setup needed
      return true
    }
    // define your RPC-able methods here
  }

Then, in your test suite, you can use your test-driver as follows:

var test = require('ava')
  var electronPath = require('electron')
  
  var app = new TestDriver({
    path: electronPath,
    args: ['./app'],
    env: {
      NODE_ENV: 'test'
    }
  })
  test.before(async t => {
    await app.isReady
  })
  test.after.always('cleanup', async t => {
    await app.stop()
  })