466 lines
		
	
	
	
		
			15 KiB
			
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
	
		
			15 KiB
			
		
	
	
	
		
			Markdown
		
	
	
	
	
	
# Automated Testing
 | 
						||
 | 
						||
Test automation is an efficient way of validating that your application code works as intended.
 | 
						||
While Electron doesn't actively maintain its own testing solution, this guide will go over a couple
 | 
						||
ways you can run end-to-end automated tests on your Electron app.
 | 
						||
 | 
						||
## Using the WebDriver interface
 | 
						||
 | 
						||
From [ChromeDriver - WebDriver for Chrome][chrome-driver]:
 | 
						||
 | 
						||
> WebDriver is an open source tool for automated testing of web apps across many
 | 
						||
> browsers. It provides capabilities for navigating to web pages, user input,
 | 
						||
> JavaScript execution, and more. ChromeDriver is a standalone server which
 | 
						||
> implements WebDriver's wire protocol for Chromium. It is being developed by
 | 
						||
> members of the Chromium and WebDriver teams.
 | 
						||
 | 
						||
There are a few ways that you can set up testing using WebDriver.
 | 
						||
 | 
						||
### With WebdriverIO
 | 
						||
 | 
						||
[WebdriverIO](https://webdriver.io/) (WDIO) is a test automation framework that provides a
 | 
						||
Node.js package for testing with WebDriver. Its ecosystem also includes various plugins
 | 
						||
(e.g. reporter and services) that can help you put together your test setup.
 | 
						||
 | 
						||
If you already have an existing WebdriverIO setup, it is recommended to update your dependencies and validate your existing configuration with how it is [outlined in the docs](https://webdriver.io/docs/desktop-testing/electron#configuration).
 | 
						||
 | 
						||
#### Install the test runner
 | 
						||
 | 
						||
If you don't use WebdriverIO in your project yet, you can add it by running the starter toolkit in your project root directory:
 | 
						||
 | 
						||
```sh npm2yarn
 | 
						||
npm init wdio@latest ./
 | 
						||
```
 | 
						||
 | 
						||
This starts a configuration wizard that helps you put together the right setup, installs all necessary packages, and generates a `wdio.conf.js` configuration file. Make sure to select _"Desktop Testing - of Electron Applications"_ on one of the first questions asking _"What type of testing would you like to do?"_.
 | 
						||
 | 
						||
#### Connect WDIO to your Electron app
 | 
						||
 | 
						||
After running the configuration wizard, your `wdio.conf.js` should include roughly the following content:
 | 
						||
 | 
						||
```js title='wdio.conf.js' @ts-nocheck
 | 
						||
export const config = {
 | 
						||
  // ...
 | 
						||
  services: ['electron'],
 | 
						||
  capabilities: [{
 | 
						||
    browserName: 'electron',
 | 
						||
    'wdio:electronServiceOptions': {
 | 
						||
      // WebdriverIO can automatically find your bundled application
 | 
						||
      // if you use Electron Forge or electron-builder, otherwise you
 | 
						||
      // can define it here, e.g.:
 | 
						||
      // appBinaryPath: './path/to/bundled/application.exe',
 | 
						||
      appArgs: ['foo', 'bar=baz']
 | 
						||
    }
 | 
						||
  }]
 | 
						||
  // ...
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
#### Write your tests
 | 
						||
 | 
						||
Use the [WebdriverIO API](https://webdriver.io/docs/api) to interact with elements on the screen. The framework provides custom "matchers" that make asserting the state of your application easy, e.g.:
 | 
						||
 | 
						||
```js @ts-nocheck
 | 
						||
import { browser, $, expect } from '@wdio/globals'
 | 
						||
 | 
						||
describe('keyboard input', () => {
 | 
						||
  it('should detect keyboard input', async () => {
 | 
						||
    await browser.keys(['y', 'o'])
 | 
						||
    await expect($('keypress-count')).toHaveText('YO')
 | 
						||
  })
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
Furthermore, WebdriverIO allows you to access Electron APIs to get static information about your application:
 | 
						||
 | 
						||
```js @ts-nocheck
 | 
						||
import { browser, $, expect } from '@wdio/globals'
 | 
						||
 | 
						||
describe('when the make smaller button is clicked', () => {
 | 
						||
  it('should decrease the window height and width by 10 pixels', async () => {
 | 
						||
    const boundsBefore = await browser.electron.browserWindow('getBounds')
 | 
						||
    expect(boundsBefore.width).toEqual(210)
 | 
						||
    expect(boundsBefore.height).toEqual(310)
 | 
						||
 | 
						||
    await $('.make-smaller').click()
 | 
						||
    const boundsAfter = await browser.electron.browserWindow('getBounds')
 | 
						||
    expect(boundsAfter.width).toEqual(200)
 | 
						||
    expect(boundsAfter.height).toEqual(300)
 | 
						||
  })
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
or to retrieve other Electron process information:
 | 
						||
 | 
						||
```js @ts-nocheck
 | 
						||
import fs from 'node:fs'
 | 
						||
import path from 'node:path'
 | 
						||
import { browser, expect } from '@wdio/globals'
 | 
						||
 | 
						||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), { encoding: 'utf-8' }))
 | 
						||
const { name, version } = packageJson
 | 
						||
 | 
						||
describe('electron APIs', () => {
 | 
						||
  it('should retrieve app metadata through the electron API', async () => {
 | 
						||
    const appName = await browser.electron.app('getName')
 | 
						||
    expect(appName).toEqual(name)
 | 
						||
    const appVersion = await browser.electron.app('getVersion')
 | 
						||
    expect(appVersion).toEqual(version)
 | 
						||
  })
 | 
						||
 | 
						||
  it('should pass args through to the launched application', async () => {
 | 
						||
    // custom args are set in the wdio.conf.js file as they need to be set before WDIO starts
 | 
						||
    const argv = await browser.electron.mainProcess('argv')
 | 
						||
    expect(argv).toContain('--foo')
 | 
						||
    expect(argv).toContain('--bar=baz')
 | 
						||
  })
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
#### Run your tests
 | 
						||
 | 
						||
To run your tests:
 | 
						||
 | 
						||
```sh
 | 
						||
$ npx wdio run wdio.conf.js
 | 
						||
```
 | 
						||
 | 
						||
WebdriverIO helps launch and shut down the application for you.
 | 
						||
 | 
						||
#### More documentation
 | 
						||
 | 
						||
Find more documentation on Mocking Electron APIs and other useful resources in the [official WebdriverIO documentation](https://webdriver.io/docs/desktop-testing/electron).
 | 
						||
 | 
						||
### With Selenium
 | 
						||
 | 
						||
[Selenium](https://www.selenium.dev/) is a web automation framework that
 | 
						||
exposes bindings to WebDriver APIs in many languages. Their Node.js bindings
 | 
						||
are available under the `selenium-webdriver` package on NPM.
 | 
						||
 | 
						||
#### Run a ChromeDriver server
 | 
						||
 | 
						||
In order to use Selenium with Electron, you need to download the `electron-chromedriver`
 | 
						||
binary, and run it:
 | 
						||
 | 
						||
```sh npm2yarn
 | 
						||
npm install --save-dev electron-chromedriver
 | 
						||
./node_modules/.bin/chromedriver
 | 
						||
Starting ChromeDriver (v2.10.291558) on port 9515
 | 
						||
Only local connections are allowed.
 | 
						||
```
 | 
						||
 | 
						||
Remember the port number `9515`, which will be used later.
 | 
						||
 | 
						||
#### Connect Selenium to ChromeDriver
 | 
						||
 | 
						||
Next, install Selenium into your project:
 | 
						||
 | 
						||
```sh npm2yarn
 | 
						||
npm install --save-dev selenium-webdriver
 | 
						||
```
 | 
						||
 | 
						||
Usage of `selenium-webdriver` with Electron is the same as with
 | 
						||
normal websites, except that you have to manually specify how to connect
 | 
						||
ChromeDriver and where to find the binary of your Electron app:
 | 
						||
 | 
						||
```js title='test.js' @ts-expect-error=[1]
 | 
						||
const webdriver = require('selenium-webdriver')
 | 
						||
const driver = new webdriver.Builder()
 | 
						||
  // The "9515" is the port opened by ChromeDriver.
 | 
						||
  .usingServer('http://localhost:9515')
 | 
						||
  .withCapabilities({
 | 
						||
    'goog:chromeOptions': {
 | 
						||
      // Here is the path to your Electron binary.
 | 
						||
      binary: '/Path-to-Your-App.app/Contents/MacOS/Electron'
 | 
						||
    }
 | 
						||
  })
 | 
						||
  .forBrowser('chrome') // note: use .forBrowser('electron') for selenium-webdriver <= 3.6.0
 | 
						||
  .build()
 | 
						||
driver.get('https://www.google.com')
 | 
						||
driver.findElement(webdriver.By.name('q')).sendKeys('webdriver')
 | 
						||
driver.findElement(webdriver.By.name('btnG')).click()
 | 
						||
driver.wait(() => {
 | 
						||
  return driver.getTitle().then((title) => {
 | 
						||
    return title === 'webdriver - Google Search'
 | 
						||
  })
 | 
						||
}, 1000)
 | 
						||
driver.quit()
 | 
						||
```
 | 
						||
 | 
						||
## Using Playwright
 | 
						||
 | 
						||
[Microsoft Playwright](https://playwright.dev) is an end-to-end testing framework built
 | 
						||
using browser-specific remote debugging protocols, similar to the [Puppeteer][] headless
 | 
						||
Node.js API but geared towards end-to-end testing. Playwright has experimental Electron
 | 
						||
support via Electron's support for the [Chrome DevTools Protocol][] (CDP).
 | 
						||
 | 
						||
### Install dependencies
 | 
						||
 | 
						||
You can install Playwright through your preferred Node.js package manager. It comes with its
 | 
						||
own [test runner][playwright-intro], which is built for end-to-end testing:
 | 
						||
 | 
						||
```sh npm2yarn
 | 
						||
npm install --save-dev @playwright/test
 | 
						||
```
 | 
						||
 | 
						||
:::caution Dependencies
 | 
						||
This tutorial was written with `@playwright/test@1.41.1`. Check out
 | 
						||
[Playwright's releases][playwright-releases] page to learn about
 | 
						||
changes that might affect the code below.
 | 
						||
:::
 | 
						||
 | 
						||
### Write your tests
 | 
						||
 | 
						||
Playwright launches your app in development mode through the `_electron.launch` API.
 | 
						||
To point this API to your Electron app, you can pass the path to your main process
 | 
						||
entry point (here, it is `main.js`).
 | 
						||
 | 
						||
```js {5} @ts-nocheck
 | 
						||
const { test, _electron: electron } = require('@playwright/test')
 | 
						||
 | 
						||
test('launch app', async () => {
 | 
						||
  const electronApp = await electron.launch({ args: ['main.js'] })
 | 
						||
  // close app
 | 
						||
  await electronApp.close()
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
After that, you will access to an instance of Playwright's `ElectronApp` class. This
 | 
						||
is a powerful class that has access to main process modules for example:
 | 
						||
 | 
						||
```js {5-10} @ts-nocheck
 | 
						||
const { test, _electron: electron } = require('@playwright/test')
 | 
						||
 | 
						||
test('get isPackaged', async () => {
 | 
						||
  const electronApp = await electron.launch({ args: ['main.js'] })
 | 
						||
  const isPackaged = await electronApp.evaluate(async ({ app }) => {
 | 
						||
    // This runs in Electron's main process, parameter here is always
 | 
						||
    // the result of the require('electron') in the main app script.
 | 
						||
    return app.isPackaged
 | 
						||
  })
 | 
						||
  console.log(isPackaged) // false (because we're in development mode)
 | 
						||
  // close app
 | 
						||
  await electronApp.close()
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
It can also create individual [Page][playwright-page] objects from Electron BrowserWindow instances.
 | 
						||
For example, to grab the first BrowserWindow and save a screenshot:
 | 
						||
 | 
						||
```js {6-7} @ts-nocheck
 | 
						||
const { test, _electron: electron } = require('@playwright/test')
 | 
						||
 | 
						||
test('save screenshot', async () => {
 | 
						||
  const electronApp = await electron.launch({ args: ['main.js'] })
 | 
						||
  const window = await electronApp.firstWindow()
 | 
						||
  await window.screenshot({ path: 'intro.png' })
 | 
						||
  // close app
 | 
						||
  await electronApp.close()
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
Putting all this together using the Playwright test-runner, let's create a `example.spec.js`
 | 
						||
test file with a single test and assertion:
 | 
						||
 | 
						||
```js title='example.spec.js' @ts-nocheck
 | 
						||
const { test, expect, _electron: electron } = require('@playwright/test')
 | 
						||
 | 
						||
test('example test', async () => {
 | 
						||
  const electronApp = await electron.launch({ args: ['.'] })
 | 
						||
  const isPackaged = await electronApp.evaluate(async ({ app }) => {
 | 
						||
    // This runs in Electron's main process, parameter here is always
 | 
						||
    // the result of the require('electron') in the main app script.
 | 
						||
    return app.isPackaged
 | 
						||
  })
 | 
						||
 | 
						||
  expect(isPackaged).toBe(false)
 | 
						||
 | 
						||
  // Wait for the first BrowserWindow to open
 | 
						||
  // and return its Page object
 | 
						||
  const window = await electronApp.firstWindow()
 | 
						||
  await window.screenshot({ path: 'intro.png' })
 | 
						||
 | 
						||
  // close app
 | 
						||
  await electronApp.close()
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
Then, run Playwright Test using `npx playwright test`. You should see the test pass in your
 | 
						||
console, and have an `intro.png` screenshot on your filesystem.
 | 
						||
 | 
						||
```console
 | 
						||
☁  $ npx playwright test
 | 
						||
 | 
						||
Running 1 test using 1 worker
 | 
						||
 | 
						||
  ✓  example.spec.js:4:1 › example test (1s)
 | 
						||
```
 | 
						||
 | 
						||
:::info
 | 
						||
Playwright Test will automatically run any files matching the `.*(test|spec)\.(js|ts|mjs)` regex.
 | 
						||
You can customize this match in the [Playwright Test configuration options][playwright-test-config].
 | 
						||
It also works with TypeScript out of the box.
 | 
						||
:::
 | 
						||
 | 
						||
:::tip Further reading
 | 
						||
Check out Playwright's documentation for the full [Electron][playwright-electron]
 | 
						||
and [ElectronApplication][playwright-electronapplication] class APIs.
 | 
						||
:::
 | 
						||
 | 
						||
## Using a custom test driver
 | 
						||
 | 
						||
It's also possible to write your own custom driver using Node.js' built-in IPC-over-STDIO.
 | 
						||
Custom test drivers require you to write additional app code, but have lower overhead and let you
 | 
						||
expose custom methods to your test suite.
 | 
						||
 | 
						||
To create a custom driver, we'll use Node.js' [`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 title='testDriver.js' @ts-nocheck
 | 
						||
const childProcess = require('node:child_process')
 | 
						||
const electronPath = require('electron')
 | 
						||
 | 
						||
// spawn the process
 | 
						||
const env = { /* ... */ }
 | 
						||
const stdio = ['inherit', 'inherit', 'inherit', 'ipc']
 | 
						||
const 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 Node.js
 | 
						||
[`process`](https://nodejs.org/api/process.html) API:
 | 
						||
 | 
						||
```js title='main.js'
 | 
						||
// listen for messages from the test suite
 | 
						||
process.on('message', (msg) => {
 | 
						||
  // ...
 | 
						||
})
 | 
						||
 | 
						||
// send a 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. Let's start by creating
 | 
						||
a `TestDriver` class:
 | 
						||
 | 
						||
```js title='testDriver.js' @ts-nocheck
 | 
						||
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
 | 
						||
      const 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
 | 
						||
    const msgId = this.rpcCalls.length
 | 
						||
    this.process.send({ msgId, cmd, args })
 | 
						||
    return new Promise((resolve, reject) => this.rpcCalls.push({ resolve, reject }))
 | 
						||
  }
 | 
						||
 | 
						||
  stop () {
 | 
						||
    this.process.kill()
 | 
						||
  }
 | 
						||
}
 | 
						||
 | 
						||
module.exports = { TestDriver }
 | 
						||
```
 | 
						||
 | 
						||
In your app code, can then write a simple handler to receive RPC calls:
 | 
						||
 | 
						||
```js title='main.js'
 | 
						||
const METHODS = {
 | 
						||
  isReady () {
 | 
						||
    // do any setup needed
 | 
						||
    return true
 | 
						||
  }
 | 
						||
  // define your RPC-able methods here
 | 
						||
}
 | 
						||
 | 
						||
const onMessage = async ({ msgId, cmd, args }) => {
 | 
						||
  let method = METHODS[cmd]
 | 
						||
  if (!method) method = () => new Error('Invalid method: ' + cmd)
 | 
						||
  try {
 | 
						||
    const resolve = await method(...args)
 | 
						||
    process.send({ msgId, resolve })
 | 
						||
  } catch (err) {
 | 
						||
    const reject = {
 | 
						||
      message: err.message,
 | 
						||
      stack: err.stack,
 | 
						||
      name: err.name
 | 
						||
    }
 | 
						||
    process.send({ msgId, reject })
 | 
						||
  }
 | 
						||
}
 | 
						||
 | 
						||
if (process.env.APP_TEST_DRIVER) {
 | 
						||
  process.on('message', onMessage)
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
Then, in your test suite, you can use your `TestDriver` class with the test automation
 | 
						||
framework of your choosing. The following example uses
 | 
						||
[`ava`](https://www.npmjs.com/package/ava), but other popular choices like Jest
 | 
						||
or Mocha would work as well:
 | 
						||
 | 
						||
```js title='test.js' @ts-nocheck
 | 
						||
const test = require('ava')
 | 
						||
const electronPath = require('electron')
 | 
						||
const { TestDriver } = require('./testDriver')
 | 
						||
 | 
						||
const 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()
 | 
						||
})
 | 
						||
```
 | 
						||
 | 
						||
[chrome-driver]: https://sites.google.com/chromium.org/driver/
 | 
						||
[Puppeteer]: https://github.com/puppeteer/puppeteer
 | 
						||
[playwright-intro]: https://playwright.dev/docs/intro
 | 
						||
[playwright-electron]: https://playwright.dev/docs/api/class-electron/
 | 
						||
[playwright-electronapplication]: https://playwright.dev/docs/api/class-electronapplication
 | 
						||
[playwright-page]: https://playwright.dev/docs/api/class-page
 | 
						||
[playwright-releases]: https://playwright.dev/docs/release-notes
 | 
						||
[playwright-test-config]: https://playwright.dev/docs/api/class-testconfig#test-config-test-match
 | 
						||
[Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/
 |