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.
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.
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.
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.
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.
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.
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
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!