Debugging and testing DOM code
Use DevTools breakpoints and console utilities to inspect live nodes, then test the same code in JSDOM and in a real browser.
DevTools for DOM work
// type these in the DevTools console
$0 // the element currently selected in the Elements panel
$('button.primary') // document.querySelector shorthand
$$('#list li') // document.querySelectorAll, returns a real array
getEventListeners($0) // every listener on the node, with source links
monitorEvents($0, 'click'); // log events as they fire
unmonitorEvents($0);
debug(handleClick); // break inside this function when it runs
undebug(handleClick);
copy($0.outerHTML); // to the clipboard
inspect(document.body); // reveal an element in the Elements panel// a DOM breakpoint: right-click a node in Elements
// Break on -> subtree modifications
// Break on -> attribute modifications
// Break on -> node removal
// the debugger then pauses at the exact line that changed the node,
// with the call stack that caused it - the fastest way to find
// the code responsible for an unexpected DOM changegetEventListenersanswers 'is this handler even attached' in one line.- Subtree-modification breakpoints find the culprit when a third-party script rewrites your markup.
- The Elements panel shows the accessibility tree and computed values for the selected node; use both before adding JavaScript fixes.
- The Performance panel's layout entries, between script frames, are the signature of layout thrashing.
JSDOM and unit tests
// vitest.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
},
});import { test, expect, beforeEach } from "vitest";
import { renderTodoList } from "../src/todo";
beforeEach(() => {
document.body.innerHTML = '<ul id="list"></ul>';
});
test("renders one row per item", () => {
renderTodoList(document.querySelector("#list"), [
{ id: 1, text: "Write docs" },
{ id: 2, text: "Ship it" },
]);
expect(document.querySelectorAll("#list li")).toHaveLength(2);
expect(document.querySelector("#list li").textContent).toContain("Write docs");
});
test("delegated clicks toggle the done class", () => {
renderTodoList(document.querySelector("#list"), [{ id: 1, text: "Write docs" }]);
document.querySelector("#list button[data-action=toggle]")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(document.querySelector("#list li").classList.contains("done")).toBe(true);
});| Behaviour | JSDOM | Real browser |
|---|---|---|
| Query, traversal, attributes | Yes | Yes |
| Events and delegation | Yes | Yes |
| Layout and geometry | Zeros | Yes |
getComputedStyle | Partial | Yes |
Focus and activeElement | Partial | Yes |
| IntersectionObserver, ResizeObserver | Needs a stub | Yes |
| Custom elements and shadow DOM | Partial | Yes |
⚠️
JSDOM has no layout engine, so every measurement returns zero and every visibility check passes. A test that depends on
getBoundingClientRect, scrolling, focus order or an observer will pass in JSDOM and crash in a browser - keep those in a real-browser test instead.Real-browser tests
import { test, expect } from "@playwright/test";
test("the list survives an update without losing scroll position", async ({ page }) => {
await page.goto("/todos");
await page.getByRole("list").evaluate((el) => { el.scrollTop = 200; });
await page.getByRole("button", { name: "Add" }).click();
await expect(page.getByRole("listitem")).toHaveCount(4);
const scrollTop = await page.getByRole("list").evaluate((el) => el.scrollTop);
expect(scrollTop).toBeGreaterThan(0);
});
test("focus returns to the opener when the dialog closes", async ({ page }) => {
await page.goto("/settings");
const opener = page.getByRole("button", { name: "Delete account" });
await opener.click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(opener).toBeFocused();
});- Test logic and structure in JSDOM, because it is fast and gives precise failures.
- Test anything involving layout, focus, scroll or observers in a real browser.
- Drive interactions through the same APIs a user would trigger, and assert on
document.activeElementrather than on a class. - Keep a small accessibility assertion such as a role or an accessible name in the browser suite; it fails when a refactor breaks the semantics.
- Assert on the DOM, not on implementation internals, so a rendering change does not break every test.
// an accessible-name check is a cheap regression guard
await expect(page.getByRole("button", { name: "Close dialog" })).toBeVisible();
await expect(page.getByRole("heading", { level: 2 })).toHaveText("Invite a teammate");FAQ
Why does my element query work in the browser console but fail in a test?
In a test the document is built by your setup code, so the markup the console query relied on may not exist yet. Render the fixture first, or dispatch the DOMContentLoaded event, and query the element you created rather than the document.
How do I debug a handler that fires too often?
Set a breakpoint with
debug(fn) and read the call stack for the extra invocations. In practice the cause is usually a listener added on every render, or delegation on an ancestor that also matches unrelated clicks.Related
Performance and memory in DOM code Observing changes with MutationObserver
Last refreshed 2026-09-18.