I caught myself relying too much on manual tests for a feature I was building on top of a Stimulus controller. Manual tests were enough for the initial proof of concept phase. However as I was adding more features to the prototype, I was also causing regressions.
As I threw away the prototype and started over with a test-driven approach, I thought about how I test in general and how that applies to Stimulus.js
No jsdom-based tests with sample HTML
I'll start by stating how I do not test Stimulus controllers.
It would be possible to attach the controller to some fixture HTML and test things on top of jsdom. That approach would be reasonable for a library. But I'm not packaging a stimulus controller. I'm using it directly in my application.
In applications, the mistakes I commonly make with Stimulus controllers are all in the integration with the page. Maybe I forgot to set a target, or I have a typo in a value, or it's not listening to the right event.
Because these mistakes can happen at every use of a controller, I could get it right in the test code, but wrong in the production.
1-2 integration tests with Capybara (Rails system test)
What I do instead is test the functionality from a user's perspective in a Rails system test. The controller is an implementation detail that is tested implicitly.
Unit tests
Think about a Rails controller. With a handful of code paths, testing the controller is not much of a problem. But the more code paths and edge cases there are, the harder it becomes to prepare the initial test state for each scenario. Not to mention that all these tests exercise the request/response stack and are slow in aggregate.
Usually, with a Rails controller, I extract a domain object and put the business logic there. The controller is left with HTTP concerns: parsing parameters and headers for the request, setting headers and rendering the response.
A few tests would cover the integration between the domain object and the controller. The majority of tests test the domain object in isolation. Given these inputs, it has those outputs and/or side-effects.
I see a Stimulus controller the same way. It excels at being the glue between the HTML document and the business logic. It allows easily subscribing to events, reading properties and setting properties in the DOM. The core business logic doesn't need to directly depend on the DOM. It can be extracted and tested in isolation.
An example with a music player
Consider a music player on a web page. Browsers have a default user interface for audio and video elements, but they vary between browsers. We want the player to follow the brand guidelines. Also, the player can play playlists (say that five times faster). There is no web standard for that, so we have to implement it ourselves.
I like two things about this example. First, it's extracted (and slightly simplified) from a real application. Second, developers tend to not test things when it looks too hard and testing audio playback seemed too hard to do automatically to me at first.
How do I break down this feature and test it?
The HTML
The basis is a <video>
element. That can play both audio-only and video files.
The native player controls are hidden, but there are custom controls for play/pause as well as going to the previous and next track in a playlist.
Here are the relevant bits of the code:
<div data-controller="player">
<video
data-player-target="player"
data-action="
playing->player#playing
pause->player#paused
ended->player#ended
playable:play@window->player#play
"
data-testid="player">
</video>
<div>
<button data-action="player#previous">
Previous
</button>
<button data-player-target="playButton" data-action="player#resume">
Play
</button>
<button data-player-target="pauseButton" data-action="player#pause" class="hidden">
Pause
</button>
<button data-action="player#next">
Next
</button>
</div>
</div>
Notice the video element is a Stimulus target. It also has event listeners attached. These allow us to keep the player controls in sync with the player state.
Separation of concerns with cross-controller communication
Playback starts when pressing the play button of a song or a playlist on the page. There's a separate controller that dispatches a custom event. The following Stimulus action is what listens to play request events and reacts by actually starting the payback:
data-action="playable:play@window->player#play"
This follows the pattern for Stimulus.js cross-controller communication with events.
In order to keep the example focused, I won't show this controller's code. The cross-controller communication is covered by the system test that I show at the end.
The player controller
The player controller looks roughly like this:
import { Controller } from "@hotwired/stimulus"
import PlayableList from "../player/playable_list"
export default class extends Controller {
static targets = ["playButton", "pauseButton", "player"]
static classes = ["hidden"]
connect() {
this.playableList = new PlayableList()
}
/*
* API
*/
play(event) {
this.playableList.playNow(event.detail)
this.switchTo(this.playableList.currentTrack)
}
resume() {
// ...
}
pause() {
// ...
}
next() {
let old = this.playableList.currentTrack
this.playableList.skipForward()
if (old !== this.playableList.currentTrack) {
this.switchTo(this.playableList.currentTrack)
}
}
previous() {
// ...
}
switchTo(track) {
this.playSource(track.source)
}
playSource(source) {
this.playerTarget.src = source
this.playerTarget.play().catch((error) => this.handleError(error))
}
handleError(error) {
this.playerTarget.pause()
}
/*
* Events
*
* Named after HTMLMediaElement events
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#events
*/
playing(event) {
this.hidePlayButton()
this.showPauseButton()
}
paused(event) {
this.showPlayButton()
this.hidePauseButton()
}
ended(event) {
this.showPlayButton()
this.hidePauseButton()
this.next()
}
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError
error(event) {
handleError(event.error)
}
/*
* User interface
*/
hidePlayButton() {
// ...
}
showPlayButton() {
// ...
}
hidePauseButton() {
// ...
}
showPauseButton() {
// ...
}
}
The core business logic and its tests
The player controller can hold player state, but it doesn't have to know the implementation details. Those details are in what I call here PlayableList
, which controls moving back and forth in the list of tracks.
export default class PlayableList {
constructor({ history = [], current = null, queue = [] } = {}) {
this.history = history
this.current = current
this.queue = queue
}
skipBackward() {
// ...
}
skipForward() {
// ...
}
playNow(playable) {
// ...
}
get currentTrack() {
// ...
}
}
It's precisely thanks to that abstraction that we can now easily unit test. Look at the following test for example.
import PlayableList from "player/playable_list"
import Track from "player/track"
describe("PlayableList", () => {
test("skipping to next then previous returns to the initial item", () => {
let track1 = new Track({title: "track1"})
let track2 = new Track({title: "track2"})
let list = new PlayableList({
current: track1,
queue: [track2]
})
list.skipForward()
list.skipBackward()
expect(list).toEqual(new PlayableList({
history: [track1],
current: track1,
queue: [track2]
}))
})
})
The assertion is comparing two data structures (Jest's toEqual
matcher does a "deep" equality check).
The JavaScript unit tests all look like that. They are fast, they are short, they are clear.
I used Jest for the JavaScript tests, but the test framework doesn't matter. I only needed to configure two things to make these unit tests run with Jest: the location of the test files (testMatch
) and the location of the production code (moduleDirectories
). This is my jest.config.js
:
module.exports = {
testMatch: ['**/test/**/*.test.js'],
cacheDirectory: './tmp/cache/jest',
transformIgnorePatterns: ['node_modules'],
moduleDirectories: ['node_modules', 'app/javascript']
};
There's a common argument that modifying code just for the sake of testing is an anti-pattern. I have two counter arguments specific to this example:
- When parts of the business logic are self-contained, it becomes easier to focus on the individual modules. I've hit enough edge cases that I was glad to be able to quickly modify that part of the code in isolation and test it in isolation.
- An older version of this app had JavaScript controllers with a similar structure, but not powered by Stimulus.js, and the playlist management logic was intertwined in a controller. If the
PlayableList
abstraction had existed, it would have made the transition to Stimulus faster.
The system test
Now let's look at the system test. I've hard-coded some strings to make the excerpt easier to read outside of the application.
require "application_system_test_case"
class PlayerTest < ApplicationSystemTestCase
driven_by :selenium, using: :headless_chrome
test "playing a playlist" do
visit '/playlists/1'
find_button("Play Playlist 1").click
find_button("Pause")
player = find("[data-testid=player]")
assert_playing(player)
first_source = player["src"]
find_button("Next").click
assert_playing(player)
assert first_source != player["src"]
end
def assert_playing(element)
Timeout.timeout(Capybara.default_max_wait_time) do
playing = false
loop do
playing = page.evaluate_script(<<~JS, element)
(function(element) {
return (
element.duration > 0 &&
!element.paused &&
!element.ended &&
element.readyState > 2
)
})(arguments[0])
JS
break if playing
end
assert playing
end
end
end
It checks the common path of visiting a page and pressing play. It even tests pressing "Next" for good measure. If the workflow were even longer, I'd still put it all in one system test. I like it when a system test/feature test tells a complete story. I also like to avoid repeating expensive setup and teardown between tests when possible.
The usual advice about browser-based tests applies:
- minimize race conditions by first waiting for things to be on the page, or be in a stable state
- lean on Capybara's retry-aware finders
- implement custom auto retry logic if really necessary
In the past, I've resorted to using Cypress for tests of complex JavaScript code. For this player system test, I tried Playwright directly in a Capybara test with the Playwright Ruby driver. In the end, I realized the Capybara evaluate_script
helper is enough to query the player state. That's lucky, because sticking to the Rails defaults reduces the long-term maintenance burden.
Defaults matter
I think part of the reason most Rails projects lack JavaScript tests is lack of examples.
The Rails documentation includes a testing guide. Rails generators create test files and placeholders for Ruby tests. But there are no examples with JavaScript tests.
I hope my example above inspires you to write more JavaScript tests for your application.