DEV Community

Mohammad Saddam Hossain
Mohammad Saddam Hossain

Posted on

I built self-healing Selenium locators in Java — here's how it works

The problem every Selenium team hits

Your test suite was green last week. Today it's failing on 12 tests. The error is always the same:

org.openqa.selenium.TimeoutException: Expected condition failed:
waiting for visibility of element located by By.cssSelector: #submit-btn
Enter fullscreen mode Exit fullscreen mode

You open the app. The button is right there. You inspect it — the id changed from submit-btn to btn-submit. A frontend developer renamed it during a refactor and didn't tell QA.

This is the #1 cause of flaky, high-maintenance Selenium suites. Locators are brittle by nature. The moment the DOM changes, your tests break — even when the feature itself works perfectly.

The standard advice is "use better locators" (data-testid, aria labels). Good advice. But you're inheriting 300 tests you didn't write, running against an app you don't own, and you need them green today.


What if the framework tried to fix itself?

The idea behind self-healing locators is simple: when a locator times out, don't immediately fail — try a set of fallback strategies derived from the original locator, and if one works, continue the test.

Here's the contract:

  1. The primary locator fails (timeout)
  2. The framework extracts fallback candidates from the locator descriptor
  3. Each fallback is tried in order
  4. If one succeeds, the test continues — and the heal is logged for the developer to review later
  5. If none succeed, the original exception is thrown

The test doesn't silently lie. It passes, but it marks itself as healed so you know a locator needs updating.


Parsing By.toString() to extract fallbacks

The interesting engineering problem is: given a By object, what fallback strategies can you derive?

Selenium's By doesn't expose its internals via a clean API — but By.toString() is predictable. By.cssSelector: #submit-btn tells you everything you need.

Here's the core of the parser:

public static List<By> buildFallbacks(By original) {
    String desc = original.toString();
    List<By> fallbacks = new ArrayList<>();

    if (desc.startsWith("By.cssSelector: ")) {
        String css = desc.substring("By.cssSelector: ".length()).trim();

        // #submit-btn → By.id("submit-btn")
        Matcher id = Pattern.compile("#([\\w-]+)").matcher(css);
        if (id.find()) fallbacks.add(By.id(id.group(1)));

        // [name='username'] → By.name("username")
        Matcher name = Pattern.compile("\\[name=['\"]?([\\w-]+)['\"]?]").matcher(css);
        if (name.find()) fallbacks.add(By.name(name.group(1)));

        // .primary-btn → By.className("primary-btn")
        Matcher cls = Pattern.compile("\\.([\\w-]+)").matcher(css);
        if (cls.find()) fallbacks.add(By.className(cls.group(1)));

        // [data-testid='submit'] → By.cssSelector("[data-testid='submit']")
        Matcher testid = Pattern.compile("\\[data-testid=['\"]?([\\w-]+)['\"]?]").matcher(css);
        if (testid.find()) fallbacks.add(By.cssSelector("[data-testid='" + testid.group(1) + "']"));

        // [placeholder='Email'] → By.cssSelector("[placeholder='Email']")
        Matcher ph = Pattern.compile("\\[placeholder=['\"]([^'\"]+)['\"]]").matcher(css);
        if (ph.find()) fallbacks.add(By.cssSelector("[placeholder='" + ph.group(1) + "']"));
    }

    if (desc.startsWith("By.xpath: ")) {
        String xpath = desc.substring("By.xpath: ".length()).trim();

        // @id='submit-btn' → By.id("submit-btn")
        Matcher id = Pattern.compile("@id=['\"]([^'\"]+)['\"]").matcher(xpath);
        if (id.find()) fallbacks.add(By.id(id.group(1)));

        // @name='username' → By.name("username")
        Matcher name = Pattern.compile("@name=['\"]([^'\"]+)['\"]").matcher(xpath);
        if (name.find()) fallbacks.add(By.name(name.group(1)));

        // text()='Submit' → By.xpath("//*[text()='Submit']")
        Matcher text = Pattern.compile("text\\(\\)=['\"]([^'\"]+)['\"]").matcher(xpath);
        if (text.find()) fallbacks.add(By.xpath("//*[text()='" + text.group(1) + "']"));
    }

    return fallbacks;
}
Enter fullscreen mode Exit fullscreen mode

Then the healing attempt:

public static WebElement tryHeal(WebDriver driver, By original) {
    List<By> fallbacks = buildFallbacks(original);
    for (By fallback : fallbacks) {
        try {
            WebElement el = driver.findElement(fallback);
            if (el.isDisplayed()) {
                logHeal(original, fallback); // record for report
                return el;
            }
        } catch (NoSuchElementException ignored) {}
    }
    return null; // all fallbacks failed
}
Enter fullscreen mode Exit fullscreen mode

Where it hooks into the wait layer

Self-healing only makes sense at the wait layer, not raw findElement. You want it to fire after you've already waited the full timeout — otherwise you'd be bailing out of waits too early.

public static WebElement waitForVisible(By locator) {
    try {
        return new WebDriverWait(driver, Duration.ofSeconds(timeout))
            .until(ExpectedConditions.visibilityOfElementLocated(locator));
    } catch (TimeoutException | NoSuchElementException e) {
        // Primary locator failed — try healing
        WebElement healed = SelfHealingLocator.tryHeal(driver, locator);
        if (healed != null) return healed;
        throw e; // nothing worked, surface the real error
    }
}
Enter fullscreen mode Exit fullscreen mode

The rest of your framework calls waitForVisible before every interaction — so every click, type, and assertion gets self-healing automatically.


Logging heals without noise

A healed locator is a warning, not a success. You want to know about it — but you don't want it to drown in test output. The right pattern is to accumulate healed locator records and export them after the suite runs:

// target/healed-locators.json
[
  {
    "test": "LoginTest#submitForm",
    "original": "By.cssSelector: #submit-btn",
    "healed":   "By.id: btn-submit",
    "timestamp": "2026-04-27T10:42:11"
  }
]
Enter fullscreen mode Exit fullscreen mode

A developer reviews this file after the run, updates the locator in the page object, and the test is back to clean. The framework does not silently swallow the problem — it surfaces it in a structured, actionable way.


Using it in Selenium Boot

I built all of this into Selenium Boot, an open-source zero-boilerplate Selenium + TestNG framework. To enable self-healing, you add one line to your config:

# selenium-boot.yml
locators:
  selfHealing: true
Enter fullscreen mode Exit fullscreen mode

That's it. No code changes. Every waitForVisible and waitForClickable call in your tests now has automatic fallback healing.

In the HTML report, healed tests get a ⚠ healed badge so they're immediately visible:

✓ LoginTest#submitForm  [⚠ healed]   1.2s
Enter fullscreen mode Exit fullscreen mode

And target/healed-locators.json gives you the exact diff to fix.


What healing covers (and what it doesn't)

Heals well:

  • id changed (most common — developers rename IDs during refactors)
  • name attribute changed
  • Class name changed (single-class selectors)
  • data-testid added/renamed
  • Text-based XPath when the element text is stable

Won't heal:

  • Structurally removed elements (the feature was deleted — this should fail)
  • Deep CSS selector chains (div.container > ul > li:nth-child(3))
  • Dynamic content that changes every render

Self-healing is a safety net for legitimate DOM drift, not a substitute for well-maintained locators. The goal is to keep the suite green through minor frontend changes while giving you a clear log of what needs updating.


The bigger takeaway

Locator brittleness is a maintenance tax, not an inherent property of Selenium. With ~100 lines of By.toString() parsing and a hook in your wait layer, you can cut a significant chunk of false failures — and turn every heal into a clear, actionable signal for the team.

The full implementation is in Selenium Boot on GitHub. If you're building a Selenium framework from scratch or inheriting one that's brittle, it's worth a look.


Example Screenshot Of The Report


What's your current strategy for dealing with locator churn? Drop it in the comments.

Top comments (0)