JavaScript and TypeScript course · Module 8: Events and the DOM
Unit Tests for Event Handlers
How to test code related to event handling.
After every change in the control panel, you could click every dinosaur card by hand and check whether the alarm still works. That's like walking along every fence with a flashlight each day. Automated tests do that round for you in a fraction of a second. We'll use Jest, a popular testing framework for JavaScript. The describe function groups tests, test describes a single case, beforeEach prepares fresh data before every test, and expect checks the result. The most important tool is jest.fn(): it creates a fake function, a mock, that records every call and its arguments.
The Component We Are Testing
Before we write the tests, let's meet the main character. DinosaurCardHandler attaches a click to a card, blocks the default action, and announces the selected dinosaur with a custom event.
class DinosaurCardHandler {
constructor(element) {
this.element = element;
this.handleClick = this.handleClick.bind(this);
this.element.addEventListener('click', this.handleClick);
}
handleClick(event) {
event.preventDefault();
this.emitDinosaurSelected(event.target.dataset.dinoId);
}
emitDinosaurSelected(dinosaurId) {
this.element.dispatchEvent(
new CustomEvent('dinosaurSelected', { detail: { dinosaurId } })
);
}
destroy() {
this.element.removeEventListener('click', this.handleClick);
}
}The class has four behaviors worth checking: it registers a listener in the constructor, calls preventDefault() in handleClick(), dispatches a dinosaurSelected event with the ID, and in destroy() removes the same function it attached. Each of them will get its own test.
Testing Events
We start with the setup. Instead of a real DOM element, we create a plain object with three methods made with jest.fn().
// Example tests with Jest
describe('Event Handlers', () => {
let mockElement;
let eventHandler;
beforeEach(() => {
// Creating a mock DOM element
mockElement = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn()
};
// Mock global functions
global.document = {
getElementById: jest.fn(() => mockElement),
createElement: jest.fn(() => mockElement)
};
});This is how you build a mock element: you assign jest.fn() to addEventListener and later ask it how it was called. Thanks to beforeEach, every test gets a fresh mock, so calls from one test don't mix with the next. Replacing the whole global.document is only a backup here, because the tests pass mockElement directly anyway. In a jsdom environment document already exists, so instead of overwriting it, replace a single method with jest.spyOn(document, 'getElementById'). The first test checks the registration.
test('should add event listener on init', () => {
const handler = new DinosaurCardHandler(mockElement);
expect(mockElement.addEventListener).toHaveBeenCalledWith(
'click',
expect.any(Function)
);
});The toHaveBeenCalledWith() matcher checks that the listener was added with the 'click' type, and expect.any(Function) accepts any function, because we don't care which one exactly. The second test checks the click handling itself.
test('should handle click event correctly', () => {
const mockCallback = jest.fn();
const handler = new DinosaurCardHandler(mockElement);
// Event simulation
const mockEvent = {
target: { dataset: { dinoId: '123' } },
preventDefault: jest.fn()
};
// Calling the handler directly
handler.handleClick(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});The mock element has no real event system, so we call handleClick() directly with a fake event object. Its preventDefault is again a jest.fn(), so we can check that it was called. The mockCallback variable isn't used anywhere here, and in a real project such a dead line is worth removing. The third test guards the cleanup.
test('should remove event listener on destroy', () => {
const handler = new DinosaurCardHandler(mockElement);
handler.destroy();
expect(mockElement.removeEventListener).toHaveBeenCalledWith(
'click',
expect.any(Function)
);
});This test confirms that destroy() called removeEventListener() for the click. A stronger version would compare the function with the one passed earlier to addEventListener(), which Jest keeps in mock.calls. The fourth test checks the custom event.
test('should emit custom event', () => {
const handler = new DinosaurCardHandler(mockElement);
const customEvent = new CustomEvent('dinosaurSelected');
handler.emitDinosaurSelected('123');
expect(mockElement.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dinosaurSelected',
detail: { dinosaurId: '123' }
})
);
});
});The expect.objectContaining() matcher checks only the listed fields of the dispatched object: the type and detail. The customEvent variable is unnecessary here, because the assertion looks at the event created by the handler itself.
An Integration Test with a Real DOM
Unit tests check the logic in isolation. An integration test creates a real button and checks whether the browser's event mechanism really connects it with the listener.
// Integration test with real DOM
describe('Integration Tests', () => {
test('should handle real DOM events', () => {
// Creating a real element
const button = document.createElement('button');
document.body.appendChild(button);
let clickCount = 0;
button.addEventListener('click', () => {
clickCount++;
});
// Simulating a click
button.click();
expect(clickCount).toBe(1);
// Cleanup
document.body.removeChild(button);
});
});The click() method simulates a click and dispatches a real click event, which bubbles just like after a mouse click. At the end we remove the button so that it doesn't affect other tests. Such a test needs the jsdom environment: since version 27, Jest runs tests in a Node environment by default, so you have to set testEnvironment: 'jsdom', and since version 28 also install the jest-environment-jsdom package.
Every handler test goes through the same four steps: we create a mock DOM element with jest.fn(), initialize the component with that mock, simulate an event, for example a click, and check the assertions with expect.
My advice: check the logic with fast unit tests using mocks, and the connection with the DOM with a few integration tests. For events without a shortcut like click(), use element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })). In the sandbox below you'll run the test suite with a single button and see the results. The next lesson will sum up the whole module on events.
Remember: a good test is a guard who walks the fences for you every night and wakes you up only when something is really wrong.
Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Testing Event Handlers</title>
<style>
body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
.test-section { background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; margin: 20px 0; }
.test-result { padding: 10px; margin: 10px 0; border-radius: 6px; }
.pass { background: rgba(46, 204, 113, 0.2); border-left: 4px solid #27ae60; }
.fail { background: rgba(231, 76, 60, 0.2); border-left: 4px solid #e74c3c; }
button { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 5px; }
code { background: rgba(255,255,255,0.1); padding: 2px 6px; border-radius: 3px; font-family: monospace; }
</style>
</head>
<body>
<h1>Testing Event Handlers</h1>
<div class="test-section">
<h2>Component to Test</h2>
<button id="test-button" data-dino="T-Rex">Click Me</button>
<div id="click-counter">Clicks: 0</div>
</div>
<div class="test-section">
<h2>Test Suite Results</h2>
<button onclick="runTests()">▶Run All Tests</button>
<div id="test-results"></div>
</div>
<script>
console.log('Event Handler Testing Suite');
// COMPONENT TO TEST
class DinoButton {
constructor(element) {
this.element = element;
this.clickCount = 0;
this.handleClick = this.handleClick.bind(this);
this.init();
}
init() {
this.element.addEventListener('click', this.handleClick);
}
handleClick(e) {
this.clickCount++;
this.updateDisplay();
console.log(`Clicked: ${this.element.dataset.dino}`);
}
updateDisplay() {
document.getElementById('click-counter').textContent = `Clicks: ${this.clickCount}`;
}
getClickCount() {
return this.clickCount;
}
destroy() {
this.element.removeEventListener('click', this.handleClick);
}
}
// Create component
const testButton = document.getElementById('test-button');
const dinoButton = new DinoButton(testButton);
// TEST SUITE
class TestRunner {
constructor() {
this.results = [];
}
test(name, testFn) {
try {
testFn();
this.results.push({ name, passed: true, error: null });
console.log(`PASS: ${name}`);
} catch (error) {
this.results.push({ name, passed: false, error: error.message });
console.log(`FAIL: ${name} - ${error.message}`);
}
}
expect(value) {
return {
toBe: (expected) => {
if (value !== expected) {
throw new Error(`Expected ${expected}, got ${value}`);
}
},
toBeGreaterThan: (expected) => {
if (value <= expected) {
throw new Error(`Expected > ${expected}, got ${value}`);
}
},
toBeDefined: () => {
if (value === undefined || value === null) {
throw new Error(`Expected value to be defined`);
}
}
};
}
displayResults() {
const container = document.getElementById('test-results');
container.innerHTML = '';
const summary = document.createElement('div');
const passed = this.results.filter(r => r.passed).length;
const total = this.results.length;
summary.innerHTML = `<strong>Results: ${passed}/${total} tests passed</strong>`;
container.appendChild(summary);
this.results.forEach(result => {
const div = document.createElement('div');
div.className = `test-result ${result.passed ? 'pass' : 'fail'}`;
div.innerHTML = `
${result.passed ? 'OK' : '×'} <strong>${result.name}</strong>
${result.error ? `<br><code>${result.error}</code>` : ''}
`;
container.appendChild(div);
});
}
}
// RUN TESTS
function runTests() {
const runner = new TestRunner();
runner.test('Component initializes with click count 0', () => {
const button = document.createElement('button');
const component = new DinoButton(button);
runner.expect(component.getClickCount()).toBe(0);
component.destroy();
});
runner.test('Click count increments on click', () => {
const button = document.createElement('button');
const component = new DinoButton(button);
const initialCount = component.getClickCount();
// Simulate click
button.click();
runner.expect(component.getClickCount()).toBeGreaterThan(initialCount);
component.destroy();
});
runner.test('Element has correct data attribute', () => {
const button = document.getElementById('test-button');
const dino = button.dataset.dino;
runner.expect(dino).toBe('T-Rex');
});
runner.test('Event listener is added', () => {
const button = document.createElement('button');
const component = new DinoButton(button);
// Event listener should be added
runner.expect(component.element).toBeDefined();
runner.expect(component.handleClick).toBeDefined();
component.destroy();
});
runner.test('Event listener is removed on destroy', () => {
const button = document.createElement('button');
const component = new DinoButton(button);
const countBefore = component.getClickCount();
component.destroy();
button.click(); // This click should not increase the counter
// Counter should not change after destroy
runner.expect(component.getClickCount()).toBe(countBefore);
});
runner.test('Multiple clicks increment correctly', () => {
const button = document.createElement('button');
const component = new DinoButton(button);
button.click();
button.click();
button.click();
runner.expect(component.getClickCount()).toBe(3);
component.destroy();
});
runner.displayResults();
console.log('Test suite completed');
}
console.log('Test suite ready - click "Run All Tests"');
</script>
</body>
</html>Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Which function of Jest's own API (no extra libraries) do we use to create a mock of the addEventListener method on a DOM element?
2. How do you simulate a click on a DOM element in integration tests?
Hands-on tasks in the game
- Code editor
The index.js file has the DinosaurCardHandler component from the testing lesson, a simple jest.fn() (the mock remembers its calls in mock.calls) and three tests written as functions that return true when the component works correctly. Fill in the blanks: ___BLANK1___ is the event type the constructor should register with addEventListener, ___BLANK2___ is the number of preventDefault() calls after one click, and ___BLANK3___ is the comparison operator that checks that destroy() removes the same function the constructor added. The checker runs your tests on the correct component (they must return true) and on broken versions (they must return false).
- Vertical ordering
Arrange the steps for testing an event handler in the correct order: