Back to blog

Testing Stimulus - minimalistic approach

Posted on September 20, 2024 00:00
stimulus testing

While writing this article, Stimulus did not yet provide clear documentation on testing Stimulus controllers.
The approach presented below is very minimalist and introduces almost no dependencies into our system. It does not require any dedicated test runner or transpiler.

Let’s start by outlining the problem. Assume we want to test the behavior of the following controller.

<div data-controller="test">
    <a id="test-button" data-action="click->test#test" href="#">Open Modal</a>
    <span data-test-target="output"></span>
</div>
class extends Controller {
    static targets = ["output"];

    test() {
        this.outputTarget.textContent = "Hello World!";
    }
})

The task of this controller is to place the text "Hello World!" inside a span element (marked as the output target).

Let’s start by preparing all the dependencies. We’re taking an absolutely minimalist approach, and the only dependency we’ll introduce into our system (apart from NodeJS v20 and Stimulus) is JSDOM.

This is how the package.json file should look:

{
  "name": "stimulus-test",
  "version": "1.0.0",
  "description": "",
  "scripts": {},
  "author": "",
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "jsdom": "^25.0.0"
  }
}

Before we start writing the tests, we need to prepare the runtime environment.
Stimulus runs in the browser, which means the browser provides it with many global objects/functions that Stimulus internally uses.

To recreate this environment, we will prepare a simple createWindow function, whose task will be to globally expose the Window object and a few others, which come directly from the JSDOM library.

This is how the bootstrap.js file might look.

const JSDOM = require("jsdom").JSDOM;

/**
 * Creates a global window object with the given HTML.
 * Expose all global objects used by Stimulus.
 *
 * @param {string} html
 */
function createWindow(html) {
    global.window = new JSDOM(html).window;
    global.document = window.document;
    global.MutationObserver = window.MutationObserver;
    global.KeyboardEvent = window.KeyboardEvent;
    global.MouseEvent = window.MouseEvent;
    global.Element = window.Element;
    global.Node = window.Node;
}

module.exports = createWindow;

The createWindow function expects HTML code that will be placed in the virtual body object of the virtual document object nested in the virtual window element, which is exposed to the runtime environment via global.window.

Okay, now we have all the necessary building blocks, and we can proceed with writing the actual test.

const { test }  = require('node:test');
const assert = require('node:assert').strict;
const { Application, Controller } = require('@hotwired/stimulus');
const createWindow = require('./bootstrap');

test('connecting modal controller', async () => {

    createWindow(`
        <div data-controller="test">
            <a id="test-button" data-action="click->test#test" href="#">Open Modal</a>
            <span data-test-target="output"></span>
        </div>
    `);

    const application = Application.start();
    application.register("test", class extends Controller {
        static targets = ['output'];

        connect() {
        }

        test() {
            this.outputTarget.textContent = "Hello World!";
        }
    });

    await new Promise(resolve => setTimeout(resolve, 0));

    document.getElementById('test-button').dispatchEvent(new MouseEvent('click'));

    assert.equal(
        document.querySelector('[data-test-target="output"]').textContent,
        "Hello World!"
    );
});

Let’s take a look at the individual elements of this test.

Since Stimulus controllers are registered asynchronously, we need to wait a little while. To do this, after registering the controller, we will introduce an artificial delay.

const application = Application.start();
application.register("test", OurController);

await new Promise(resolve => setTimeout(resolve, 0));

To ensure that everything is working properly, we can add console.log() to the connect() method inside our controller. If after running the test using node --test ./test.js, we see our message in the console, it means that Stimulus and the controller have been correctly registered.

This essentially concludes the preparation phase. Everything beyond this point is just the logic of our test.

The logic involves simulating a click on the element and checking via assertions whether the content of the target element has changed.

In order to execute our test, please run following command in the cli:

node --test ./test.js