Write Automated Tests for Electron with Spectron, Mocha, and Chai

In this article, you will learn how to test your Electron-based desktop application with Mocha, Chai, and Spectron. You will learn how to set up the test environment and run automated integration or E2E tests with Spectron, Mocha, and Chai. Furthermore, a short introduction to CSS selectors is given.

1. Cross-platform Apps with Electron

Electron allows us to write cross-platform apps for Windows, Linux, and Mac based on the same code base. Thereby, we develop our desktop applications using HTML, CSS, and Javascript (or Typescript). Finally, we package our Electron app with electron-packager or electron-builder and ship it as OS-specific bundle (.app, .exe, etc.). The application itself runs on Chromium. In this article, I will give an introduction on how to setup the test environment for automated end-to-end test or integration tests for your Electron application.

2. Automated Test Environment

Mocha is a JavaScript test framework which runs on Node.js. Mocha allows us to use any assertion library we like. In the following, I will use Chai with should-style assertions.

Chai is a BDD / TDD assertion library for Node.js. Furthermore, we will use Chai as Promised which extends Chai for asserting facts about promises. Thereby, we can transform any Chai assertion into one that acts on a promise.

Spectron is an open source framework for easily writing automated integrations and end-to-end tests for Electron. It provides capabilities for navigating on web pages, simulating user input, and many more. It also simplifies the setup, running, and tearing down of the test environment for Electron.

Through Spectron, you have the entire Electron API and Chromium API available within your tests. Thereby, we can test our Electron apps using ChromeDriver and WebdriverIO.

Finally, we can also run the tests on continuous integration services and build servers, such as Travis and Jenkins.

3. Environment Setup

First, we install Spectron via npm as development dependency:

npm install --save-dev spectron

You may want to install chai and chai-as-promised as well.

npm install --save-dev chai
npm install --save-dev chai-as-promised

Chai is a BDD / TDD assertion library for node and Chai-as-promised extends it with assertions about promises.

Also install @types/mocha, electron-chromedriver, mocha, and spectron as dev dependencies.

Finally, your devDependencies in your package.json should look like this (perhaps with newer version numbers):

"devDependencies": {
   "@types/mocha": "2.2.43",
   "chai": "4.1.2",
   "chai-as-promised": "7.1.1",
   "electron-chromedriver": "1.7.1",
   "mocha": "3.5.3",
   "spectron": "3.7.2"
}

Setting up the Mocha Test Runner

Next, we go to our package.json file to set up the test commands. A minimal configuration calls the Mocha test runner without any parameter settings.

"scripts": {
    "test": "mocha"
}

When we execute npm run test Mocha is executed looking for files defining tests (can be a unit test or an integration test).

The following command tells Mocha to only run tests contained at a specific location. The grep command tells it to only consider files with a certain file name. The –watch  parameter runs tests on changes to JavaScript files in the defined directory and once initially.

mocha {{folder/with/tests}} --grep {{^regex$}} --watch

I have created two scripts. One is for running all integration tests in the test folder. The other one runs only one specific test everytime the test or the class under test is changed.

test:all runs mocha

  • with mocha-jenkins-reporter as reporter,
  • sets the timeout to 20 seconds,
  • tells Mocha to only look for tests in the src/test folder within files whose names start with test- and end with .js
"test:all": "mocha -R mocha-jenkins-reporter --timeout 20000 \"./src/test/**/*test-*.js\""

test:one tells mocha to watch for code changes and to re-run the test. We use grep to run only tests where the name fits the given string.

"test:one": "mocha --watch -u bdd \"./src/test/**/test-*.js\" --grep \"test-login-form\""

Setting up Spectron

I have written an extra class where I have a function to launch Spectron which then creates a new Application class that when configured, starts and stops our Electron application via app.start()  and app.stop() .

Therefore, we have to specify the path to the electron batch file as well as to our dist folder. Here, I am setting the start timeout to 20 seconds. By setting the property chromeDriverLogPath, Chromedriver is activated to execute logging.

function initialiseSpectron() {
   let electronPath = path.join(__dirname, "../../node_modules", ".bin", "electron");
   const appPath = path.join(__dirname, "../../dist");
   if (process.platform === "win32") {
       electronPath += ".cmd";
   }

   return new Application({
       path: electronPath,
       args: [appPath],
       env: {
           ELECTRON_ENABLE_LOGGING: true,
           ELECTRON_ENABLE_STACK_DUMPING: true,
           NODE_ENV: "development"
       },
       startTimeout: 20000,
       chromeDriverLogPath: '../chromedriverlog.txt'
  });
}

Have a look in the application API of Spectron to find out more about the configuration.

4. Writing Automated Tests

After setting up our test environment, we will write an integration test using Mocha and Chai. I am going to write a test for a login form written in Angular 4 using reactive forms which I have presented in this article.

We create a new javascript class in our test folder which we call test-login-form.js. First, we import all libraries and call our Spectron helper class.

const testhelper = require("./spectron-helper");
const app = testhelper.initialiseSpectron();

const chaiAsPromised = require("chai-as-promised");
const chai = require("chai");
chai.should();
chai.use(chaiAsPromised);

Here, I am using the should style which allows to chain assertions by starting with should property. You can also use the expect or the assert style.

Life cycle hooks

Similar to JUnit, Mocha offers functions for initial set up, cleaning, and tear down actions. These life cycle hooks are called before()beforeEach()after(), and afterEach().

We use the before hook to set up Chai-as-promised and to start our Electron application via Spectron.

before(function () {
   chaiAsPromised.transferPromiseness = app.transferPromiseness;
   return app.start();      
});

The after function is used to tear down everything and to stop the application.

after(function () {
   if (app && app.isRunning()) {
       return app.stop();
   }
});

Defining test beds and tests

We define a test called Login via

describe("Login", function () { 
   it('open window', function () {
   }
});

Initially, I would to test if the application was successfully initialised. Thereby, a window should be created. We test this via

it('open window', function () {
   return app.client.waitUntilWindowLoaded().getWindowCount().should.eventually.equal(1);
});

Remember, app is our Electron application. Client is the renderer process. This simple example also shows how Chai-as-promised can be used with Spectron. By appending should.eventually.equal(1) we create a promise which is resolved when a window is loaded before the default timeout of five seconds is over. Otherwise, the promise is rejected and the test fails.

Simulate actions and select elements with CSS selectors

We can also simulate clicks and filling out forms.

it("go to login view", function () {
    return app.client.element('#sidebar').element("a*=credentials").click();
});

This test searches for a DOM element with the ID #sidebar. Within this element it looks for another element which matches the CSS selector a*=credentials . Finally, we simulate a click on this element (in this case a link to another view).

For a reference on CSS selectors, I would recommend to have a look on this and this page.

You can simulate a click on a button <button>Store login credentials</button> whose text includes Store by using click and the *-selector. Use = to search for an exact match.

click('button*=Store')

For more complex selections you can also chain elements. The next example searches for elements within the form which are of type input with the formcontrolName username.

element('form input[formControlName="username"]');

To check if the username field is initially empty and has focus, we execute this test:

it("test initial form", function () {
   return app.client
     .getText(usernameInput).should.eventually.equal('')
     .hasFocus(usernameInput);
}

Wrap-up

I hope that this articles gave you a good first impression on how you to setup your own test environment for your Electron application with Spectron, Mocha, and Chai.

Finally, here is the full test class.

const testhelper = require("./spectron-helper");
const app = testhelper.initialiseSpectron();

const chaiAsPromised = require("chai-as-promised");
const chai = require("chai");
chai.should();
chai.use(chaiAsPromised);

describe("test-login-form", function () {
    // CSS selectors
    const usernameInput = 'form input[formControlName="username"]';
    const usernameError = 'form input[formControlName="username"] + div';
    const passwordInput = 'form input[formControlName="password"]';
    const passwordError = 'form input[formControlName="password"] + div';
    const submitButton = 'button*=Store';

    // Start spectron
    before(function () {
        chaiAsPromised.transferPromiseness = app.transferPromiseness;
        return app.start();
    });

    // Stop Electron
    after(function () {
        if (app && app.isRunning()) {
            return app.stop();
        }
    });

    describe("Login", function () {
        // wait for Electron window to open
        it('open window', function () {
            return app.client.waitUntilWindowLoaded().getWindowCount().should.eventually.equal(1);
        });

        // click on link in sidebar
        it("go to login view", function () {
            return app.client.element('#sidebar').element("a*=credentials").click();
        });

        // Initially, the submit button should be deactivated
        it("test initial form", function () {
            return app.client
                .waitUntilTextExists('h1', 'user credentials')
                .element(submitButton).isEnabled().should.eventually.equal(false)
                .getText(usernameInput).should.eventually.equal('')
                .hasFocus(usernameInput)
                .getText(passwordInput).should.eventually.equal('');
        });

        // The password required validator should be activated when the textfield is empty
        it("test password validator", function () {
            return app.client
                .click(passwordInput)
                .click(usernameInput)
                .getText('.k-textbox.ng-invalid').should.eventually.exist
                .element(submitButton).isEnabled().should.eventually.equal(false)
        });

        // The username required validator should be activated when the textfield is empty
        it("test username validator", function () {
            return app.client
                .click(passwordInput)
                .click(usernameInput)
                .getText('.k-textbox.ng-invalid').should.eventually.exist
                .element(submitButton).isEnabled().should.eventually.equal(false)
        });

        // The password minimal length validator should be activated when the password is not long enough
        it("test minimal length validator", function () {
            return app.client
                .setValue(usernameInput, 'name')
                .setValue(passwordInput, 'tooshort')
                .getText(usernameError).should.eventually.include('must be at least')
                .getText(passwordError).should.eventually.include('must be at least')
                .element(submitButton).isEnabled().should.eventually.equal(false)
        });

        // Simulate filling out the form with valid values
        it("fill out form", function () {
            return app.client
                .setValue(usernameInput, 'myusername')
                .setValue(passwordInput, 'mypassword')
                .element(submitButton).isEnabled().should.eventually.equal(true)
        });

        // Simulate submitting the form
        it("submit form", function () {
            return app.client
                .click(submitButton)
                .waitUntilTextExists('h1', 'Project details')
                .getText('header').should.eventually.include('myusername')
                .element('simple-snack-bar').should.eventually.exist;
        });
    });
});