The DOM

The Document Object Model (DOM) is the browser’s live, in-memory model of a web page. When the browser parses HTML it builds a tree of nodes — the document at the root, an element node for every element, text nodes for the words — and gives JavaScript an interface to read and change that tree. The browser draws the screen from the DOM and the CSS, not from the HTML file.

A concrete example: a cart page sends <input id="qty" value="1">. The DOM gets an input element whose value attribute is 1. When the user changes the quantity to 3, the element’s value property becomes 3, the attribute still says 1, and the page source does not change at all. The source is the page as it was sent; the DOM is the page as it is now.

Why it matters

Everything a UI test does goes through the DOM. A locator such as #qty is a query against the current tree, a click is delivered to an element node, and an assertion reads a property or the text of a node. If you do not know what the DOM held at that moment, you cannot tell whether a failed test found a defect, used a wrong locator or looked too early.

It matters for manual testing too. Some defects are visible only in the DOM: a message that is added but stays hidden, a field whose label is not connected to it, a button that exists twice. Reading the DOM lets you report the cause — the element, its attributes and its state — instead of only the symptom, and check whether a problem is in the page or in your test.

A tree of nodes

In the lesson on how browsers work you saw the browser parse HTML. The output of that step is the DOM: a tree in which every item is a node. The document node is the root, available to scripts as document. Every HTML element becomes an element node, a branch that can contain other nodes. The text inside elements becomes text nodes, the leaves of the tree. HTML comments become comment nodes: they are in the DOM even though nothing displays them.

Attributes are not children in the tree. id="qty" is stored on the input element itself, like a label stuck on a box rather than an item inside it. The spaces and line breaks between tags usually become text nodes too, which is why a script that counts an element’s child nodes often finds more than the tags suggest.

  • Parent and child: in the form below, the form is the parent of the label, input, button and p, and they are its children.
  • Siblings: children of the same parent, such as the label and the input.
  • Ancestor and descendant: body is an ancestor of the input; everything inside the form is a descendant of the form.
  • Locators use the same relationships. In CSS, form > input means an input that is a child of a form, and form input means any descendant input. In XPath, / steps to a child, // searches all descendants (at the start of an expression, the whole document), [@id='qty'] keeps only elements with that attribute value, and .. steps up to the parent: //input/.. moves from the input to its parent.
<form id="cart">
  <label for="qty">Quantity</label>
  <input id="qty" value="1">
  <button type="submit">Update</button>
  <p class="msg" hidden>Cart updated</p>
</form>
The cart form as the browser holds it: element nodes branch from the document node, text nodes are the leaves, and attributes such as value="1" sit on their element, not inside it.

The cart form in the DOM

Part 1:#document
└ html
  ├ head
  └ body
    └ Part 2:form id="cart"
      ├ label for="qty"
      │ └ Part 4:"Quantity"
      ├ input Part 3:id="qty" value="1"
      ├ button type="submit"
      │ └ Part 4:"Update"
      └ p class="msg" Part 5:hidden
        └ Part 4:"Cart updated"
  1. 1Document nodeThe root, document in scripts: every search for an element starts here or from an element already found
  2. 2Element nodeOne for each element, from html to p. The form is the parent of four children, which are siblings of each other
  3. 3AttributesStored on the element, not children in the tree. After the user changes the quantity to 3, the value property is "3" while this attribute still says "1"
  4. 4Text nodesThe words a user reads; leaves with no children. Text locators match them
  5. 5Present, not displayedhidden keeps the paragraph off the screen, but a query still finds it: present is not visible

The head’s children and the whitespace-only text nodes between tags are left out.

Try it: HTML Formatter

Attributes and properties

An attribute is written in the HTML: value="1". A property belongs to the JavaScript object that represents the element: qty.value. For many attributes the browser keeps the two in step, which the HTML standard calls reflecting: setting the id property changes the id attribute, and the other way round. Some pairs have different names: the class attribute is the className property, and for is htmlFor.

The pairs that matter most to a tester are the ones that do not stay in step:

  • value on an input: the attribute is the starting value, also readable as the defaultValue property. The value property is what the field holds now. Typing changes the property only.
  • checked on a checkbox: the attribute says whether it starts ticked (defaultChecked); the checked property says whether it is ticked now.
  • href on a link: getAttribute('href') returns what the HTML says, such as /help; the href property returns the full URL the browser resolved, such as https://shop.example/help.
  • Boolean attributes such as disabled and required: only presence counts. disabled="false" still disables a button, because the attribute is there. hidden behaves the same way: with any value, even hidden="false", the element is hidden.
// After the user changed the quantity from 1 to 3
const qty = document.querySelector('#qty');
qty.getAttribute('value');  // "1"  attribute: what the HTML said
qty.defaultValue;           // "1"  property that reflects the attribute
qty.value;                  // "3"  property: what the field holds now

How JavaScript changes the DOM

The JavaScript from the previous lesson changes a page by changing the DOM; the browser then redraws the screen from the new tree. Almost every change is one of a few operations: find a node, create one, insert it, remove it, or change its text, attributes or properties. Scripts usually find elements with document.querySelector(), which takes a CSS selector and returns the first matching element, or null when nothing matches; querySelectorAll() returns all the matches.

Frameworks such as React, Angular and Vue make the same calls for you. When data changes they may remove an element and insert a new one in its place, so an element your test found a moment ago may no longer be in the tree. Selenium then fails with a stale element reference error; Playwright avoids most of these failures because it locates an up-to-date element every time a locator is used.

  • The page source and the DOM differ. View Page Source (Ctrl+U) shows what the server sent; the DOM also contains everything scripts added since. In a single-page application the source can be little more than an empty <div id="root"></div>.
  • Content can arrive after the page appears. When a script fetches data and then inserts it, the element does not exist at first, so a test has to wait for it.
  • The parser changes the tree too. A table row written directly inside <table> ends up inside a <tbody> that the parser inserts, so an XPath copied from the source, such as //table/tr, matches nothing.
  • Text inserted with innerHTML is parsed as markup, while textContent inserts plain text. If a name typed as <b>Ali</b> shows up in bold somewhere, user input is reaching the DOM as markup: report it, because the same path can allow cross-site scripting (XSS).
const form = document.querySelector('#cart');   // find an element
const msg = form.querySelector('.msg');
msg.textContent = 'Cart updated: 3 items';     // replace its text
msg.hidden = false;                            // removes the hidden attribute
const note = document.createElement('p');      // create a node, not in the tree yet
note.textContent = 'Free delivery';
form.append(note);                             // insert it: now it can be seen
note.remove();                                 // remove it again

Inspecting the DOM, and why locators depend on it

Right-click an element and choose Inspect: the developer tools open on the Elements panel (Inspector in Firefox) with that element selected. This panel shows the live DOM, not the source: elements added by scripts appear, removed ones disappear, and attributes update as they change. Its tree shows attributes, not properties, so after you change the quantity to 3 it still shows value="1". To read a property, type $0.value in the Console; $0 is the element selected in the Elements panel.

Page source misses what scripts added and the tbody the parser inserted; the Elements tree shows both but lists the value attribute, so of the three only the Console ($0.value) reads what a user typed.

View Page SourceCtrl+U

The HTML as the server sent it

Elements added by scripts:
No
A tbody the parser inserted:
No
What the user typed into a field:
No
Use it to:
See what the server delivered

Elements panel

The live DOM: the nodes and their attributes now

Elements added by scripts:
Yes
A tbody the parser inserted:
Yes
What the user typed into a field:
No: it shows the value attribute
Use it to:
Write and debug locators

Console$0

Any property of the element selected in the Elements panel

Elements added by scripts:
Yes
A tbody the parser inserted:
Yes
What the user typed into a field:
Yes: $0.value
Use it to:
Read properties and count locator matches

Every locator is a query against this live tree. CSS selectors and XPath match element names, attributes and positions; text locators match the text of nodes; role-based locators such as Playwright’s getByRole use a role and an accessible name computed from the element, its attributes, its text and related elements such as its <label>. That is why you write and debug locators against the DOM in the state the test will meet, never against the page source.

  • Count matches before you rely on a locator: document.querySelectorAll('#cart button').length in the Console should return 1. In Chrome and Edge, Ctrl+F in the Elements panel also accepts a CSS selector or an XPath expression.
  • Check in the right state: after the data has loaded, with the dialog open, in every language the suite runs in.
  • Present is not visible. The p with hidden is in the DOM, so a query finds it, but the user cannot see it. When the requirement is that the user sees something, assert visibility.
  • An iframe holds a separate document, and a shadow root hides a component’s inner tree from ordinary queries. Both need the special handling described in the locator strategies guide.

Worked example: three checks on the cart page

A tester automates the cart form from this lesson, together with an order history table. The requirement: after the user changes the quantity and clicks Update, the message "Cart updated" is shown. Three checks give surprising results, and repeating each one in the developer tools shows how the DOM explains it.

  • Check 1 fails: after the quantity is changed to 3, the test reads the input’s value attribute and gets "1". Repeat it in the Console with the input selected in the Elements panel: $0.getAttribute('value') gives "1", while $0.value gives "3". The application is right; the test read the attribute instead of the property. Fix: make the check read the value property. Automation tools expose the same attribute and property pair under their own names, and the Playwright and Selenium lessons show which call reads which.
  • Check 2 fails: //table[@id='orders']/tr, copied from the page source, finds no rows. In the Console, $x() runs an XPath expression and returns the matching elements: $x("//table[@id='orders']/tr") returns an empty list [], and the Elements panel shows the tbody the parser inserted. Fix: locate the rows as they are in the DOM: $x("//table[@id='orders']/tbody/tr") returns every row.
  • Check 3 passes, but should not: the test reads the textContent of .msg and finds "Cart updated: 3 items". The tester watching the run sees no message. In the Elements panel the p still carries hidden, and with it selected, $0.hidden in the Console returns true: the script changed the text but never showed it. This is a real defect, and the test missed it because it checked text, not visibility.
  • To see exactly what a script changed, copy the form’s outerHTML before and after clicking Update (right-click the element in the Elements panel and choose Copy, then Copy outerHTML) and compare the two copies in Text Diff. Like the Elements panel, outerHTML shows attributes, not typed values.

Try it: Text Diff

Common mistakes

  • Treating View Page Source as the DOM, and writing locators or bug reports from it.
  • Checking what a user typed by reading the value attribute or the Elements panel; the current value is the value property.
  • Assuming that an element a query finds is visible to the user; presence and visibility are separate checks.
  • Reading disabled="false" or hidden="false" as enabled or visible; for these attributes, being present is what counts, whatever the value.
  • Keeping a reference to an element across a re-render and using it later, which ends in a stale element error.
  • Using a locator from "Copy XPath" or from the source without checking in the live DOM that it matches exactly one element.

The QA perspective

On a team, the DOM is common ground between testers and developers. When you report a UI defect, add what the DOM showed: the element (a short selector or its outerHTML), the attribute or property values that matter, and whether the element was missing, hidden or duplicated. "After Update, p.msg keeps its hidden attribute" can be fixed in minutes; "the message does not appear" sends a developer searching.

When you automate, ask developers early for stable hooks — meaningful ids, accessible names or an agreed test id attribute — and ask which parts of the page are re-rendered when data changes. Before you report a failed UI test as a product defect, look at the DOM in the state where it failed: many failures are test problems, such as a stale element, a locator copied from the source or a check made before the content arrived, and the DOM tells you which.

Practice exercise

Read the DOM of a sign-up form

Your task

Below is part of a newsletter sign-up page. The requirement: Join stays disabled until the terms are accepted, and after Join is clicked the message "Check your inbox" is shown. A user types ana@example.com and ticks the checkbox. The script at the end runs each time the checkbox changes and sets the button’s disabled property to the opposite of the checkbox’s checked property (! means "not").

Answer the steps on paper first. Then check your answers: save the code as an .html file, open it in a browser and use the Elements panel and the Console.

Material
<section id="signup">
  <h2>Newsletter</h2>
  <!-- shown to visitors who are not signed in -->
  <label for="email">Email</label>
  <input id="email" type="email" value="">
  <label><input id="terms" type="checkbox"> I accept the terms</label>
  <button id="join" disabled>Join</button>
  <p id="status" hidden></p>
</section>
<script>
  const terms = document.querySelector('#terms');
  const join = document.querySelector('#join');
  terms.addEventListener('change', () => { join.disabled = !terms.checked; });
</script>
Steps
  1. Draw the DOM tree of the section: element nodes, text nodes and the comment. Leave out whitespace-only text nodes.
  2. After the user’s actions, give the email field’s value attribute and value property, and the checkbox’s checked attribute and checked property.
  3. What happens to the button’s disabled attribute when the box is ticked, and what does the Elements panel show?
  4. Which of these locators match exactly one element: #signup label, #signup input, #terms, label[for=email]?
  5. A colleague’s test finds #status after clicking Join and passes because the element exists. What is wrong with the check, and what would a better one find?
Show the model answer
Model answer

Tree: section#signup has six element children in order — h2, label, input#email, label, button#join, p#status — with the comment node between the h2 and the first label. The h2 holds the text node "Newsletter"; the first label holds "Email"; the second label has two children, the checkbox input#terms and the text node " I accept the terms"; the button holds "Join"; p#status has no children. Attributes such as for, type, disabled and hidden sit on their elements, not in the tree.

Email field: the value attribute is still an empty string, as written; the value property is "ana@example.com". Checkbox: there is no checked attribute (getAttribute('checked') returns null), while the checked property is true.

The script sets the disabled property to false; because disabled is reflected, the browser removes the attribute and the Elements panel shows <button id="join">Join</button>. Unticking the box adds disabled back.

#terms and label[for=email] match one element each. #signup label matches two labels, and #signup input matches two inputs: the email field and the checkbox.

The paragraph is in the DOM from the start, hidden and empty, so "it exists" is true before and after the click and proves nothing. A better check asserts that #status is visible and contains "Check your inbox". On this page it would fail: no script fills or shows the message, which is a defect to report, with the element and its hidden attribute as evidence.

Frequently asked questions

Is the DOM the same as the HTML?

No. HTML is the text the server sends; the DOM is the tree the browser builds from it and keeps changing. The parser completes and corrects the markup, and scripts add, remove and change nodes, so after the page loads the two can differ a lot. View Page Source shows the HTML; the Elements panel shows the DOM.

Do testers need to learn the DOM API?

You need to read the DOM, not program it: the node types and their relationships, the difference between attributes and properties, and a few Console commands such as $0 and document.querySelectorAll to check a value or a locator. Automation uses more of the API, but its locators and assertions rest on the same ideas.

Why can my test not find an element I can see on the screen?

Usually because the element is inside an iframe, which is a separate document; it is inside a shadow root; it had not been added yet when the test looked; or the locator was written from the page source rather than the live DOM. Some things on screen are not elements at all: text drawn on a canvas, for example, cannot be found by any DOM locator.

References

  • HTML Living StandardWHATWGThe HTML language, including named character references (entities) and the parsing rules browsers follow.
  • WebDriver (W3C)W3C · 2026The W3C protocol browsers implement for remote control by automation tools such as Selenium (the Level 2 edition is a Working Draft).
  • Document Object Model (DOM) — MDNMDN Web Docs (Mozilla)The DOM: the tree of nodes a browser builds from a page, which scripts — and UI test tools — read and change.
  • Locators — PlaywrightMicrosoft (Playwright)How Playwright finds elements, why locators re-resolve and auto-wait, and why role-based locators are recommended first.

Practise with Mutqan tools

Beautify HTML with proper indentation of nested elements, preserving pre, script and style blocks.

Formatting

Compare two texts line by line or word by word and highlight differences.

Formatting

Go deeper

Your progress on this topic

Part of the QA Roadmap