Testing DOM Code with jsdom
When you’re building web applications, a significant portion of your code interacts with the DOM—manipulating elements, handling events, and updating the user interface. Testing this code traditionally required a browser, but jsdom changes that by providing a pure JavaScript implementation of web standards that runs entirely in Node.js.
What is jsdom?
jsdom is a JavaScript implementation of web standards, including the DOM and HTML. It allows you to parse HTML strings, create document objects, and interact with them using the same APIs you’d use in a browser—without actually launching a browser.
Here’s why jsdom has become essential for testing:
- Fast: No browser startup overhead
- CI-friendly: Runs in any Node.js environment
- Comprehensive: Supports most DOM APIs
- Flexible: Configure viewport, URL, and more
Setting Up jsdom with Jest
Jest has built-in support for jsdom. If you’ve already set up Jest (covered in our earlier tutorial on unit testing with Jest), you’re most of the way there. Install jsdom as a dev dependency:
npm install --save-dev jsdom
Jest automatically uses jsdom when testing files that import or interact with the DOM. For explicit configuration, create or update jest.config.js:
export default {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
In jest.setup.js, you can configure jsdom options:
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
pretendToBeVisual: true,
});
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
Testing DOM Queries
Let’s start with the most common scenario: testing code that queries the DOM. Suppose you have a function that finds elements:
// src/dom-utils.js
export function getTitle() {
const title = document.querySelector('h1');
return title ? title.textContent : null;
}
export function getAllButtons() {
return Array.from(document.querySelectorAll('button'));
}
Here’s how to test these functions with jsdom:
// src/dom-utils.test.js
import { getTitle, getAllButtons } from './dom-utils';
beforeEach(() => {
document.body.innerHTML = `
<h1>Welcome</h1>
<button id="submit">Submit</button>
<button class="btn">Cancel</button>
`;
});
test('getTitle returns the h1 text content', () => {
expect(getTitle()).toBe('Welcome');
});
test('getTitle returns null when no h1 exists', () => {
document.body.innerHTML = '<p>No title</p>';
expect(getTitle()).toBeNull();
});
test('getAllButtons returns all button elements', () => {
const buttons = getAllButtons();
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent).toBe('Submit');
});
Testing DOM Manipulation
Your code often modifies the DOM—adding elements, changing content, or updating attributes. Here’s how to test these operations:
// src/list-manager.js
export function addItem(text) {
const list = document.getElementById('todo-list');
if (!list) return;
const item = document.createElement('li');
item.textContent = text;
item.className = 'todo-item';
list.appendChild(item);
}
export function clearList() {
const list = document.getElementById('todo-list');
if (list) {
list.innerHTML = '';
}
}
// src/list-manager.test.js
import { addItem, clearList } from './list-manager';
beforeEach(() => {
document.body.innerHTML = '<ul id="todo-list"></ul>';
});
test('addItem appends a new li to the list', () => {
addItem('Buy milk');
const list = document.getElementById('todo-list');
const items = list.querySelectorAll('.todo-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toBe('Buy milk');
});
test('addItem handles multiple items', () => {
addItem('Task 1');
addItem('Task 2');
const list = document.getElementById('todo-list');
expect(list.children).toHaveLength(2);
});
test('clearList removes all items from the list', () => {
document.body.innerHTML = '<ul id="todo-list"><li>Item</li></ul>';
clearList();
const list = document.getElementById('todo-list');
expect(list.children).toHaveLength(0);
});
Testing Event Handling
Events are central to web interactivity. jsdom supports dispatching events and verifying your handlers work correctly:
// src/button-handler.js
export function setupCounter(buttonId) {
const button = document.getElementById(buttonId);
let count = 0;
button.addEventListener('click', () => {
count++;
button.textContent = `Clicked ${count} times`;
});
return { getCount: () => count };
}
// src/button-handler.test.js
import { setupCounter } from './button-handler';
beforeEach(() => {
document.body.innerHTML = '<button id="counter">Click me</button>';
});
test('clicking the button increments the counter', () => {
const { getCount } = setupCounter('counter');
const button = document.getElementById('counter');
button.click();
expect(getCount()).toBe(1);
expect(button.textContent).toBe('Clicked 1 times');
});
test('multiple clicks update count correctly', () => {
const { getCount } = setupCounter('counter');
const button = document.getElementById('counter');
button.click();
button.click();
button.click();
expect(getCount()).toBe(3);
expect(button.textContent).toBe('Clicked 3 times');
});
For more complex events, you can create and dispatch specific event types:
test('form submission can be tested', () => {
document.body.innerHTML = `
<form id="login-form">
<input type="email" id="email" value="test@example.com" />
<button type="submit">Login</button>
</form>
`;
const form = document.getElementById('login-form');
const submittedData = {};
form.addEventListener('submit', (e) => {
e.preventDefault();
submittedData.email = document.getElementById('email').value;
});
// Create and dispatch submit event
const event = new dom.window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(event);
expect(submittedData.email).toBe('test@example.com');
});
Testing Form Validation
Form validation is a perfect use case for jsdom testing. Here’s a validation module and its tests:
// src/validators.js
export function validateEmail(email) {
if (!email) return { valid: false, error: 'Email is required' };
const pattern = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
if (!pattern.test(email)) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true, error: null };
}
export function showValidationError(inputId, message) {
const input = document.getElementById(inputId);
const error = document.createElement('div');
error.className = 'error-message';
error.textContent = message;
input.parentNode.insertBefore(error, input.nextSibling);
input.classList.add('invalid');
}
// src/validators.test.js
import { validateEmail, showValidationError } from './validators';
describe('validateEmail', () => {
test('returns valid for correct email', () => {
expect(validateEmail('user@example.com')).toEqual({ valid: true, error: null });
});
test('returns invalid for empty email', () => {
expect(validateEmail('')).toEqual({ valid: false, error: 'Email is required' });
});
test('returns invalid for malformed email', () => {
expect(validateEmail('not-an-email')).toEqual({ valid: false, error: 'Invalid email format' });
});
});
describe('showValidationError', () => {
beforeEach(() => {
document.body.innerHTML = '<input id="email" type="text" />';
});
test('adds error message element after input', () => {
showValidationError('email', 'Invalid email');
const error = document.querySelector('.error-message');
expect(error).not.toBeNull();
expect(error.textContent).toBe('Invalid email');
});
test('adds invalid class to input', () => {
showValidationError('email', 'Error');
const input = document.getElementById('email');
expect(input.classList.contains('invalid')).toBe(true);
});
});
Common Pitfalls and Solutions
1. Global State Pollution
Each test should start with a clean DOM. Always use beforeEach to reset document.body.innerHTML:
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
});
2. Timers and Async Operations
If your code uses setTimeout or setInterval, use Jest’s timer mocks:
jest.useFakeTimers();
test('delayed action executes after timeout', () => {
const callback = jest.fn();
setTimeout(callback, 5000);
jest.advanceTimersByTime(5000);
expect(callback).toHaveBeenCalled();
});
3. Event Listeners Not Removed
If you’re adding event listeners in tests, clean them up or use a fresh DOM:
afterEach(() => {
document.body.innerHTML = '';
});
4. Module Caching
Jest caches modules. If you’re setting up globals in setupFilesAfterEnv, changes might not reflect. Clear require cache when needed:
beforeEach(() => {
jest.resetModules();
});
Advanced: Custom jsdom Configuration
For more control, create a custom test environment:
// custom-jsdom-environment.js
const JSDOM = require('jsdom').JSDOM;
class CustomTestEnvironment {
constructor(config) {
this.document = new JSDOM('<!DOCTYPE html>', {
url: 'http://localhost',
pretendToBeVisual: true,
runScripts: 'dangerously',
}).window.document;
}
getGlobal() {
return this.document.defaultView;
}
}
module.exports = CustomTestEnvironment;
Configure Jest to use it:
// jest.config.js
export default {
testEnvironment: '<rootDir>/custom-jsdom-environment.js',
};
Conclusion
jsdom bridges the gap between Node.js testing and browser-specific code. It lets you test DOM manipulation, event handling, and form validation without leaving your terminal. Combined with Jest’s mocking capabilities, you can achieve comprehensive test coverage for your frontend code.
In this tutorial, you learned how to set up jsdom, test DOM queries and manipulation, handle events, validate forms, and avoid common pitfalls. With these skills, you can confidently test any DOM-related code in your JavaScript applications.