← til

Testing Stimulus

January 19, 2019
stimulus

Since the official Stimulus handbook doesn't include testing, and I recently had to cover a Stimulus controller with tests, I'd like to share my approach.

Install dependencies

The first step is installing the required npm packages to make this work.

yarn add --dev jest
yarn add --dev babel-jest babel-preset-es2015
yarn add --dev mutationobserver-shim

We're going to use Jest for testing; Babel packages to make it play nice with Jest and mutationobserver-shim for shimming MutationObserver API which is required by Stimulus. We need that shim since Jest uses jsdom by default and jsdom doesn't have support for MutationObserver as of this writing.

Instruct Jest to use the MutationObserver shim

Place this in ./test/javascript/setup.js:

// Setup MutationObserver shim since jsdom doesn't
// support it out of the box.

const fs = require("fs");
const path = require("path");

const shim = fs.readFileSync(
 path.resolve(
    "node_modules",
    "mutationobserver-shim",
    "dist",
    "mutationobserver.min.js"
  ),
  { encoding: "utf-8" }
);
const script = window.document.createElement("script");
script.textContent = shim;

window.document.body.appendChild(script);

We need to tell Jest to lookup this file before each test and execute it. Place this in package.json:

 "jest": {
   "testRegex": ["test/javascript/.*_test\\.js"],
   "setupFiles": [
     "./test/javascript/setup.js"
   ]
 }

The "jest" property should be added to the top level.

Write your first test

Now that we have our shim in place, we need to write our first failing test.

We need to set up Babel to play nice with Jest first. To do that, add "es2015" to "presets". Place this in your .babelrc:

{ "presets": ["@babel/env"] }

Place this in test/javascript/first_test.js:

describe("Failing test", () => {
  it("Fails spectacularly", () => {
    expect(false).toEqual(true);
  });
});

And run it with:

yarn run jest test/javascript/first_test.js

Congrats, you should see your first test fail! Woohoo!

If you have some linter running, it will complain about "describe" and "it". In my case that was eslint, so I had to place this in .eslintrc:

module.exports = {
  env: {
    jest: true,
    // rest of env config
  }
}

Once you have set that up, let's fix our failing test. Replace your test/javascript/first_test.js with:

describe("Passing test", () => {
  it("Passes spectacularly", () => {
    expect(true).toEqual(true);
  });
});

Run it with the same command:

yarn run jest test/javascript/first_test.js

And you should see the test passing.

Write a Stimulus controller test

Now that we've written the trivial assertion let's get serious and write a real Stimulus controller test.

Put this in test/javascript/basic_controller_test.js:

import { Application, Controller } from "stimulus";

class BasicController extends Controller {
  static targets = ["input", "output"];

  copy() {
    this.targets.find("output").value =
      this.targets.find("input").value;
  }
}

describe("BasicController", () => {
  describe("#copy", () => {
    beforeEach(() => {
      document.body.innerHTML = `<div data-controller="basic">
        <input id="input" data-target="basic.input" />
        <input id="output" data-target="basic.output" />
        <button id="button" data-action="basic#copy" />
      </div>`;

      const application = Application.start();
      application.register("basic", BasicController);
    });

    it("copies input and sets it on output", () => {
      const input = document.getElementById("input");
      const output = document.getElementById("output");
      const button = document.getElementById("button");

      input.value = "foo";
      button.click();

      expect(output.value).toEqual("bar");
    });
  });
});

And run it with:

yarn run jest test/javascript/basic_controller_test.js

This test should fail, which means you have just written your first failing Stimulus test.

To make it pass, replace the expected value to "foo":

import { Application, Controller } from "stimulus";

class BasicController extends Controller {
  static targets = ["input", "output"];

  copy() {
    this.targets.find("output").value =
      this.targets.find("input").value;
  }
}

describe("BasicController", () => {
  describe("#copy", () => {
    beforeEach(() => {
      document.body.innerHTML = `<div data-controller="basic">
        <input id="input" data-target="basic.input" />
        <input id="output" data-target="basic.output" />
        <button id="button" data-action="basic#copy" />
      </div>`;

      const application = Application.start();
      application.register("basic", BasicController);
    });

    it("copies input and sets it on output", () => {
      const input = document.getElementById("input");
      const output = document.getElementById("output");
      const button = document.getElementById("button");

      input.value = "foo";
      button.click();

      // This line has changed its expectations.
      expect(output.value).toEqual("foo");
    });
  });
});

Congrats! This test should pass now, which means you have just written your first passing Stimulus controller test.

Write a more complex Stimulus controller test

It's time to write something more complex; real Stimulus controllers usually do something more complicated than copying a value from one input to another. Like copying a value from input to a paragraph. It can't get more complicated than that! Just kidding, I'd like to show how to handle some non-default actions with this example.

Put this in test/javascript/advanced_controller_test.js:

import { Application, Controller } from "stimulus";

class AdvancedController extends Controller {
  static targets = ["result"];

  valueChanged() {
    this.targets.find("result").
      innerHTML = "Some input has changed its value";
  }
}

const changeValue = (element, value, eventType) => {
  const event = new Event(eventType);
  element.value = value;
  element.dispatchEvent(event);
};

describe("AdvancedController", () => {
  describe("#valueChanged", () => {
    beforeEach(() => {
      document.body.innerHTML = `<div data-controller="advanced">
        <input id="input" data-action="keyup->advanced#valueChanged"/>

        <p id="result" data-target="advanced.result"/>
      </div>`;

      const application = Application.start();
      application.register("advanced", AdvancedController);
    });

    it("copies input and sets it on output", () => {
      const input = document.getElementById("input");
      const result = document.getElementById("result");

      changeValue(input, "foo", "keyup");

      expect(result.innerHTML).
        toEqual("This will fail, right?");
    });
  });
});

And run it with Jest again:

yarn run jest test/javascript/advanced_controller_test.js

Congrats if it fails! Change the expectation to match the actual text inside a paragraph.

import { Application, Controller } from "stimulus";

class AdvancedController extends Controller {
  static targets = ["result"];

  valueChanged() {
    this.targets.find("result").
      innerHTML = "Some input has changed its value";
  }
}

const changeValue = (element, value, eventType) => {
  const event = new Event(eventType);
  element.value = value;
  element.dispatchEvent(event);
};

describe("AdvancedController", () => {
  describe("#valueChanged", () => {
    beforeEach(() => {
      document.body.innerHTML = `<div data-controller="advanced">
        <input id="input" data-action="keyup->advanced#valueChanged"/>

        <p id="result" data-target="advanced.result"/>
      </div>`;

      const application = Application.start();
      application.register("advanced", AdvancedController);
    });

    it("copies input and sets it on output", () => {
      const input = document.getElementById("input");
      const result = document.getElementById("result");

      changeValue(input, "foo", "keyup");

      // This line has changed its expectations.
      expect(result.innerHTML).
        toEqual("Some input has changed its value");
    });
  });
});

The test should pass now.

Note how we needed to explicitly dispatch the type of event we wanted on the input element. We need to do that for any event when testing Stimulus controllers. We had used the HTMLElement.click() helper in the basic test for simulating a click on an element, but unfortunately those helpers don't exist for most of the other events.

Configure your environment for the real world

Now that you know how to test a Stimulus controller, you might be interested in how to set up your environment for testing your Stimulus application.

You probably don't write Stimulus controllers in your test files, as we did here, so you'll need to instruct Jest how to import those controllers. I'm going to assume your Stimulus controllers are located in app/javascript/controllers.

To tell Jest where to look for your controllers, use "moduleDirectories" property in Jest configuration.

You also probably want to run all your JavaScript tests with a single command, since you'll probably end up with one test file per controller. I'm going to assume your JavaScript tests are in test/javascript and each test ends with _test.js.

To tell Jest where to look for your tests, use "roots" and "testRegex" properties in Jest configuration.

Here's how that Jest configuration would look like in package.json:

"jest": {
  "setupFiles": [
    "./test/javascript/setup.js"
  ],
  "testRegex": ".*_test.js",
  "roots": [
    "test/javascript"
  ],
  "moduleDirectories": [
    "node_modules",
    "app/javascript/controllers"
  ]
}

Let's say we have moved our AdvancedController from the test file to app/javascript/controllers/advanced_controller.js and added the correct export to the end of that file:

import { Controller } from "stimulus";

class AdvancedController extends Controller {
  // rest of the controller copied from the test
}

export default AdvancedController;

The "moduleDirectories" property enables us to import those Stimulus controllers very easily in tests now:

import { Application } from "stimulus";
import AdvancedController from "advanced_controller";

const changeValue = (element, value, eventType) => {
  const event = new Event(eventType);
  element.value = value;
  element.dispatchEvent(event);
};

describe("AdvancedController", () => {
  describe("#valueChanged", () => {
    beforeEach(() => {
      document.body.innerHTML = `<div data-controller="advanced">
        <input id="input" data-action="keyup->advanced#valueChanged"/>

        <p id="result" data-target="advanced.result"/>
      </div>`;

      const application = Application.start();
      application.register("advanced", AdvancedController);
    });

    it("copies input and sets it on output", () => {
      const input = document.getElementById("input");
      const result = document.getElementById("result");

      changeValue(input, "foo", "keyup");

      // This line has changed its expectations.
      expect(result.innerHTML).
        toEqual("Some input has changed its value");
    });
  });
});

We can also run all our JavaScript tests with a single command, by configuring the standard "scripts" property in package.json:

"scripts": {
  "test": "jest"
}

We can now run JavaScript tests with:

yarn test

Set up Rake to run JavaScript tests

If you're using Rails, you probably run all your tests with rake. To include JavaScript tests in your default task, add this to your Rakefile:

task default: ['test:system', 'test', 'test:js']

namespace :test do
  task :js do
    sh 'yarn test'
  end
end

It's so easy to run JavaScript tests now with:

rake test:js

Or all tests with:

rake

That's it; you're now ready to test your Stimulus controllers!