Skip to content

Instantly share code, notes, and snippets.

@markgandolfo
Created April 29, 2025 22:13
Show Gist options
  • Save markgandolfo/a2f5cf66ee1c622282be1ec4a198fd13 to your computer and use it in GitHub Desktop.
Save markgandolfo/a2f5cf66ee1c622282be1ec4a198fd13 to your computer and use it in GitHub Desktop.
Stimulus Controller testing in rails/jest
// This helper file provides a consistent API for testing Stimulus Controllers
// From: https://gist.github.com/bholtbholt/c8351665a861aee62e915d8b32e2c759
// put the file in /spec/javascript/support/_stimulus-helper.js
//
// Use:
// import { getHTML, setHTML, startStimulus } from './_stimulus_helper';
// import MyController from '@javascripts/controllers/my_controller';
//
// beforeEach(() => startStimulus('my', MyController));
// test('should do something', async () => {
// await setHTML(`<button data-controller="my" data-action="my#action">click</button>`);
//
// const button = screen.getByText('click');
// await button.click();
//
// expect(getHTML()).toEqual('something');
// });
//
import { Application } from "@hotwired/stimulus";
// Initializes and registers the controller for the test file
// Use it in a before block:
// beforeEach(() => startStimulus('dom', DomController));
//
// @name = string of the controller
// @controller = controller class
//
// https://stimulus.hotwired.dev/handbook/installing#using-other-build-systems
export function startStimulus(name, controller) {
const application = Application.start();
application.register(name, controller);
}
// Helper function for setting HTML
// - It trims content to prevent false negatives
// - It's async so there's time for the Stimulus controller to load
//
// Use within tests:
// await setHTML(`<p>My HTML Content</p>`);
export async function setHTML(content = "") {
document.body.innerHTML = content.trim();
return document.body.innerHTML;
}
// Helper function for getting HTML content
// - Trims content to prevent false negatives
// - Is consistent with setHTML
//
// Use within tests:
// expect(getHTML()).toEqual('something');
export function getHTML() {
return document.body.innerHTML.trim();
}
// Example test.
// it'll live in /spec/javascript/controllers/<filename>>controller.test.js
import { screen } from "@testing-library/dom";
import TruncatableTextController from "controllers/truncatable_text_controller.js";
import { setHTML, startStimulus } from "../support/_stimulus-helper.js";
beforeEach(() => startStimulus("truncatable-text", TruncatableTextController));
test("should toggle content visibility when buttons are clicked", async () => {
await setHTML(`
<div data-controller="truncatable-text">
<div data-truncatable-text-target="truncatedContent">
Truncated Content
<button data-action="click->truncatable-text#showFull">Show more</button>
</div>
<div data-truncatable-text-target="fullContent" class="hidden">
Full Content
<button data-action="click->truncatable-text#showLess">Show less</button>
</div>
</div>`);
// Get elements
const truncatedContent = screen
.getByText(/Truncated Content/i)
.closest('[data-truncatable-text-target="truncatedContent"]');
const fullContent = screen
.getByText(/Full Content/i)
.closest('[data-truncatable-text-target="fullContent"]');
const showMoreButton = screen.getByText("Show more");
// Initial state
expect(truncatedContent).not.toHaveClass("hidden");
expect(fullContent).toHaveClass("hidden");
// Click "Show more"
showMoreButton.click(showMoreButton);
// Verify state after clicking "Show more"
expect(truncatedContent).toHaveClass("hidden");
expect(fullContent).not.toHaveClass("hidden");
// Click "Show less"
const showLessButton = screen.getByText("Show less");
showLessButton.click(showLessButton);
// Verify state is back to initial
expect(truncatedContent).not.toHaveClass("hidden");
expect(fullContent).toHaveClass("hidden");
});
// jest.config.js
module.exports = {
// Where Jest should look for test files
roots: ["<rootDir>/spec/javascript"],
// The environment Jest should use to run tests
// 'jsdom' simulates a browser environment, 'node' uses Node.js environment
testEnvironment: "jsdom",
// File patterns Jest should consider as test files
testMatch: [
"**/spec/javascript/**/*.test.js",
"**/spec/javascript/**/*.spec.js",
],
// Module Name Mapper: THIS IS KEY FOR IMPORTMAPS
// This tells Jest how to resolve module paths that you use in your import statements,
// mimicking how importmap-rails resolves them in the browser.
moduleNameMapper: {
"^application$": "<rootDir>/app/javascript/application.js",
"^helpers/(.*)$": "<rootDir>/app/javascript/helpers/$1",
"^controllers/(.*)$": "<rootDir>/app/javascript/controllers/$1",
},
transform: {
"^.+\\.js$": "babel-jest",
},
transformIgnorePatterns: ["node_modules/(?!(@hotwired)/)"],
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
};
import "@testing-library/jest-dom";
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/preset-env": "^7.26.9",
"@hotwired/stimulus": "^3.2.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment