Home› Testing› Complete Guide
Complete Guide

Complete Testing Tutorial

This complete guide covers all 12 Testing tutorials on TheCodeForge, organised by topic.

Learning Roadmap
Beginner → Build a strong foundation
Intermediate → Deepen your understanding with practical topics
Advanced → Master advanced concepts and real-world applications
12
Topics
5
Beginner
7
Intermediate
0
Advanced
Jump to section
Selenium (5)Cypress (4)Playwright (3)

Every flaky test in a browser suite is the same bug: the test assumed the page was ready and the page was not. Selenium calls the result StaleElementReferenceException. Cypress calls it element detached from the DOM. Playwright calls it a timeout. Three tools, one mistake — a reference captured at one moment and used at another, across a boundary where a framework re-rendered the node.

Which is why the single highest-value skill in this track is not learning a tool's API but learning to wait on the right condition. sleep(2) is a bet that the app will be ready in two seconds; it fails on a slow CI runner and wastes two seconds on every other run. Waiting on a state — this element is visible, this request has completed, this text has changed — is both faster and deterministic.

The three generations of browser automation

The tools in this track sit at different points in a clear evolution, and knowing which problem each was built to solve explains their error messages.

ToolHow it talks to the browserWhat it fixed, and what it did not
Selenium / WebDriverHTTP commands to a per-browser driver processCross-browser standardisation. Every command is a separate round trip, so a node can change between finding it and clicking it — hence stale element references and mandatory explicit waits
CypressRuns inside the browser, in the same event loop as the appAutomatic retry-ability and excellent debuggability. The in-browser position creates its own limits around cross-origin navigation and multiple tabs
PlaywrightA persistent bidirectional protocol connection per browserAuto-waiting on actionability, real multi-origin and multi-tab support, parallel isolated contexts. Strict-mode locator errors are the cost of it refusing to guess

None of them removes the need to understand what you are waiting for. They differ in how much of that waiting they do on your behalf, and in how loudly they complain when your intent is ambiguous.

Stale elements and detached nodes are a caching bug

A WebElement is a handle to a specific DOM node. If the framework re-renders — React reconciling, Angular running change detection, a Vue list re-keying — the old node is discarded and your handle points at something no longer in the document. Selenium raises StaleElementReferenceException on the next use.

The fix is not to retry the click; it is to stop holding the reference across a state change. Find the element as late as possible, and where the framework re-renders on every keystroke, re-find it per interaction.

python
# Fragile: the handle is captured before an action that re-renders the row
row = driver.find_element(By.CSS_SELECTOR, "[data-test=row-42]")
driver.find_element(By.CSS_SELECTOR, "[data-test=refresh]").click()
row.click()                       # StaleElementReferenceException

# Robust: re-locate after the state change, and wait on a condition
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

wait = WebDriverWait(driver, 10)
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-test=refresh]"))).click()
wait.until(EC.staleness_of(row))  # assert the re-render actually happened
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-test=row-42]"))).click()
In practiceElementClickInterceptedException is a different animal and often misread as staleness: the element exists and is visible, but something else is on top of it — a sticky header, a cookie banner, a toast, a modal backdrop mid-fade. Waiting for the overlay to disappear is the fix; scrolling or forcing the click hides a real user-facing problem.

Playwright strict mode: an error that is doing you a favour

Strict mode violation: locator resolved to 3 elements means your selector is ambiguous and Playwright refuses to pick one. Selenium would have silently taken the first match, which is exactly how a test ends up asserting on the wrong row and passing for months.

So the correct response is to make the intent precise rather than to suppress the check. Playwright's locator API is built for this: scope to a container, filter by text, or name the element the way a user would.

javascript
// Ambiguous - three "Delete" buttons on the page
await page.locator('button:has-text("Delete")').click();   // strict violation

// Scope to the row that matters
await page.getByRole('row', { name: 'invoice-1042' })
          .getByRole('button', { name: 'Delete' })
          .click();

// Or filter explicitly
await page.getByRole('listitem')
          .filter({ hasText: 'invoice-1042' })
          .getByRole('button', { name: 'Delete' })
          .click();

// .first() is legitimate only when "any of them" is genuinely the intent
await page.getByRole('link', { name: 'Docs' }).first().click();

Why cy.wait(ms) is the wrong tool, and what to use instead

A fixed wait is a guess about someone else's machine. On a fast developer laptop it is dead time; on a loaded CI runner with four suites in parallel it is too short, and the test fails for a reason that has nothing to do with the code under test. That is the definition of a flaky test, and flaky tests get muted, and muted tests stop protecting anything.

Every framework gives you a way to wait on the actual condition. Waiting on a network response is usually the strongest signal, because it ties the test to the app's real readiness rather than to a visual side effect of it.

javascript
// Guessing
cy.get('[data-test=save]').click();
cy.wait(3000);
cy.get('[data-test=toast]').should('contain', 'Saved');

// Waiting on the thing you actually depend on
cy.intercept('POST', '/api/invoices').as('save');
cy.get('[data-test=save]').click();
cy.wait('@save').its('response.statusCode').should('eq', 201);
cy.get('[data-test=toast]').should('contain', 'Saved');

// Playwright equivalent
const save = page.waitForResponse(r =>
  r.url().includes('/api/invoices') && r.status() === 201);
await page.getByRole('button', { name: 'Save' }).click();
await save;
await expect(page.getByTestId('toast')).toHaveText(/Saved/);
In practiceAdd a data-test attribute and select on it. CSS classes and DOM structure are implementation details that change with every redesign; a test attribute is a contract you chose to keep. The half hour spent adding them pays for itself the first time someone refactors the markup.

Frequently Asked Questions

Why do my tests pass locally and fail in headless CI?
Four causes cover most of it: viewport — headless defaults are often smaller, so an element is off-screen or a responsive breakpoint changed the layout; timing — CI is slower and more variable, so fixed waits expire; missing fonts or animations that change measured positions; and no browser profile, so cookie banners and consent dialogs appear that you never see locally. Run headed against the CI viewport before assuming headless is broken.
Is Playwright strictly better than Selenium now?
For a new browser suite on modern browsers, usually yes — auto-waiting, parallel isolated contexts and tracing remove a lot of work. Selenium still wins where you need a genuine WebDriver grid across many real browser and OS combinations, where an existing suite is large enough that rewriting is not worth it, or where the ecosystem you are in is built around it. Both appear in this track for that reason.
What does SessionNotCreatedException mean exactly?
That the driver could not start a browser session, and the message almost always contains the reason: a ChromeDriver built for a different Chrome major version, a browser binary not found, or a profile directory already locked by another session. Since Selenium Manager handles driver downloads automatically, the version mismatch case is usually a pinned driver left over from before.
How should I handle authentication in a browser test suite?
Log in once, save the storage state, and reuse it. Every framework supports this — Playwright's storageState, Cypress's session caching — and it removes the slowest, flakiest step from every test. Reserve a real UI login for the one test whose subject is the login flow itself.
How many end-to-end tests should we have?
Fewer than instinct suggests. E2E tests are the slowest and most brittle layer, so they earn their cost on critical journeys — sign up, checkout, the one workflow the business runs on — while component and integration tests cover breadth far more cheaply. A suite where every feature has an E2E test tends to become a suite nobody trusts, because the failures are more often environmental than real.
What is the fastest way to reduce flakiness in an existing suite?
Delete every fixed wait and replace it with a condition, then add data-test attributes to the elements those tests touch. Those two changes address the majority of real flakiness. Retries are worth adding afterwards, but retrying first is how a genuine race condition gets hidden until it reaches production.

Selenium

Cypress

Playwright

Also Explore
JavaScript 185 tutorials → Java 257 tutorials → Python 171 tutorials → C# / .NET 60 tutorials → Frontend 13 tutorials → Mobile 20 tutorials →
Start from the beginning

Every tutorial starts with a plain-English analogy — then real code, then interview questions.

Browse Testing Tutorials →