135 lines
		
	
	
	
		
			3.8 KiB
			
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			135 lines
		
	
	
	
		
			3.8 KiB
			
		
	
	
	
		
			Markdown
		
	
	
	
	
	
# Automated Testing with a Custom Driver
 | 
						|
 | 
						|
To write automated tests for your Electron app, you will need a way to "drive" your application. [Spectron](https://electronjs.org/spectron) is a commonly-used solution which lets you emulate user actions via [WebDriver](http://webdriver.io/). 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](https://nodejs.org/api/child_process.html) API. The test suite will spawn the Electron process, then establish a simple messaging protocol:
 | 
						|
 | 
						|
```js
 | 
						|
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](https://nodejs.org/api/process.html) API:
 | 
						|
 | 
						|
```js
 | 
						|
// 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:
 | 
						|
 | 
						|
```js
 | 
						|
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:
 | 
						|
 | 
						|
```js
 | 
						|
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:
 | 
						|
 | 
						|
```js
 | 
						|
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()
 | 
						|
})
 | 
						|
```
 |