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 / truegetElementById 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 arrayIf 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 classtag— by tag name (button,input,div)[attribute]— by attribute presence[attribute="value"]— by attribute valueparent 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 / falseA 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 tickedThese 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
- – 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
querySelectorreturnsnullwhen nothing matches.document.querySelector("#nonexistent").click()throws aTypeError. Always check the result, or use optional chaining:document.querySelector("#x")?.click(). - Using brittle selectors.
div > div:nth-child(3) > buttonsurvives 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.
querySelectorAllreturns a NodeList —forEachworks, butmapandfilterdon't. Use the spread operator ([...nodeList]) orArray.from(nodeList)to get a real array.
🎯 Practice task
Try the selectors against a real page. 15-20 minutes.
- Open
https://qa.codesin Chrome and press F12 → Console. - Try each of the following and note what it returns:
document.querySelector("h1")document.querySelectorAll("a").lengthdocument.querySelector("nav a").textContent
- 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"). - 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.
- Stretch: find a
data-testidon any production site (most modern apps have them on key buttons). Select it withdocument.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.