jsguides

Testing DOM code with jsdom in Node.js: a tutorial

Introduction to testing DOM code

When you are building web applications, a significant portion of your code interacts with the DOM, manipulating elements, handling events, and updating the user interface. Testing DOM code without jsdom requires a real browser, which adds overhead and slows down CI pipelines. jsdom provides a pure JavaScript implementation of web standards that runs entirely in Node.js, making DOM tests fast and portable.

This tutorial walks through installing jsdom, creating a document from an HTML string, querying and mutating elements with the same APIs you use in the browser, dispatching events, and wiring everything up under Jest so the tests run in CI without a headless browser. We also cover the boundaries: features jsdom does not implement, and when to graduate to Playwright for real browser coverage.

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

With jsdom installed, Jest can use it as the test environment. The testEnvironment setting tells Jest to create a DOM for each test file. The setupFilesAfterEnv array lets you run custom configuration before each test suite runs, which is where you would set global DOM utilities:

export default {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

The config above tells Jest which environment to use, but the jest.setup.js file is where you populate the global scope with window, document, and navigator. Setting pretendToBeVisual to true makes jsdom report itself as a visual browser, which helps code that checks for visual capabilities:

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;

Once jsdom is configured, the globals are available in every test file. This means your DOM utility functions can call document.querySelector and document.createElement exactly as they would in a browser. The next section tests a real set of utility functions against a jsdom-based fixture.

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'));
}

The utilities above are simple DOM queries, but they still need proper test setup. Each test must set document.body.innerHTML to the markup the functions expect. The beforeEach block resets the DOM before every test so no state leaks between assertions:

// 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');
});

Querying the DOM and reading values is the simplest kind of test. But many components also modify the DOM—creating elements, changing text, appending or removing nodes. Verifying these mutations requires checking both the element tree structure and the content of the newly created nodes.

Testing DOM manipulation

Your code often modifies the DOM, adding elements, changing content, or updating attributes. Here is how to test these operations:

// src/list-manager.js
export function addItem(text) {
  const list = document.getElementById('task-list');
  if (!list) return;

  const item = document.createElement('li');
  item.textContent = text;
  item.className = 'task-item';
  list.appendChild(item);
}

export function clearList() {
  const list = document.getElementById('task-list');
  if (list) {
    list.innerHTML = '';
  }
}

The implementation above handles two cases: adding items to a list and clearing all items. Both functions guard against a missing #task-list element by returning early when getElementById returns null. The tests confirm that elements are created with the correct tag, class, and text, and that clearList empties the container without removing the container itself:

// src/list-manager.test.js
import { addItem, clearList } from './list-manager';

beforeEach(() => {
  document.body.innerHTML = '<ul id="task-list"></ul>';
});

test('addItem appends a new li to the list', () => {
  addItem('Buy milk');

  const list = document.getElementById('task-list');
  const items = list.querySelectorAll('.task-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('task-list');
  expect(list.children).toHaveLength(2);
});

test('clearList removes all items from the list', () => {
  document.body.innerHTML = '<ul id="task-list"><li>Item</li></ul>';
  clearList();

  const list = document.getElementById('task-list');
  expect(list.children).toHaveLength(0);
});

DOM manipulation tests verify structure, but most interactive components also respond to user actions. jsdom can simulate clicks, form submissions, and custom events. Testing event handlers confirms that your code reacts correctly when the user clicks a button or types into an input.

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 };
}

The setupCounter function attaches a click handler and returns a getCount accessor so the test can verify the internal counter state without exposing the variable directly. The test calls button.click() which jsdom dispatches synchronously, making assertions straightforward without any async coordination or timer manipulation:

// 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');
});

Click events work with the built-in .click() method, but custom events like submit need manual dispatch. Creating an Event object and calling dispatchEvent gives you full control over the event type, bubbling behavior, and cancelability. This pattern is essential for testing form validation and custom component events:

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');
});

Working with real DOM shape

The most helpful habit with jsdom is to keep your test DOM close to the markup your app really renders. A test that sets up a tiny fragment with only the exact nodes your function touches is easier to read, but it can also hide problems that appear once the surrounding structure changes. When a component depends on labels, wrappers, or sibling elements, include those pieces in the fixture so the test covers the same shape the browser will see.

That approach also helps when you are testing selectors. A selector that works in a hand-built fragment may fail once a real component library inserts an extra wrapper or changes an attribute. If the test fixture mirrors the production DOM more closely, a failing query usually points to a genuine regression rather than a shortcut in the test. It is a small amount of extra setup that pays off when the codebase grows and components start nesting more deeply.

Another useful habit is to keep the assertions close to behavior. Instead of checking only that a node exists, check that the right text, class, value, or event result appears after the action. This makes the test describe user-visible behavior rather than implementation details. That style is especially valuable when working with DOM mutation helpers, because those helpers often change over time while the behavior should remain stable.

When a test starts to feel brittle, look at the setup instead of the assertion first. Many fragile jsdom tests come from fixtures that are too small, too magical, or too far removed from the real page. A clearer setup can remove several lines of debugging later. As a bonus, other developers can read the test and understand which browser behavior it is modeling without guessing at hidden assumptions.

Finally, remember that jsdom is a simulation, not a full browser. If you depend on layout, CSS-driven behavior, or features that only exist in real rendering engines, keep that boundary in mind and move those checks to a browser-based test. jsdom is best at proving that your logic, event handling, and DOM updates are wired correctly before the code reaches a browser.

Keep the fixture close to the browser

The more your fixture resembles the real page, the more useful the test becomes. That does not mean copying the entire application into every test. It means including the wrappers, labels, and sibling nodes that affect how selectors and events behave. A slightly richer setup often reveals bugs that a minimal fragment would hide, especially when layout or surrounding markup changes over time.

Good jsdom tests also keep the assertion centered on the user-facing result. A node existing in the tree is a start, but the better signal is whether the right text, class, or event outcome appears after the action. That style makes the test tell a short story: set up the page, do one thing, and verify what the browser would show back to the user.

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');
}

The validateEmail function is a pure logic test that does not touch the DOM, while showValidationError creates and inserts DOM elements. The tests in the next block keep both kinds of code under the same describe block, which works well when the functions are closely related:

// 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);
  });
});

Form validation tests cover both pure logic and DOM manipulation in one file. But as your test suite grows, you will run into common jsdom-specific issues: state leaking between tests, timer-dependent code running slowly, and stale event listeners. The next section covers patterns that address each one.

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>';
});

Resetting innerHTML in beforeEach is the simplest guard against state pollution, but tests that use timers need a different approach. Jest’s fake timers let you control setTimeout and setInterval without waiting for real time, which keeps the test suite fast even when testing delayed DOM updates:

jest.useFakeTimers();

test('delayed action executes after timeout', () => {
  const callback = jest.fn();
  setTimeout(callback, 5000);

  jest.advanceTimersByTime(5000);
  expect(callback).toHaveBeenCalled();
});

Fake timers solve the speed problem, but event listeners can cause a different kind of test pollution. If one test adds a click handler and the next test reuses the same DOM, the handler from the first test can fire unexpectedly. Clearing or replacing the DOM in afterEach prevents this:

afterEach(() => {
  document.body.innerHTML = '';
});

Event listener cleanup handles the test-level state, but Jest also caches module imports between test files. If one test mutates a shared global from setupFilesAfterEnv, later tests may see stale values. Calling jest.resetModules() forces a fresh module load for the next import:

beforeEach(() => {
  jest.resetModules();
});

The built-in jsdom environment works for most projects, but some teams need custom settings like runScripts: 'dangerously' to execute inline scripts, or a specific url for testing routing logic. A custom test environment class gives you full control over the JSDOM constructor:

// 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;

The custom environment class extends Jest’s default behavior by providing its own getGlobal() method that returns the jsdom window. The runScripts: 'dangerously' option is rarely needed but allows <script> tags in your test HTML to execute, which can be useful for testing legacy code that depends on inline scripts. Point Jest to the custom environment with the testEnvironment config key:

// 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.

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.

Next steps

If your tests are passing under jsdom but a production bug still sneaks through, check whether the issue involves layout, CSS computed styles, or navigation. Those features are outside jsdom’s scope and need a real browser. Playwright fills that gap by running tests in Chromium, Firefox, and WebKit. For CI configuration and coverage reporting, see the code coverage tutorial.

See Also