You now have the pieces. The DOM is a tree. Selectors find nodes. Events fire when things happen. Test automation frameworks — Cypress, Playwright, WebdriverIO, Selenium — are sophisticated wrappers around exactly those three concepts. This lesson connects the dots: what your favourite framework is actually doing under the hood, why it has built-in waiting, and what to do (and not do) about timing in tests.
Same action, three styles
Click a button labelled "Submit" and assert that "Success" appears on screen. Three syntaxes, the same DOM operations:
Vanilla DOM (what every framework does internally):
const submit = document.querySelector('[data-testid="submit-login"]');
submit.click();
// then somehow wait for the success element to appear...
const success = document.querySelector('[data-testid="success"]');
console.log(success.textContent); // "Success"Cypress:
cy.get('[data-testid="submit-login"]').click();
cy.get('[data-testid="success"]').should("contain.text", "Success");Playwright:
await page.getByTestId("submit-login").click();
await expect(page.getByTestId("success")).toContainText("Success");Underneath, every one of those calls becomes some flavour of document.querySelector, element.click(), element.textContent — the methods you've already met. The frameworks add three things on top: a friendlier API, automatic waiting, and assertions designed for test failure messages.
What Cypress does under the hood
Cypress was built on top of jQuery — its selector syntax, traversal helpers, and chaining feel jQuery-ish:
cy.get('[data-testid="email-input"]') // querySelector + retry
.type("alice@x.com") // sequence of keydown / input / keyup events
.should("have.value", "alice@x.com"); // read .value, retry until it matchesEach command is queued. The framework executes them one at a time, retrying each one against the live DOM until it succeeds or hits a timeout (the default is 4 seconds).
That retry behaviour is what fixes the timing problem. If the input doesn't exist yet because an API call is still loading data, cy.get keeps re-querying. When the input appears, the chain proceeds. No setTimeout needed; no manual sleep needed.
What Playwright does under the hood
Playwright uses locators — first-class objects that describe an element rather than holding a reference to it. Each interaction re-queries the DOM:
const submit = page.getByTestId("submit-login");
await submit.click();getByTestId(...) doesn't return an element — it returns a Locator. The element lookup happens when you call an action like .click() or an assertion like expect(locator).toBeVisible(). Each action waits for the element to exist, be visible, and be stable (not animating) before firing the event.
Same problem solved a different way: rather than retrying queued commands, Playwright re-queries on every action.
Both frameworks do these three things in every action
- Wait for the element. It might not be in the DOM yet — re-query until it appears.
- Wait for the element to be ready. Visible, enabled, and not animating.
- Dispatch the event(s). A
clickismousedown+mouseup+click. Atypeis a sequence ofkeydown+input+keyupper character. The framework sends real events the app can't tell apart from a user.
That's the entire automation stack. The pretty cy.get(...).click() API is a layer over document.querySelector(...).dispatchEvent(...) with retries.
The same action, three syntaxes
Click and assert — three ways to say the same thing
Raw DOM (vanilla JS)
document.querySelector('[data-testid=submit]').click()
document.querySelector('[data-testid=success]').textContent
No automatic waiting
You handle every timing edge case
Cypress
cy.get('[data-testid=submit]').click()
cy.get('[data-testid=success]').should('contain.text', 'Success')
Queued chain, automatic retry per command
No await — the framework manages it
Playwright
await page.getByTestId('submit').click()
await expect(page.getByTestId('success')).toContainText('Success')
Locators re-query on every action
Standard async/await
Why you should never use setTimeout in tests
The naive fix for "the element isn't ready yet" is to wait a fixed amount of time:
// ❌ Don't
cy.wait(2000);
cy.get('[data-testid="submit"]').click();Three reasons this is wrong:
- It's slow when the page is fast. If the element appears in 50ms, you've still wasted 1950ms.
- It's flaky when the page is slow. A CI machine under load takes 3000ms. Your 2000ms wait fails the test even though the app worked perfectly.
- It hides real timing bugs. A flaky test is a signal — usually that the app doesn't communicate "I'm ready" properly. Sleeping past the problem hides the signal.
The right pattern is condition-based waiting. Cypress and Playwright already do this for elements:
cy.get('[data-testid="success"]')— waits up to 4s for the element.await page.getByTestId("success").waitFor()— explicit version when needed.await expect(locator).toBeVisible()— assertion that retries until true.
For non-DOM conditions (waiting for an API response, a state change), both frameworks give you cy.intercept / page.waitForResponse / cy.wait("@alias") — wait for a thing happening, not a fixed duration.
You're now ready for a framework
This chapter gave you the foundation. You understand:
- The DOM is a live tree of nodes the browser builds from HTML.
- Selectors (
querySelector,data-testid) locate nodes in the tree. - Events (clicks, inputs, submits) flow from target outwards through bubbling.
- Automation frameworks wrap all of this with retries and assertions designed for test failure.
Two natural next steps:
- Cypress — the Cypress tools entry and the Cypress commands cheat sheet cover the framework's APIs. The chained-command model fits naturally on top of what you've just learned.
- Playwright — see the Playwright tools entry. The locator + async/await model uses every JavaScript skill from this course directly.
Either path takes you from "JavaScript-aware QA engineer" to "automation engineer who can write and debug tests in a real codebase." The capstone project in chapter 8 of this course exercises the foundations one more time before you go.
⚠️ Common mistakes
- Believing tests "click" buttons the same way users do. Frameworks fire DOM events. Real users move a mouse cursor through a path, hover, click. Most apps treat the two the same — but apps that depend on hover state, focus events, or pointer trajectory can fail in tests in ways they don't fail with users. When a test passes but the feature is broken in production, this gap is the usual culprit.
- Using
setTimeout/cy.wait(ms)to "fix" flake. Always replace fixed waits with condition-based waits — wait for an element, wait for a response, wait for an assertion. A test with no fixed waits is a test that's actually telling you something when it fails. - Selecting on volatile attributes.
nth-child, generated class names, and dynamic IDs all change between builds. Tests that select on them break for unrelated reasons.data-testid(and accessibility-role-based locators) are the durable choice.
🎯 Practice task
Map a real test to the DOM. 20 minutes.
-
Open
https://qa.codesin Chrome. -
Press F12, switch to the Console, and complete each of the following from raw JavaScript — exactly what a framework does internally:
- Find the page's main heading:
const h1 = document.querySelector("h1"); - Read its text:
console.log(h1.textContent); - Find the first link:
const link = document.querySelector("a"); - Read its href:
console.log(link.getAttribute("href")); - Trigger a click programmatically:
link.click();(the page should navigate).
- Find the page's main heading:
-
Now imagine the same actions in Cypress and Playwright syntax. Without writing a real test, sketch them as comments in a file:
// Cypress: cy.get("h1").should("contain.text", "...") // Playwright: await expect(page.locator("h1")).toContainText("...")Notice that
should("contain.text", ...)andtoContainText(...)are wrappers overtextContent. The frameworks aren't doing magic — they're adding retries and good error messages on top of methods you already understand. -
Stretch: if you have Cypress or Playwright already installed somewhere, write a real one-line test against any public page —
cy.get("h1").should("be.visible")orawait expect(page.locator("h1")).toBeVisible(). Run it. The chain of "find element → wait for visibility → assert" is exactly what you've internalised in this chapter.
That's the end of chapter 6. You've gone from what is JavaScript to how does Cypress work in six chapters. The next chapter covers error handling and debugging — how to make failures surface clearly so the rest of your career doesn't involve hunting silent bugs.