CSS is the locator strategy you should default to. It's faster than XPath (the browser parses it natively, the same engine the page styles use), it's shorter to read and write, and it covers ~90% of the matching you'll ever do. This lesson is the working catalogue: every CSS construct you'll lean on, when each is the right tool, and the small set of cases where XPath still wins. By the end you'll know exactly when to reach for By.cssSelector and when to fall back.
The CSS selectors you already know
If you've written any CSS for styling, the basics carry over:
import org.openqa.selenium.By;
// By id
driver.findElement(By.cssSelector("#email-input"));
// By class
driver.findElement(By.cssSelector(".submit-button"));
// By tag
driver.findElement(By.cssSelector("button"));
// Compound — tag + class
driver.findElement(By.cssSelector("button.submit-button"));
// Compound — multiple classes (no space; means AND)
driver.findElement(By.cssSelector(".btn.btn-primary.active"));These are the ones you'd recognise from a stylesheet. In Selenium, the same syntax that styles an element selects it.
Attribute selectors — the workhorse
Attribute selectors are what make CSS competitive with XPath for QA work:
// Match by full attribute value
driver.findElement(By.cssSelector("[data-testid='submit']"));
driver.findElement(By.cssSelector("input[type='email']"));
driver.findElement(By.cssSelector("[name='password']"));
// Combine attribute + element type
driver.findElement(By.cssSelector("input[required]"));
// Attribute starts with (^=)
driver.findElement(By.cssSelector("[id^='user-']")); // id begins with "user-"
// Attribute ends with ($=)
driver.findElement(By.cssSelector("[data-action$='-submit']"));
// Attribute contains substring (*=)
driver.findElement(By.cssSelector("[id*='login']"));
// Attribute is a whitespace-separated word (~=)
driver.findElement(By.cssSelector("[class~='active']")); // matches class="active foo bar"The [attr*='substring'] pattern is the CSS equivalent of XPath's contains(@attr, '...'). Most teams converge on [data-testid='...'] as the test selector contract — when you have one, it's the cleanest selector in any framework.
Combinators — describing parent/child relationships
CSS combinators express how elements relate in the tree:
// Descendant (any depth) — a space
driver.findElement(By.cssSelector("form .submit-button")); // any .submit-button inside any form
driver.findElement(By.cssSelector("[data-testid='product-card'] .price"));
// Direct child — >
driver.findElement(By.cssSelector(".nav > li > a")); // exactly two levels down
// Adjacent sibling — +
driver.findElement(By.cssSelector("label + input")); // input immediately after label
// General sibling — ~
driver.findElement(By.cssSelector("h2 ~ p")); // any p that follows an h2 at the same levellabel + input is the CSS-native way to find an input next to its label — no XPath axis needed. It only works for next siblings, though. If you need previous siblings, CSS can't do it and XPath wins (preceding-sibling::).
Pseudo-classes for position
// First / last
driver.findElement(By.cssSelector(".product-card:first-child"));
driver.findElement(By.cssSelector(".product-card:last-child"));
// Nth child (1-indexed)
driver.findElement(By.cssSelector(".product-card:nth-child(3)"));
// Nth of type — counts only siblings of the same tag
driver.findElement(By.cssSelector("p:nth-of-type(2)"));
// :not — exclude
driver.findElement(By.cssSelector("button:not(.disabled)"));
driver.findElement(By.cssSelector("input:not([type='hidden'])"));
// Parametric nth-child — every other row
List<WebElement> evenRows = driver.findElements(By.cssSelector("tr:nth-child(even)"));:nth-child and :not(...) are the two pseudo-classes you'll write most often. The :not(.disabled) pattern is great for "any button that isn't currently greyed out."
A subtle but important detail: :nth-child(3) counts all siblings, regardless of tag. :nth-of-type(3) counts only siblings of the same tag. If a list has mixed <div> and <p> children, the two return different elements — pick deliberately.
Real-world patterns
A handful of patterns cover most production locators:
// data-testid (the gold standard)
By.cssSelector("[data-testid='submit-btn']")
// Inside a specific section
By.cssSelector("[data-testid='checkout-form'] input[name='card-number']")
// Active item in a list
By.cssSelector(".nav-item.active")
// Nth row of a table — useful for "the first user row"
By.cssSelector("table tbody tr:first-child")
// Form input by name — common, often unique
By.cssSelector("input[name='email']")
// All disabled inputs — for assertions
By.cssSelector("input[disabled]")
// Combine ID anchor with descendant
By.cssSelector("#login-form input[type='password']")Notice that almost every pattern uses an attribute selector in some form. data-testid and name= are the two most stable attributes you'll find on real pages — favour them over class names that may change with styling.
CSS vs XPath — the final answer
Same element, two locators
CSS — default choice
By.cssSelector("#login-form input[type='email']")
Shorter and easier to read
Faster — browser parses natively
Adjacent sibling (+) supported
No text matching
Cannot match element by visible text
No previous-sibling, no parent walking
CSS can't go up the tree
XPath — when CSS can't reach
By.xpath("//form[@id='login-form']//input[@type='email']")
Verbose for the same selection
Slightly slower
Text matching: text()='Submit'
Walks UP via parent::, ancestor::
preceding-sibling::, following-sibling::
The decision tree is short:
- Element has a stable ID? →
By.id(orBy.cssSelector("#x")). - Element has a
data-testid? →By.cssSelector("[data-testid='x']"). - Need to match by visible text or walk up the tree? → XPath.
- Otherwise → CSS.
Trying to do everything in XPath is a common beginner habit. A 90% CSS / 10% XPath codebase is more readable and faster to maintain.
Testing CSS selectors in DevTools
Same idea as XPath — iterate in the console first:
// Single match
document.querySelector("[data-testid='submit']")
// All matches
document.querySelectorAll(".product-card")
// Shortcut for querySelectorAll in DevTools
$$(".product-card")If the console returns the element you expected, paste the same string into By.cssSelector(...) in your Java code. Same selector, same result.
A real test using only CSS
package com.mycompany.tests.tests;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import java.util.List;
public class CssSelectorsTest {
WebDriver driver;
@BeforeMethod
public void setup() {
WebDriverManager.chromedriver().setup();
driver = new ChromeDriver();
driver.get("https://www.saucedemo.com");
driver.findElement(By.cssSelector("[data-test='username']")).sendKeys("standard_user");
driver.findElement(By.cssSelector("[data-test='password']")).sendKeys("secret_sauce");
driver.findElement(By.cssSelector("[data-test='login-button']")).click();
}
@Test
public void shouldHaveSixInventoryCards() {
List<WebElement> cards = driver.findElements(By.cssSelector("[data-test='inventory-item']"));
Assert.assertEquals(cards.size(), 6);
}
@Test
public void shouldShowFirstProductPrice() {
WebElement price = driver.findElement(
By.cssSelector("[data-test='inventory-item']:first-child .inventory_item_price")
);
Assert.assertTrue(price.getText().startsWith("$"));
}
@Test
public void shouldHaveOnlyEnabledAddButtons() {
// Negation — count buttons that aren't disabled
List<WebElement> enabledAddButtons = driver.findElements(
By.cssSelector("button.btn_inventory:not([disabled])")
);
Assert.assertEquals(enabledAddButtons.size(), 6);
}
@AfterMethod
public void teardown() {
if (driver != null) driver.quit();
}
}Three tests, four CSS techniques (attribute selector, descendant, :first-child, :not(...)), zero XPath. Run all three; all three pass.
The full reference lives on the XPath & CSS selectors cheat sheet on qa.codes — bookmark it.
⚠️ Common mistakes
- Quoting attribute values inconsistently.
[data-testid="submit"](double quotes inside Java's double-quoted string) is a syntax error in Java. Either escape ("[data-testid=\"submit\"]") or use single quotes ("[data-testid='submit']"). Pick the second; it's what every codebase does. - Confusing
:nth-childwith:nth-of-type.li:nth-child(3)returns the third sibling only if that sibling is an<li>. If the parent has a non-<li>child first, the count shifts.li:nth-of-type(3)always returns the third<li>. Use:nth-of-typewhenever a parent has mixed children. - Forgetting that CSS classes are space-separated. An element with
class="btn primary active"matches.btn,.primary, AND.active. Don't write[class='btn']— that requires the class attribute to be exactly the string "btn", which fails the moment any other class is present. Use.btn(which means "contains the class btn") instead.
🎯 Practice task
Build CSS selector fluency on Sauce Demo. 25–35 minutes.
- Add
CssSelectorsTestfrom this lesson to your project. Run all three tests; all should pass. - Add three more tests, each demonstrating one of the patterns from the lesson:
- Attribute starts-with: locate the inventory image elements via
[data-test^='inventory-item-']and assert there are six. :not(...): locate anyAdd to cartbutton that isn't disabled by class — start withbutton.btn_inventory:not(.btn_secondary).- Direct child (
>): drill into the cart icon using#shopping_cart_container > aand assert it's displayed.
- Attribute starts-with: locate the inventory image elements via
- CSS-vs-XPath face-off. For the same element on the page (the menu button at the top-right), write two locators:
- CSS:
By.cssSelector("#react-burger-menu-btn") - XPath:
By.xpath("//button[@id='react-burger-menu-btn']")Time both withSystem.nanoTime()over 1,000 iterations. CSS will measurably win — feel the speed difference.
- CSS:
- DevTools loop. Open Sauce Demo in Chrome, press F12, and use
$$(".product-card")in the console to discover that the page actually uses.inventory_item(not.product-card). The console gives you instant feedback that a Java compile-and-run loop never will. Make this your locator-development habit. - Stretch: rewrite all locators in
XPathPatternsTest(from the previous lesson) as CSS where possible. Anywhere CSS can't replace XPath, leave a comment explaining why (text match, parent walk, previous sibling). Compare the two files — your CSS file should be noticeably shorter.
Next lesson: now that you can find elements, do something with them. Clicking, typing, reading attributes, dealing with the four most common interaction-time exceptions — the methods every Selenium test runs hundreds of times per suite.