Selecting Elements — querySelector and getElementById

8 min read

To do anything to a DOM node, you first have to find it. That's true in the browser console, in vanilla JavaScript, and in every test framework — cy.get(...), page.locator(...), driver.findElement(...) are all sophisticated wrappers around the same handful of native browser methods. This lesson covers those native methods, the CSS selector syntax they accept, and the data-testid convention that keeps test selectors stable.

getElementById — by id

The simplest and oldest selector. document.getElementById(id) returns the element whose id attribute matches, or null if nothing matches.

const submitBtn = document.getElementById("submit-btn");
console.log(submitBtn);          // the <button> element
console.log(submitBtn.disabled); // false / true

getElementById is fast and unambiguous — id attributes are supposed to be unique per page. The catch: lots of modern apps don't use id consistently, and component libraries often generate random IDs that change between builds.

querySelector — by CSS selector, first match

document.querySelector(selector) accepts any CSS selector and returns the first match, or null.

const firstError = document.querySelector(".error-message");
const emailInput = document.querySelector("input[type='email']");
const submit = document.querySelector("button[type='submit']");

Anything CSS supports as a selector, this method supports — class names, attribute selectors, descendant combinators, pseudo-classes. The same .username selector you'd use in a stylesheet works here.

querySelectorAll — every match

document.querySelectorAll(selector) returns a NodeList of every match. NodeLists are array-like — they have .length and you can iterate them with for...of or forEach, but most array methods (map, filter) require converting first.

const errors = document.querySelectorAll(".error-message");
console.log(errors.length);   // e.g., 3
 
errors.forEach(e => console.log(e.textContent));
 
const messages = [...errors].map(e => e.textContent);  // spread to get a real array

If nothing matches, you get an empty NodeList — never null. Always check .length before assuming something was found.

CSS selector refresher

The selectors querySelector accepts are exactly the ones from CSS — and the ones every test framework's locator system understands too:

  • #id — by ID
  • .class — by class
  • tag — by tag name (button, input, div)
  • [attribute] — by attribute presence
  • [attribute="value"] — by attribute value
  • parent child — descendant (any depth)
  • parent > child — direct child only
  • :nth-child(2) — pseudo-class (positional)
  • tag.class[attr] — combined: button.primary[type="submit"]

The full set is in the XPath and CSS selectors cheat sheet.

data-testid — the gold-standard test attribute

Selectors based on classes (.btn-primary) or DOM structure (form > div:nth-child(2) > button) break the moment a designer changes a class name or a developer wraps an element in a new div. Tests that use them turn flaky for reasons that have nothing to do with bugs.

The fix is to add a dedicated attribute for tests:

<button type="submit" data-testid="submit-login">Sign in</button>

Then select on it:

const submit = document.querySelector('[data-testid="submit-login"]');

data-testid doesn't affect users (browsers ignore unknown attributes), doesn't change with styling, and is explicit — anyone reading the test knows what's being tested. Cypress (cy.get('[data-testid=submit-login]')), Playwright (page.getByTestId('submit-login')), and Testing Library all elevate this convention into first-class APIs.

When you have a choice — and you almost always do — selecting by data-testid beats every other strategy.

Reading element properties

Once you have an element, you can read the things you care about for assertions.

const input = document.getElementById("email");
const submit = document.querySelector('[data-testid="submit-login"]');
 
console.log(input.value);                    // current value of an input
console.log(input.placeholder);              // placeholder text
console.log(submit.textContent);             // "Sign in" — the visible text
console.log(submit.getAttribute("type"));    // "submit"
console.log(submit.classList);               // DOMTokenList — like an array of class names
console.log(submit.classList.contains("primary"));  // true / false

A common QA pattern: read textContent for visible text assertions, value for input contents, getAttribute(name) for any custom attribute (href, data-*, aria-label).

Checking element state

Elements expose boolean properties for their interactive state.

const submit = document.querySelector('[data-testid="submit-login"]');
const checkbox = document.querySelector('[name="remember"]');
 
console.log(submit.disabled);   // true if the button is disabled
console.log(submit.hidden);     // true if hidden via the `hidden` attribute
console.log(checkbox.checked);  // true if the checkbox is ticked

These map directly to the assertions you'll write in test frameworks — toBeDisabled, toBeChecked, toBeVisible. The framework reads the same properties under the hood; it just retries until the value settles.

A real login form

Selecting and reading every part of a login form, end to end:

const form = document.querySelector('form[data-testid="login-form"]');
 
const email    = form.querySelector('[data-testid="email-input"]');
const password = form.querySelector('[data-testid="password-input"]');
const submit   = form.querySelector('[data-testid="submit-login"]');
const error    = form.querySelector('[data-testid="error-message"]');
 
console.log("email value:", email.value);
console.log("submit disabled:", submit.disabled);
console.log("error text:", error?.textContent ?? "(no error)");

Output (on an empty form with no error):

email value:
submit disabled: true
error text: (no error)

Note the use of form.querySelector(...) to scope the search to inside the form — document.querySelector would search the whole page. Scoping by parent is good practice anywhere a page might have several similar elements.

Selector methods at a glance

DOM selectors
  • – Single id
  • – Returns one or null
  • – Fast, unambiguous
  • – Any CSS selector
  • – Returns FIRST match
  • – Returns null if none
  • – Any CSS selector
  • – Returns NodeList of all
  • – Empty list if none
  • [data-testid='x'] –
  • [name='email'] –
  • [href*='/login'] –

⚠️ Common mistakes

  • Forgetting that querySelector returns null when nothing matches. document.querySelector("#nonexistent").click() throws a TypeError. Always check the result, or use optional chaining: document.querySelector("#x")?.click().
  • Using brittle selectors. div > div:nth-child(3) > button survives until the next CSS refactor. Tests built on those selectors break for reasons unrelated to bugs. Prefer [data-testid="..."] or accessibility-based locators.
  • Mistaking a NodeList for an array. querySelectorAll returns a NodeList — forEach works, but map and filter don't. Use the spread operator ([...nodeList]) or Array.from(nodeList) to get a real array.

🎯 Practice task

Try the selectors against a real page. 15-20 minutes.

  1. Open https://qa.codes in Chrome and press F12Console.
  2. Try each of the following and note what it returns:
    • document.querySelector("h1")
    • document.querySelectorAll("a").length
    • document.querySelector("nav a").textContent
  3. Use the Elements tab to find an element with a class — pick something obvious like a button. Switch back to the console and select it: document.querySelector(".the-class").
  4. Open Locator Lab on qa.codes — paste a snippet of HTML and your selector, and the tool tells you what matches, grades how resilient the selector is, and suggests better locators. Try a few selectors of varying robustness.
  5. Stretch: find a data-testid on any production site (most modern apps have them on key buttons). Select it with document.querySelector('[data-testid="..."]'). Note that the same exact selector would work in a Cypress or Playwright test against the same page.

The next lesson covers what happens after you select an element — events, the way the browser tells your code that something happened.

// tip to track lessons you complete and pick up where you left off across devices.