PRACTICAL GUIDE / Selenium TypeScript component locator contracts

Keep Selenium locators inside the component that owns them

Design typed Selenium component objects that hide DOM details, survive re-renders, and make locator changes fail in one clear, maintainable place.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Define a contract around behavior, not CSS
  2. Use a fresh root after reactive re-renders
  3. Build a runnable TypeScript component
  4. Tell a locator leak from a product failure
  5. Fix ownership without building a second application
  6. Know when a component object is too much

What you will learn

  • Define a contract around behavior, not CSS
  • Use a fresh root after reactive re-renders
  • Build a runnable TypeScript component
  • Tell a locator leak from a product failure

A product-card redesign changes one button class, and thirty-four Selenium tests fail to compile or locate the control. Each test knew the card's internal markup. The selector was not shared behavior, it was leaked implementation detail.

Define a contract around behavior, not CSS

A component locator contract answers three questions: which object owns a region of the DOM, which operations that region offers, and what typed information can leave it. The CSS selectors are an implementation of that contract, not the contract itself.

For a product card, useful public operations might be details() and addToCart(). A test should not need card.root.findElement(...), card.addButton, or a selector string. If the markup changes from a button with a class to a button inside a footer, one component object should absorb the change.

Selenium's official page-object guidance makes the same separation. Page and component objects expose services, keep structural knowledge in one place, and generally leave assertions to the test. A component object differs mainly in scope. It represents a discrete region such as a product card, date picker, address panel, or navigation bar rather than an entire page.

A useful TypeScript contract has four properties:

  • The root identity is stable enough to find the same logical component again.
  • Descendant locators are private.
  • Public methods use product language and typed inputs or outputs.
  • The test owns assertions about the expected business result.

Types help at the boundary. Promise<ProductDetails> tells a caller what can be observed. A union such as ProductId prevents accidental calls with unsupported fixture data. TypeScript cannot prove that a CSS selector matches the right element at runtime, so the component still needs clear failures when its root or expected child is absent.

Use a fresh root after reactive re-renders

Selenium returns a remote reference when it locates a WebElement. If React, Vue, or another client framework replaces that DOM node, the reference points to a detached element and the next command can raise StaleElementReferenceError. The replacement may look identical on screen, but it has a different remote element identity.

Caching a component root in the constructor is therefore risky when an action can re-render the component. Store a By locator and resolve the root inside each public operation. Descendant searches should begin at that root, which prevents a selector intended for one card from matching the same label or button in another card.

Re-locating costs remote calls. On a high-latency Grid, repeatedly resolving a deeply nested component can add measurable time. That is a real trade-off, not a reason to cache everything. Cache only when the DOM region is known to remain stable for the component's lifetime and the performance gain has been measured.

Root identity also needs care. A class such as .card:nth-child(2) describes layout, not product identity. Sorting or personalization can make the second card a different product. A stable product key, accessible label, or deliberately maintained data-testid plus domain ID gives the component a stronger anchor.

Build a runnable TypeScript component

This example creates a small catalog in a data URL, so it does not depend on an external application. The component keeps every descendant selector private, re-finds its root for each operation, and returns domain data rather than elements.

TypeScript
import { deepStrictEqual, strictEqual } from 'node:assert';
import {
  Browser,
  Builder,
  By,
  WebDriver,
  WebElement,
} from 'selenium-webdriver';

type ProductId = 'backpack';
type ProductDetails = Readonly<{
  name: string;
  priceCents: number;
}>;

class ProductCard {
  private readonly name = By.css('[data-testid="product-name"]');
  private readonly price = By.css('[data-testid="product-price"]');
  private readonly addButton = By.css('[data-testid="add-to-cart"]');

  constructor(
    private readonly driver: WebDriver,
    private readonly rootBy: By,
  ) {}

  private async root(): Promise<WebElement> {
    return await this.driver.findElement(this.rootBy);
  }

  private async child(locator: By): Promise<WebElement> {
    return await (await this.root()).findElement(locator);
  }

  async details(): Promise<ProductDetails> {
    const name = await (await this.child(this.name)).getText();
    const priceText = await (await this.child(this.price)).getText();
    const match = /^\$(\d+)\.(\d{2})$/.exec(priceText);
    const dollars = match?.[1];
    const cents = match?.[2];

    if (dollars === undefined || cents === undefined) {
      throw new Error(`Unexpected product price: ${priceText}`);
    }

    return {
      name,
      priceCents: Number(dollars) * 100 + Number(cents),
    };
  }

  async addToCart(): Promise<void> {
    await (await this.child(this.addButton)).click();
  }
}

class CatalogPage {
  constructor(private readonly driver: WebDriver) {}

  product(id: ProductId): ProductCard {
    const root = By.css(
      `[data-testid="product-card"][data-product-id="${id}"]`,
    );
    return new ProductCard(this.driver, root);
  }

  async cartCount(): Promise<number> {
    const text = await this.driver
      .findElement(By.css('[data-testid="cart-count"]'))
      .getText();
    return Number(text);
  }
}

async function main(): Promise<void> {
  const driver = await new Builder().forBrowser(Browser.CHROME).build();

  try {
    const html = `
      <p>Cart: <span data-testid="cart-count">0</span></p>
      <article data-testid="product-card" data-product-id="backpack">
        <h2 data-testid="product-name">Trail backpack</h2>
        <p data-testid="product-price">$29.99</p>
        <button data-testid="add-to-cart" onclick="
          const count = document.querySelector('[data-testid=cart-count]');
          count.textContent = String(Number(count.textContent) + 1);
        ">Add to cart</button>
      </article>
    `;

    await driver.get(
      `data:text/html;charset=utf-8,${encodeURIComponent(html)}`,
    );

    const catalog = new CatalogPage(driver);
    const backpack = catalog.product('backpack');

    deepStrictEqual(await backpack.details(), {
      name: 'Trail backpack',
      priceCents: 2999,
    });

    await backpack.addToCart();
    strictEqual(await catalog.cartCount(), 1);
  } finally {
    await driver.quit();
  }
}

void main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});

Notice what the test section can see: product, details, addToCart, and cartCount. It cannot reach the button locator because that field is private. It also receives price as integer cents, so currency formatting does not leak into every assertion.

The component does not assert that the price is $29.99 or that the cart count becomes one. Those are scenario decisions and remain outside the component. The parsing guard is different. It enforces the component's output contract and gives a specific error if the UI returns data the object cannot represent.

Tell a locator leak from a product failure

When several tests fail after a UI change, inspect the first Selenium command in each stack trace. Repeated NoSuchElementError from the same component method suggests a broken shared locator. Different successful component actions followed by wrong domain values suggest a product or test-data failure.

Search the test tree for direct selector construction:

Shell
rg -n 'By\.(css|xpath|id|name)\(' test

The goal is not zero results. Component and page-object files must define locators. The evidence you want is whether spec files or unrelated helpers know the component's descendant markup. A selector repeated in five tests has five owners, regardless of whether it was copied from a constant.

For stale-element failures, look at timing and ownership. Did a component method click something that re-rendered its root, then reuse a stored WebElement? Does resolving the root again find the replacement? A stale reference immediately after navigation may instead mean the test is using an object from the previous page. Re-finding in the wrong browsing context will not fix that.

Capture a screenshot and the current page source for one failing attempt when the DOM shape is disputed. Correlate both to the same test and session. A screenshot proves what a person could see; page source and stack trace show whether the expected root and child attributes existed when Selenium searched.

Fix ownership without building a second application

Moving all selectors into one giant page class reduces duplication but does not create useful ownership. A checkout page with eighty unrelated locators becomes another global namespace. Split objects along UI regions that have their own behavior and lifecycle.

Stable test IDs can strengthen that boundary. Ask the product team for a component-level identity and names for controls that lack reliable semantics. The cost is a maintained testing interface in production markup. Keep it small. IDs for every wrapper, grid cell, and decorative icon simply freeze the DOM under another naming system.

Private locators make changes local, but they can also hide poor behavior names. clickBlueButton() still describes presentation. Prefer applyCoupon(), removeItem(), or chooseDelivery(). Renaming methods as product language changes is work, but TypeScript then identifies every caller at compile time.

Review the public surface as strictly as the selectors. A method that accepts a CSS string, child index, or WebElement lets callers tunnel through the boundary even if the fields are marked private. Inputs should represent choices a user or product makes, such as a product ID or delivery method. If a caller needs an index because the UI has no stable identity, fix that ambiguity instead of blessing it in the API.

Error messages are part of the contract too. NoSuchElementError with a generated CSS expression is hard to triage when several components share a page. Catching and replacing every Selenium error loses the original stack, so do not do that. Add component and domain identity as context while preserving the underlying cause. Reviewers should be able to tell whether the missing object was the backpack card root or its add button.

Returning typed data can cause method growth. Do not add a getter for every text node. Expose information a test needs to make a product assertion. If callers repeatedly ask for the same cluster of values, return one read-only domain snapshot.

Nested components are useful when ownership is genuinely nested, such as an order summary containing line-item components. They are harmful when every <div> gets a class. Each layer adds files, constructors, and remote lookups. The abstraction should remove repeated decisions, not mirror HTML indentation.

Lists require an explicit lifetime choice. Constructing a component from each returned WebElement is concise and matches Selenium's documented component example, but the list becomes stale if sorting replaces its rows. Constructing components from stable IDs costs an extra text or attribute read and allows them to re-locate later. Choose based on whether callers use the component before or after actions that can rebuild the list.

A small architecture check can enforce ownership without parsing TypeScript. Keep component files under a known directory, list direct By calls found elsewhere, and review the exceptions. The check will produce legitimate hits for page roots and test utilities, so make it a review signal before turning it into a blocking rule.

Know when a component object is too much

A one-off static element used by one short test may be clearer as a locator in a small page object. Creating a class, interface, and factory for it adds ceremony without containing change.

Do not wrap generic Selenium actions such as click() or getText() in methods with equally generic names. That hides the API while adding no domain meaning.

Avoid a component object that coordinates three independent regions of the page. That behavior belongs in the test flow or a higher-level page service. Otherwise the component gains knowledge it does not own and becomes difficult to reuse.

Finally, do not force tests through an abstraction that cannot expose necessary evidence. A carefully named diagnostic method that returns a domain snapshot is better than making fields public during an incident. The contract should make ordinary tests simpler and failures more local. If it only makes selectors harder to find, redesign it.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Should a Selenium component object return WebElements?

Usually it should return domain data or perform a named user action instead. Returning raw elements lets tests depend on private markup and spreads locator ownership back across the suite.

How do component objects avoid stale element errors?

Store a stable locator for the component root and resolve it again when a public method runs. This costs extra remote commands, but it avoids holding a WebElement that a framework re-render has detached.

Where should assertions live with page component objects?

Product assertions belong in the test so the scenario's expected result stays visible. A component may validate that it was constructed on the right kind of UI, but it should not decide the business outcome.

Is a data-testid a good component locator?

A dedicated test ID is useful when it expresses stable component identity and the team treats it as an interface. It becomes harmful when every nested div receives an implementation-shaped ID that changes with routine refactoring.

What is the difference between a page object and a component object?

A page object represents services offered by a page or route, while a component object models a smaller reusable region such as a product card, basket summary, or navigation bar. Components can be composed inside pages and nested when the UI has the same ownership structure.