Stimulus Testing — Complete Guide with Examples
In this tutorial, you'll learn about Stimulus Testing. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Stimulus testing validates controller behavior by simulating DOM interactions, lifecycle events, and value changes, ensuring components work correctly before deployment.
What You'll Learn
- Setting up Jest for Stimulus testing
- Unit Testing controller methods
- Testing with DOM integration using
jsdom - Simulating lifecycle: connect, disconnect
- Testing targets, values, classes, and actions
- Testing outlets and cross-controller communication
- Mocking fetch and async operations
Why It Matters
Untested controllers break silently. A misspelled target name, wrong value type, or missing outlet method can crash a page. Automated tests catch these errors during development. In the Doda Browser extension, every controller has unit tests that verify behavior across different configurations, ensuring extensions work reliably across browser versions.
Learning Path
flowchart LR A[TypeScript] --> B[Testing] B --> C[Project] B --> D[Real Projects:
DodaTech Tools] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style D fill:#059669,color:#fff
Setup
Installation
npm install -D jest @testing-library/dom @testing-library/jest-dom jest-environment-jsdom
jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['@testing-library/jest-dom'],
moduleNameMapper: {
'^@hotwired/stimulus$': '@hotwired/stimulus/dist/stimulus.js'
}
};
Testing Setup Helper
Create a test helper for Stimulus controllers:
import { Application, Controller } from '@hotwired/stimulus';
export function setupTest() {
const app = Application.start();
const container = document.createElement('div');
document.body.appendChild(container);
return {
app,
container,
registerController(name, controllerClass) {
app.register(name, controllerClass);
},
cleanup() {
document.body.removeChild(container);
app.stop();
}
};
}
Testing Controller Lifecycle
Connect and Disconnect
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class TestController extends Controller {
connect() {
this.element.dataset.connected = 'true';
}
disconnect() {
this.element.dataset.connected = 'false';
}
}
describe('TestController', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('connects when element is added to DOM', () => {
test.container.innerHTML = `<div data-controller="test"></div>`;
test.registerController('test', TestController);
const element = test.container.querySelector('[data-controller="test"]');
expect(element.dataset.connected).toBe('true');
});
it('disconnects when element is removed from DOM', () => {
test.container.innerHTML = `<div data-controller="test"></div>`;
test.registerController('test', TestController);
const element = test.container.querySelector('[data-controller="test"]');
element.remove();
expect(element.dataset.connected).toBe('false');
});
});
Testing Targets
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class FormController extends Controller {
static targets = ['name', 'email'];
validate() {
if (!this.nameTarget.value.trim()) {
this.nameTarget.classList.add('error');
return false;
}
if (!this.emailTarget.value.includes('@')) {
this.emailTarget.classList.add('error');
return false;
}
return true;
}
clearErrors() {
this.nameTarget.classList.remove('error');
this.emailTarget.classList.remove('error');
}
}
describe('FormController targets', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('throws when accessing missing target', () => {
test.container.innerHTML = `<div data-controller="form"></div>`;
test.registerController('form', FormController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="form"]'),
'form'
);
expect(() => controller.nameTarget).toThrow();
});
it('accesses existing targets', () => {
test.container.innerHTML = `
<div data-controller="form">
<input type="text" data-form-target="name">
<input type="email" data-form-target="email">
</div>
`;
test.registerController('form', FormController);
const element = test.container.querySelector('[data-controller="form"]');
const controller = test.app.getControllerForElementAndIdentifier(element, 'form');
expect(controller.hasNameTarget).toBe(true);
expect(controller.nameTarget).toBeInstanceOf(HTMLInputElement);
expect(controller.emailTargets).toHaveLength(1);
});
it('validates form fields', () => {
test.container.innerHTML = `
<div data-controller="form">
<input type="text" data-form-target="name">
<input type="email" data-form-target="email">
</div>
`;
test.registerController('form', FormController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="form"]'),
'form'
);
expect(controller.validate()).toBe(false);
expect(controller.nameTarget.classList.contains('error')).toBe(true);
expect(controller.emailTarget.classList.contains('error')).toBe(true);
});
});
Testing Values
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class CounterController extends Controller {
static targets = ['display'];
static values = { count: { type: Number, default: 0 } };
connect() {
this.render();
}
increment() {
this.countValue++;
}
countValueChanged(current) {
this.render();
}
render() {
if (this.hasDisplayTarget) {
this.displayTarget.textContent = String(this.countValue);
}
}
}
describe('CounterController values', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('uses default value when not specified in HTML', () => {
test.container.innerHTML = `
<div data-controller="counter">
<span data-counter-target="display"></span>
</div>
`;
test.registerController('counter', CounterController);
const display = test.container.querySelector('[data-counter-target="display"]');
expect(display.textContent).toBe('0');
});
it('reads value from HTML attribute', () => {
test.container.innerHTML = `
<div data-controller="counter" data-counter-count-value="42">
<span data-counter-target="display"></span>
</div>
`;
test.registerController('counter', CounterController);
const display = test.container.querySelector('[data-counter-target="display"]');
expect(display.textContent).toBe('42');
});
it('updates display on increment', () => {
test.container.innerHTML = `
<div data-controller="counter">
<span data-counter-target="display"></span>
</div>
`;
test.registerController('counter', CounterController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="counter"]'),
'counter'
);
controller.increment();
const display = test.container.querySelector('[data-counter-target="display"]');
expect(display.textContent).toBe('1');
});
it('triggers change callback when value changes', () => {
test.container.innerHTML = `<div data-controller="counter"></div>`;
test.registerController('counter', CounterController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="counter"]'),
'counter'
);
const spy = jest.spyOn(controller, 'countValueChanged');
controller.countValue = 10;
expect(spy).toHaveBeenCalledWith(10, 0);
});
});
Testing Actions
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class ClickController extends Controller {
static targets = ['output'];
handleClick(event) {
this.outputTarget.textContent = 'Clicked!';
this.outputTarget.dataset.clicked = 'true';
}
handleKeydown(event) {
if (event.key === 'Enter') {
this.outputTarget.textContent = `Key pressed: ${event.key}`;
}
}
}
describe('ClickController actions', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('responds to click action', () => {
test.container.innerHTML = `
<div data-controller="click">
<button data-action="click->click#handleClick">Click</button>
<span data-click-target="output"></span>
</div>
`;
test.registerController('click', ClickController);
const button = test.container.querySelector('button');
button.click();
const output = test.container.querySelector('[data-click-target="output"]');
expect(output.textContent).toBe('Clicked!');
expect(output.dataset.clicked).toBe('true');
});
it('responds to keyboard action', () => {
test.container.innerHTML = `
<div data-controller="click">
<input type="text" data-action="keydown->click#handleKeydown">
<span data-click-target="output"></span>
</div>
`;
test.registerController('click', ClickController);
const input = test.container.querySelector('input');
const event = new KeyboardEvent('keydown', { key: 'Enter' });
input.dispatchEvent(event);
const output = test.container.querySelector('[data-click-target="output"]');
expect(output.textContent).toBe('Key pressed: Enter');
});
});
Testing Classes
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class ToggleController extends Controller {
static classes = ['active'];
toggle() {
this.element.classList.toggle(this.activeClass);
}
}
describe('ToggleController classes', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('uses the class from HTML attribute', () => {
test.container.innerHTML = `
<div data-controller="toggle" data-toggle-active-class="bg-blue">
<button data-action="click->toggle#toggle">Toggle</button>
</div>
`;
test.registerController('toggle', ToggleController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="toggle"]'),
'toggle'
);
expect(controller.activeClass).toBe('bg-blue');
});
it('toggles the CSS class', () => {
test.container.innerHTML = `
<div data-controller="toggle" data-toggle-active-class="bg-blue">
<button data-action="click->toggle#toggle">Toggle</button>
</div>
`;
test.registerController('toggle', ToggleController);
const button = test.container.querySelector('button');
const element = test.container.querySelector('[data-controller="toggle"]');
expect(element.classList.contains('bg-blue')).toBe(false);
button.click();
expect(element.classList.contains('bg-blue')).toBe(true);
button.click();
expect(element.classList.contains('bg-blue')).toBe(false);
});
});
Testing Outlets
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class ParentController extends Controller {
static outlets = ['child'];
callChild() {
if (this.hasChildOutlet) {
this.childOutlet.receive('Hello from parent');
}
}
}
class ChildController extends Controller {
receive(message) {
this.element.textContent = message;
}
}
describe('ParentController outlets', () => {
let test;
beforeEach(() => {
test = setupTest();
});
afterEach(() => {
test.cleanup();
});
it('accesses child controller through outlet', () => {
test.container.innerHTML = `
<div data-controller="parent">
<div data-controller="child" data-parent-outlet="child"></div>
</div>
`;
test.registerController('parent', ParentController);
test.registerController('child', ChildController);
const parentCtrl = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="parent"]'),
'parent'
);
expect(parentCtrl.hasChildOutlet).toBe(true);
});
it('calls method on child outlet', () => {
test.container.innerHTML = `
<div data-controller="parent">
<div data-controller="child" data-parent-outlet="child"></div>
</div>
`;
test.registerController('parent', ParentController);
test.registerController('child', ChildController);
const parentCtrl = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="parent"]'),
'parent'
);
parentCtrl.callChild();
const child = test.container.querySelector('[data-controller="child"]');
expect(child.textContent).toBe('Hello from parent');
});
it('handles missing outlet gracefully', () => {
test.container.innerHTML = `<div data-controller="parent"></div>`;
test.registerController('parent', ParentController);
const parentCtrl = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="parent"]'),
'parent'
);
expect(parentCtrl.hasChildOutlet).toBe(false);
expect(() => parentCtrl.callChild()).not.toThrow();
});
});
Testing Async Operations
import { Controller } from '@hotwired/stimulus';
import { setupTest } from './test_helper';
class FetchController extends Controller {
static targets = ['result'];
static values = { url: String };
async load() {
try {
const response = await fetch(this.urlValue);
const data = await response.json();
this.resultTarget.textContent = data.message;
} catch (error) {
this.resultTarget.textContent = 'Error';
}
}
}
describe('FetchController async', () => {
let test;
beforeEach(() => {
test = setupTest();
global.fetch = jest.fn();
});
afterEach(() => {
test.cleanup();
delete global.fetch;
});
it('displays fetched message on success', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ message: 'Hello from API' })
});
test.container.innerHTML = `
<div data-controller="fetch" data-fetch-url-value="/api/greet">
<span data-fetch-target="result"></span>
</div>
`;
test.registerController('fetch', FetchController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="fetch"]'),
'fetch'
);
await controller.load();
const result = test.container.querySelector('[data-fetch-target="result"]');
expect(result.textContent).toBe('Hello from API');
});
it('displays error on failed fetch', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
test.container.innerHTML = `
<div data-controller="fetch" data-fetch-url-value="/api/greet">
<span data-fetch-target="result"></span>
</div>
`;
test.registerController('fetch', FetchController);
const controller = test.app.getControllerForElementAndIdentifier(
test.container.querySelector('[data-controller="fetch"]'),
'fetch'
);
await controller.load();
const result = test.container.querySelector('[data-fetch-target="result"]');
expect(result.textContent).toBe('Error');
});
});
Common Mistakes
1. Not Cleaning Up Between Tests
// ❌ Controllers persist between tests
// ✅ Call app.stop() and remove container in afterEach
2. Testing Without DOM Integration
// ❌ Testing controller method without DOM context
const controller = new MyController();
controller.increment(); // Fails because targets don't exist
// ✅ Use setupTest() with proper DOM
3. Forgetting to Register the Controller
// ❌ Controller class exists but isn't registered with the application
// Stimulus won't connect it to the DOM
4. Not Awaiting Async Operations
// ❌ Doesn't wait for fetch to complete
controller.load();
expect(result.textContent).toBe('...'); // Fails: not yet loaded
// ✅ Wait for the promise
await controller.load();
expect(result.textContent).toBe('...');
5. Mocking Globals Without Cleanup
// ❌ Global fetch mock persists across tests
global.fetch = jest.fn();
// ✅ Clean up in afterEach
afterEach(() => { delete global.fetch; });
Practice Questions
1. How do you set up a Stimulus controller test environment?
Use jest-environment-jsdom, create a DOM container, start a Stimulus Application, and register controllers that connect to elements in the container.
2. How do you test that a target exists?
Use controller.hasNameTarget to check existence before accessing controller.nameTarget.
3. How do you simulate a click action on a button?
button.click() triggers the click event, which Stimulus routes to the action method declared in data-action.
4. How do you test value change callbacks?
Set a value programmatically (controller.nameValue = newValue) and use jest.spyOn to verify the callback was called with the correct arguments.
Challenge
Write tests for the dynamic-form controller from the targets tutorial. Test adding fields, removing fields, and the counter update.
FAQ
What's Next
| Topic | Description |
|---|---|
| {{< ref "stimulus-project" >}} | Build a complete Stimulus application from scratch |
| Jest Documentation | Review Jest matchers, mocks, and async testing |
| DOM Testing Library | Best practices for DOM testing with Testing Library |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This testing tutorial ensures every controller in the Doda Browser extension is verified before release.
What's Next
Congratulations on completing this Stimulus Testing tutorial! Here's where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro